From dfbadaf7c24f3245e387f33f1347c2e53d74b820 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 8 Aug 2016 12:52:04 +0200 Subject: [PATCH] Split WebElementWrapper into abstract/webkit parts --- qutebrowser/browser/commands.py | 12 +- qutebrowser/browser/hints.py | 17 +- qutebrowser/browser/navigate.py | 2 +- qutebrowser/browser/webelem.py | 370 ++++++++++++++++++ qutebrowser/browser/webkit/mhtml.py | 8 +- .../webkit/{webelem.py => webkitelem.py} | 295 +++----------- qutebrowser/browser/webkit/webkittab.py | 6 +- qutebrowser/browser/webkit/webview.py | 16 +- .../{test_webelem.py => test_webkitelem.py} | 48 +-- 9 files changed, 470 insertions(+), 304 deletions(-) create mode 100644 qutebrowser/browser/webelem.py rename qutebrowser/browser/webkit/{webelem.py => webkitelem.py} (58%) rename tests/unit/browser/webkit/{test_webelem.py => test_webkitelem.py} (96%) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 33e778924..6848531db 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -40,7 +40,7 @@ import pygments.formatters from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configexc from qutebrowser.browser import urlmarks, browsertab, inspector, navigate -from qutebrowser.browser.webkit import webelem, downloads, mhtml +from qutebrowser.browser.webkit import webkitelem, downloads, mhtml from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, typing, javascript) @@ -1422,8 +1422,8 @@ class CommandDispatcher: tab = self._current_widget() page = tab._widget.page() # pylint: disable=protected-access try: - elem = webelem.focus_elem(page.currentFrame()) - except webelem.IsNullError: + elem = webkitelem.focus_elem(page.currentFrame()) + except webkitelem.IsNullError: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): raise cmdexc.CommandError("Focused element is not editable!") @@ -1444,7 +1444,7 @@ class CommandDispatcher: """ try: elem.set_text(text, use_js=True) - except webelem.IsNullError: + except webkitelem.IsNullError: raise cmdexc.CommandError("Element vanished while editing!") @cmdutils.register(instance='command-dispatcher', @@ -1456,8 +1456,8 @@ class CommandDispatcher: tab = self._current_widget() page = tab._widget.page() # pylint: disable=protected-access try: - elem = webelem.focus_elem(page.currentFrame()) - except webelem.IsNullError: + elem = webkitelem.focus_elem(page.currentFrame()) + except webkitelem.IsNullError: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): raise cmdexc.CommandError("Focused element is not editable!") diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index a886915b5..146a9af35 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -28,12 +28,11 @@ from string import ascii_lowercase from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) from PyQt5.QtGui import QMouseEvent -from PyQt5.QtWebKit import QWebElement from PyQt5.QtWebKitWidgets import QWebPage from qutebrowser.config import config from qutebrowser.keyinput import modeman, modeparsers -from qutebrowser.browser.webkit import webelem +from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils @@ -374,7 +373,7 @@ class HintManager(QObject): for elem in self._context.all_elems: try: elem.label.remove_from_document() - except webelem.IsNullError: + except webelem.Error: pass text = self._get_text() message_bridge = objreg.get('message-bridge', scope='window', @@ -516,7 +515,7 @@ class HintManager(QObject): def _is_hidden(self, elem): """Check if the element is hidden via display=none.""" - display = elem.style_property('display', QWebElement.InlineStyle) + display = elem.style_property('display', strategy='inline') return display == 'none' def _show_elem(self, elem): @@ -767,7 +766,7 @@ class HintManager(QObject): else: # element doesn't match anymore -> hide it self._hide_elem(elem.label) - except webelem.IsNullError: + except webelem.Error: pass def _filter_number_hints(self): @@ -782,7 +781,7 @@ class HintManager(QObject): try: if not self._is_hidden(e.label): elems.append(e) - except webelem.IsNullError: + except webelem.Error: pass if not elems: # Whoops, filtered all hints @@ -813,7 +812,7 @@ class HintManager(QObject): try: if not self._is_hidden(elem.label): visible[string] = elem - except webelem.IsNullError: + except webelem.Error: pass if not visible: # Whoops, filtered all hints @@ -844,7 +843,7 @@ class HintManager(QObject): else: # element doesn't match anymore -> hide it self._hide_elem(elem.label) - except webelem.IsNullError: + except webelem.Error: pass if config.get('hints', 'mode') == 'number': @@ -961,7 +960,7 @@ class HintManager(QObject): e.label.remove_from_document() continue self._set_style_position(e.elem, e.label) - except webelem.IsNullError: + except webelem.Error: pass @pyqtSlot(usertypes.KeyMode) diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index 9689105f3..04d4c6be9 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -21,7 +21,7 @@ import posixpath -from qutebrowser.browser.webkit import webelem +from qutebrowser.browser import webelem from qutebrowser.config import config from qutebrowser.utils import (usertypes, objreg, urlutils, log, message, qtutils) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py new file mode 100644 index 000000000..c1910145a --- /dev/null +++ b/qutebrowser/browser/webelem.py @@ -0,0 +1,370 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Generic web element related code. + +Module attributes: + Group: Enum for different kinds of groups. + SELECTORS: CSS selectors for different groups of elements. + FILTERS: A dictionary of filter functions for the modes. + The filter for "links" filters javascript:-links and a-tags + without "href". +""" + +import collections.abc + +from PyQt5.QtCore import QUrl + +from qutebrowser.config import config +from qutebrowser.utils import log, usertypes, utils, qtutils + + +Group = usertypes.enum('Group', ['all', 'links', 'images', 'url', 'prevnext', + 'focus', 'inputs']) + + +SELECTORS = { + Group.all: ('a, area, textarea, select, input:not([type=hidden]), button, ' + 'frame, iframe, link, [onclick], [onmousedown], [role=link], ' + '[role=option], [role=button], img'), + Group.links: 'a, area, link, [role=link]', + Group.images: 'img', + Group.url: '[src], [href]', + Group.prevnext: 'a, area, button, link, [role=button]', + Group.focus: '*:focus', + Group.inputs: ('input[type=text], input[type=email], input[type=url], ' + 'input[type=tel], input[type=number], ' + 'input[type=password], input[type=search], ' + 'input:not([type]), textarea'), +} + + +def filter_links(elem): + return 'href' in elem and QUrl(elem['href']).scheme() != 'javascript' + + +FILTERS = { + Group.links: filter_links, + Group.prevnext: filter_links, +} + + +class Error(Exception): + + """Base class for WebElement errors.""" + + pass + + +class AbstractWebElement(collections.abc.MutableMapping): + + """A wrapper around QtWebKit/QtWebEngine web element.""" + + def __eq__(self, other): + raise NotImplementedError + + def __str__(self): + raise NotImplementedError + + def __getitem__(self, key): + raise NotImplementedError + + def __setitem__(self, key, val): + raise NotImplementedError + + def __delitem__(self, key): + raise NotImplementedError + + def __contains__(self, key): + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __len__(self): + raise NotImplementedError + + def __repr__(self): + try: + html = self.debug_text() + except Error: + html = None + return utils.get_repr(self, html=html) + + def frame(self): + """Get the main frame of this element.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def geometry(self): + """Get the geometry for this element.""" + raise NotImplementedError + + def document_element(self): + """Get the document element of this element.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def create_inside(self, tagname): + """Append the given element inside the current one.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def find_first(self, selector): + """Find the first child based on the given CSS selector.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def style_property(self, name, *, strategy): + """Get the element style resolved with the given strategy.""" + raise NotImplementedError + + def classes(self): + """Get a list of classes assigned to this element.""" + raise NotImplementedError + + def tag_name(self): + """Get the tag name of this element.""" + raise NotImplementedError + + def outer_xml(self): + """Get the full HTML representation of this element.""" + raise NotImplementedError + + def text(self, *, use_js=False): + """Get the plain text content for this element. + + Args: + use_js: Whether to use javascript if the element isn't + content-editable. + """ + # FIXME:qtwebengine what to do about use_js with WebEngine? + raise NotImplementedError + + def set_text(self, text, *, use_js=False): + """Set the given plain text. + + Args: + use_js: Whether to use javascript if the element isn't + content-editable. + """ + # FIXME:qtwebengine what to do about use_js with WebEngine? + raise NotImplementedError + + def set_inner_xml(self, xml): + """Set the given inner XML.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def remove_from_document(self): + """Remove the node from the document.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def set_style_property(self, name, value): + """Set the element style.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def run_js_async(self, code, callback=None): + """Run the given JS snippet async on the element.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def parent(self): + """Get the parent element of this element.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def rect_on_view(self, *, elem_geometry=None, adjust_zoom=True, + no_js=False): + """Get the geometry of the element relative to the webview. + + Uses the getClientRects() JavaScript method to obtain the collection of + rectangles containing the element and returns the first rectangle which + is large enough (larger than 1px times 1px). If all rectangles returned + by getClientRects() are too small, falls back to elem.rect_on_view(). + + Skipping of small rectangles is due to elements containing other + elements with "display:block" style, see + https://github.com/The-Compiler/qutebrowser/issues/1298 + + Args: + elem_geometry: The geometry of the element, or None. + Calling QWebElement::geometry is rather expensive so + we want to avoid doing it twice. + adjust_zoom: Whether to adjust the element position based on the + current zoom level. + no_js: Fall back to the Python implementation + """ + raise NotImplementedError + + def is_visible(self, mainframe): + """Check if the given element is visible in the given frame.""" + # FIXME:qtwebengine get rid of this? + raise NotImplementedError + + def is_writable(self): + """Check whether an element is writable.""" + return not ('disabled' in self or 'readonly' in self) + + def is_content_editable(self): + """Check if an element has a contenteditable attribute. + + Args: + elem: The QWebElement to check. + + Return: + True if the element has a contenteditable attribute, + False otherwise. + """ + try: + return self['contenteditable'].lower() not in ['false', 'inherit'] + except KeyError: + return False + + def _is_editable_object(self): + """Check if an object-element is editable.""" + if 'type' not in self: + log.webview.debug(" without type clicked...") + return False + objtype = self['type'].lower() + if objtype.startswith('application/') or 'classid' in self: + # Let's hope flash/java stuff has an application/* mimetype OR + # at least a classid attribute. Oh, and let's hope images/... + # DON'T have a classid attribute. HTML sucks. + log.webview.debug(" clicked.".format(objtype)) + return config.get('input', 'insert-mode-on-plugins') + else: + # Image/Audio/... + return False + + def _is_editable_input(self): + """Check if an input-element is editable. + + Return: + True if the element is editable, False otherwise. + """ + try: + objtype = self['type'].lower() + except KeyError: + return self.is_writable() + else: + if objtype in ['text', 'email', 'url', 'tel', 'number', 'password', + 'search']: + return self.is_writable() + else: + return False + + def _is_editable_div(self): + """Check if a div-element is editable. + + Return: + True if the element is editable, False otherwise. + """ + # Beginnings of div-classes which are actually some kind of editor. + div_classes = ('CodeMirror', # Javascript editor over a textarea + 'kix-', # Google Docs editor + 'ace_') # http://ace.c9.io/ + for klass in self.classes(): + if any([klass.startswith(e) for e in div_classes]): + return True + return False + + def is_editable(self, strict=False): + """Check whether we should switch to insert mode for this element. + + Args: + strict: Whether to do stricter checking so only fields where we can + get the value match, for use with the :editor command. + + Return: + True if we should switch to insert mode, False otherwise. + """ + roles = ('combobox', 'textbox') + log.misc.debug("Checking if element is editable: {}".format( + repr(self))) + tag = self.tag_name().lower() + if self.is_content_editable() and self.is_writable(): + return True + elif self.get('role', None) in roles and self.is_writable(): + return True + elif tag == 'input': + return self._is_editable_input() + elif tag == 'textarea': + return self.is_writable() + elif tag in ['embed', 'applet']: + # Flash/Java/... + return config.get('input', 'insert-mode-on-plugins') and not strict + elif tag == 'object': + return self._is_editable_object() and not strict + elif tag == 'div': + return self._is_editable_div() and not strict + else: + return False + + def is_text_input(self): + """Check if this element is some kind of text box.""" + roles = ('combobox', 'textbox') + tag = self.tag_name().lower() + return self.get('role', None) in roles or tag in ['input', 'textarea'] + + def remove_blank_target(self): + """Remove target from link.""" + elem = self + for _ in range(5): + if elem is None: + break + tag = elem.tag_name().lower() + if tag == 'a' or tag == 'area': + if elem.get('target', None) == '_blank': + elem['target'] = '_top' + break + elem = elem.parent() + + def debug_text(self): + """Get a text based on an element suitable for debug output.""" + return utils.compact_text(self.outer_xml(), 500) + + def resolve_url(self, baseurl): + """Resolve the URL in the element's src/href attribute. + + Args: + baseurl: The URL to base relative URLs on as QUrl. + + Return: + A QUrl with the absolute URL, or None. + """ + if baseurl.isRelative(): + raise ValueError("Need an absolute base URL!") + + for attr in ['href', 'src']: + if attr in self: + text = self[attr].strip() + break + else: + return None + + url = QUrl(text) + if not url.isValid(): + return None + if url.isRelative(): + url = baseurl.resolved(url) + qtutils.ensure_valid(url) + return url diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index 8a157fa03..2928c5a3f 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -34,7 +34,7 @@ import email.message from PyQt5.QtCore import QUrl -from qutebrowser.browser.webkit import webelem, downloads +from qutebrowser.browser.webkit import webkitelem, downloads from qutebrowser.utils import log, objreg, message, usertypes, utils, urlutils try: @@ -271,7 +271,7 @@ class _Downloader: elements = web_frame.findAllElements('link, script, img') for element in elements: - element = webelem.WebElementWrapper(element) + element = webkitelem.WebKitElement(element) # Websites are free to set whatever rel=... attribute they want. # We just care about stylesheets and icons. if not _check_rel(element): @@ -288,7 +288,7 @@ class _Downloader: styles = web_frame.findAllElements('style') for style in styles: - style = webelem.WebElementWrapper(style) + style = webkitelem.WebKitElement(style) # The Mozilla Developer Network says: # type: This attribute defines the styling language as a MIME type # (charset should not be specified). This attribute is optional and @@ -301,7 +301,7 @@ class _Downloader: # Search for references in inline styles for element in web_frame.findAllElements('[style]'): - element = webelem.WebElementWrapper(element) + element = webkitelem.WebKitElement(element) style = element['style'] for element_url in _get_css_imports(style, inline=True): self._fetch_url(web_url.resolved(QUrl(element_url))) diff --git a/qutebrowser/browser/webkit/webelem.py b/qutebrowser/browser/webkit/webkitelem.py similarity index 58% rename from qutebrowser/browser/webkit/webelem.py rename to qutebrowser/browser/webkit/webkitelem.py index 2eb0e2e05..3bbb8a1ae 100644 --- a/qutebrowser/browser/webkit/webelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -17,65 +17,26 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Utilities related to QWebElements. +"""QtWebKit specific part of the web element API.""" -Module attributes: - Group: Enum for different kinds of groups. - SELECTORS: CSS selectors for different groups of elements. - FILTERS: A dictionary of filter functions for the modes. - The filter for "links" filters javascript:-links and a-tags - without "href". -""" - -import collections.abc - -from PyQt5.QtCore import QRect, QUrl +from PyQt5.QtCore import QRect from PyQt5.QtWebKit import QWebElement from qutebrowser.config import config -from qutebrowser.utils import log, usertypes, utils, javascript, qtutils +from qutebrowser.utils import log, utils, javascript +from qutebrowser.browser import webelem -Group = usertypes.enum('Group', ['all', 'links', 'images', 'url', 'prevnext', - 'focus', 'inputs']) +class IsNullError(webelem.Error): - -SELECTORS = { - Group.all: ('a, area, textarea, select, input:not([type=hidden]), button, ' - 'frame, iframe, link, [onclick], [onmousedown], [role=link], ' - '[role=option], [role=button], img'), - Group.links: 'a, area, link, [role=link]', - Group.images: 'img', - Group.url: '[src], [href]', - Group.prevnext: 'a, area, button, link, [role=button]', - Group.focus: '*:focus', - Group.inputs: ('input[type=text], input[type=email], input[type=url], ' - 'input[type=tel], input[type=number], ' - 'input[type=password], input[type=search], ' - 'input:not([type]), textarea'), -} - - -def filter_links(elem): - return 'href' in elem and QUrl(elem['href']).scheme() != 'javascript' - - -FILTERS = { - Group.links: filter_links, - Group.prevnext: filter_links, -} - - -class IsNullError(Exception): - - """Gets raised by WebElementWrapper if an element is null.""" + """Gets raised by WebKitElement if an element is null.""" pass -class WebElementWrapper(collections.abc.MutableMapping): +class WebKitElement(webelem.AbstractWebElement): - """A wrapper around QWebElement to make it more intelligent.""" + """A wrapper around a QWebElement.""" def __init__(self, elem): if isinstance(elem, self.__class__): @@ -85,7 +46,7 @@ class WebElementWrapper(collections.abc.MutableMapping): self._elem = elem def __eq__(self, other): - if not isinstance(other, WebElementWrapper): + if not isinstance(other, WebKitElement): return NotImplemented return self._elem == other._elem # pylint: disable=protected-access @@ -93,13 +54,6 @@ class WebElementWrapper(collections.abc.MutableMapping): self._check_vanished() return self._elem.toPlainText() - def __repr__(self): - try: - html = self.debug_text() - except IsNullError: - html = None - return utils.get_repr(self, html=html) - def __getitem__(self, key): self._check_vanished() if key not in self: @@ -134,24 +88,19 @@ class WebElementWrapper(collections.abc.MutableMapping): raise IsNullError('Element {} vanished!'.format(self._elem)) def frame(self): - """Get the main frame of this element.""" - # FIXME:qtwebengine how to get rid of this? self._check_vanished() return self._elem.webFrame() def geometry(self): - """Get the geometry for this element.""" self._check_vanished() return self._elem.geometry() def document_element(self): - """Get the document element of this element.""" self._check_vanished() elem = self._elem.webFrame().documentElement() - return WebElementWrapper(elem) + return WebKitElement(elem) def create_inside(self, tagname): - """Append the given element inside the current one.""" # It seems impossible to create an empty QWebElement for which isNull() # is false so we can work with it. # As a workaround, we use appendInside() with markup as argument, and @@ -159,28 +108,40 @@ class WebElementWrapper(collections.abc.MutableMapping): # See: http://stackoverflow.com/q/7364852/2085149 self._check_vanished() self._elem.appendInside('<{}>'.format(tagname, tagname)) - return WebElementWrapper(self._elem.lastChild()) + return WebKitElement(self._elem.lastChild()) def find_first(self, selector): - """Find the first child based on the given CSS selector.""" self._check_vanished() elem = self._elem.findFirst(selector) if elem.isNull(): return None - return WebElementWrapper(elem) + return WebKitElement(elem) - def style_property(self, name, strategy): - """Get the element style resolved with the given strategy.""" + def style_property(self, name, *, strategy): self._check_vanished() - return self._elem.styleProperty(name, strategy) + strategies = { + # FIXME:qtwebengine which ones do we actually need? + 'inline': QWebElement.InlineStyle, + 'computed': QWebElement.ComputedStyle, + } + qt_strategy = strategies[strategy] + return self._elem.styleProperty(name, qt_strategy) + + def classes(self): + self._check_vanished() + return self._elem.classes() + + def tag_name(self): + """Get the tag name for the current element.""" + self._check_vanished() + return self._elem.tagName() + + def outer_xml(self): + """Get the full HTML representation of this element.""" + self._check_vanished() + return self._elem.toOuterXml() def text(self, *, use_js=False): - """Get the plain text content for this element. - - Args: - use_js: Whether to use javascript if the element isn't - content-editable. - """ self._check_vanished() if self.is_content_editable() or not use_js: return self._elem.toPlainText() @@ -188,12 +149,6 @@ class WebElementWrapper(collections.abc.MutableMapping): return self._elem.evaluateJavaScript('this.value') def set_text(self, text, *, use_js=False): - """Set the given plain text. - - Args: - use_js: Whether to use javascript if the element isn't - content-editable. - """ self._check_vanished() if self.is_content_editable() or not use_js: log.misc.debug("Filling element {} via set_text.".format( @@ -206,158 +161,17 @@ class WebElementWrapper(collections.abc.MutableMapping): self._elem.evaluateJavaScript("this.value='{}'".format(text)) def set_inner_xml(self, xml): - """Set the given inner XML.""" self._check_vanished() self._elem.setInnerXml(xml) def remove_from_document(self): - """Remove the node from the document.""" self._check_vanished() self._elem.removeFromDocument() def set_style_property(self, name, value): - """Set the element style.""" self._check_vanished() return self._elem.setStyleProperty(name, value) - def is_writable(self): - """Check whether an element is writable.""" - self._check_vanished() - return not ('disabled' in self or 'readonly' in self) - - def is_content_editable(self): - """Check if an element has a contenteditable attribute. - - Args: - elem: The QWebElement to check. - - Return: - True if the element has a contenteditable attribute, - False otherwise. - """ - self._check_vanished() - try: - return self['contenteditable'].lower() not in ['false', 'inherit'] - except KeyError: - return False - - def _is_editable_object(self): - """Check if an object-element is editable.""" - if 'type' not in self: - log.webview.debug(" without type clicked...") - return False - objtype = self['type'].lower() - if objtype.startswith('application/') or 'classid' in self: - # Let's hope flash/java stuff has an application/* mimetype OR - # at least a classid attribute. Oh, and let's hope images/... - # DON'T have a classid attribute. HTML sucks. - log.webview.debug(" clicked.".format(objtype)) - return config.get('input', 'insert-mode-on-plugins') - else: - # Image/Audio/... - return False - - def _is_editable_input(self): - """Check if an input-element is editable. - - Return: - True if the element is editable, False otherwise. - """ - try: - objtype = self['type'].lower() - except KeyError: - return self.is_writable() - else: - if objtype in ['text', 'email', 'url', 'tel', 'number', 'password', - 'search']: - return self.is_writable() - else: - return False - - def _is_editable_div(self): - """Check if a div-element is editable. - - Return: - True if the element is editable, False otherwise. - """ - # Beginnings of div-classes which are actually some kind of editor. - div_classes = ('CodeMirror', # Javascript editor over a textarea - 'kix-', # Google Docs editor - 'ace_') # http://ace.c9.io/ - for klass in self._elem.classes(): - if any([klass.startswith(e) for e in div_classes]): - return True - return False - - def is_editable(self, strict=False): - """Check whether we should switch to insert mode for this element. - - Args: - strict: Whether to do stricter checking so only fields where we can - get the value match, for use with the :editor command. - - Return: - True if we should switch to insert mode, False otherwise. - """ - self._check_vanished() - roles = ('combobox', 'textbox') - log.misc.debug("Checking if element is editable: {}".format( - repr(self))) - tag = self._elem.tagName().lower() - if self.is_content_editable() and self.is_writable(): - return True - elif self.get('role', None) in roles and self.is_writable(): - return True - elif tag == 'input': - return self._is_editable_input() - elif tag == 'textarea': - return self.is_writable() - elif tag in ['embed', 'applet']: - # Flash/Java/... - return config.get('input', 'insert-mode-on-plugins') and not strict - elif tag == 'object': - return self._is_editable_object() and not strict - elif tag == 'div': - return self._is_editable_div() and not strict - else: - return False - - def is_text_input(self): - """Check if this element is some kind of text box.""" - self._check_vanished() - roles = ('combobox', 'textbox') - tag = self._elem.tagName().lower() - return self.get('role', None) in roles or tag in ['input', 'textarea'] - - def remove_blank_target(self): - """Remove target from link.""" - self._check_vanished() - elem = self._elem - for _ in range(5): - if elem is None: - break - tag = elem.tagName().lower() - if tag == 'a' or tag == 'area': - if elem.attribute('target') == '_blank': - elem.setAttribute('target', '_top') - break - elem = elem.parent() - - def debug_text(self): - """Get a text based on an element suitable for debug output.""" - self._check_vanished() - return utils.compact_text(self._elem.toOuterXml(), 500) - - def outer_xml(self): - """Get the full HTML representation of this element.""" - self._check_vanished() - return self._elem.toOuterXml() - - def tag_name(self): - """Get the tag name for the current element.""" - self._check_vanished() - return self._elem.tagName() - def run_js_async(self, code, callback=None): """Run the given JS snippet async on the element.""" self._check_vanished() @@ -365,8 +179,16 @@ class WebElementWrapper(collections.abc.MutableMapping): if callback is not None: callback(result) + def parent(self): + self._check_vanished() + elem = self._elem.parent() + if elem is None: + return None + return WebKitElement(elem) + def _rect_on_view_js(self, adjust_zoom): """Javascript implementation for rect_on_view.""" + # FIXME:qtwebengine maybe we can reuse this? rects = self._elem.evaluateJavaScript("this.getClientRects()") if rects is None: # pragma: no cover # Depending on unknown circumstances, this might not work with JS @@ -444,6 +266,8 @@ class WebElementWrapper(collections.abc.MutableMapping): current zoom level. no_js: Fall back to the Python implementation """ + # FIXME:qtwebengine can we get rid of this with + # find_all_elements(only_visible=True)? self._check_vanished() # First try getting the element rect via JS, as that's usually more @@ -500,33 +324,6 @@ class WebElementWrapper(collections.abc.MutableMapping): visible_in_frame = visible_on_screen return all([visible_on_screen, visible_in_frame]) - def resolve_url(self, baseurl): - """Resolve the URL in the element's src/href attribute. - - Args: - baseurl: The URL to base relative URLs on as QUrl. - - Return: - A QUrl with the absolute URL, or None. - """ - if baseurl.isRelative(): - raise ValueError("Need an absolute base URL!") - - for attr in ['href', 'src']: - if attr in self: - text = self[attr].strip() - break - else: - return None - - url = QUrl(text) - if not url.isValid(): - return None - if url.isRelative(): - url = baseurl.resolved(url) - qtutils.ensure_valid(url) - return url - def get_child_frames(startframe): """Get all children recursively of a given QWebFrame. @@ -556,5 +353,5 @@ def focus_elem(frame): Args: frame: The QWebFrame to search in. """ - elem = frame.findFirstElement(SELECTORS[Group.focus]) - return WebElementWrapper(elem) + elem = frame.findFirstElement(webelem.SELECTORS[webelem.Group.focus]) + return WebKitElement(elem) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 5dce30abd..0f479886c 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -31,7 +31,7 @@ from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab -from qutebrowser.browser.webkit import webview, tabhistory, webelem +from qutebrowser.browser.webkit import webview, tabhistory, webkitelem from qutebrowser.utils import qtutils, objreg, usertypes, utils @@ -564,10 +564,10 @@ class WebKitTab(browsertab.AbstractTab): raise browsertab.WebTabError("No frame focused!") elems = [] - frames = webelem.get_child_frames(mainframe) + frames = webkitelem.get_child_frames(mainframe) for f in frames: for elem in f.findAllElements(selector): - elems.append(webelem.WebElementWrapper(elem)) + elems.append(webkitelem.WebKitElement(elem)) if only_visible: elems = [e for e in elems if e.is_visible(mainframe)] diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index ee562ceda..e871cdb4f 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -31,7 +31,7 @@ from qutebrowser.config import config from qutebrowser.keyinput import modeman from qutebrowser.utils import message, log, usertypes, utils, qtutils, objreg from qutebrowser.browser import hints -from qutebrowser.browser.webkit import webpage, webelem +from qutebrowser.browser.webkit import webpage, webkitelem class WebView(QWebView): @@ -196,13 +196,13 @@ class WebView(QWebView): if hitresult.isNull(): # For some reason, the whole hit result can be null sometimes (e.g. # on doodle menu links). If this is the case, we schedule a check - # later (in mouseReleaseEvent) which uses webelem.focus_elem. + # later (in mouseReleaseEvent) which uses webkitelem.focus_elem. log.mouse.debug("Hitresult is null!") self._check_insertmode = True return try: - elem = webelem.WebElementWrapper(hitresult.element()) - except webelem.IsNullError: + elem = webkitelem.WebKitElement(hitresult.element()) + except webkitelem.IsNullError: # For some reason, the hit result element can be a null element # sometimes (e.g. when clicking the timetable fields on # http://www.sbb.ch/ ). If this is the case, we schedule a check @@ -227,8 +227,8 @@ class WebView(QWebView): return self._check_insertmode = False try: - elem = webelem.focus_elem(self.page().currentFrame()) - except (webelem.IsNullError, RuntimeError): + elem = webkitelem.focus_elem(self.page().currentFrame()) + except (webkitelem.IsNullError, RuntimeError): log.mouse.debug("Element/page vanished!") return if elem.is_editable(): @@ -325,8 +325,8 @@ class WebView(QWebView): return frame = self.page().currentFrame() try: - elem = webelem.WebElementWrapper(frame.findFirstElement(':focus')) - except webelem.IsNullError: + elem = webkitelem.WebKitElement(frame.findFirstElement(':focus')) + except webkitelem.IsNullError: log.webview.debug("Focused element is null!") return log.modes.debug("focus element: {}".format(repr(elem))) diff --git a/tests/unit/browser/webkit/test_webelem.py b/tests/unit/browser/webkit/test_webkitelem.py similarity index 96% rename from tests/unit/browser/webkit/test_webelem.py rename to tests/unit/browser/webkit/test_webkitelem.py index 774bc4911..635c3a1b0 100644 --- a/tests/unit/browser/webkit/test_webelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -28,13 +28,14 @@ from PyQt5.QtCore import QRect, QPoint, QUrl from PyQt5.QtWebKit import QWebElement import pytest -from qutebrowser.browser.webkit import webelem +from qutebrowser.browser import webelem +from qutebrowser.browser.webkit import webkitelem def get_webelem(geometry=None, frame=None, *, null=False, style=None, attributes=None, tagname=None, classes=None, parent=None, js_rect_return=None, zoom_text_only=False): - """Factory for WebElementWrapper objects based on a mock. + """Factory for WebKitElement objects based on a mock. Args: geometry: The geometry of the QWebElement as QRect. @@ -117,7 +118,7 @@ def get_webelem(geometry=None, frame=None, *, null=False, style=None, return style_dict[name] elem.styleProperty.side_effect = _style_property - wrapped = webelem.WebElementWrapper(elem) + wrapped = webkitelem.WebKitElement(elem) return wrapped @@ -215,15 +216,14 @@ class TestSelectorsAndFilters: # Make sure setting HTML succeeded and there's a new element assert len(webframe.findAllElements('*')) == 3 elems = webframe.findAllElements(webelem.SELECTORS[group]) - elems = [webelem.WebElementWrapper(e) for e in elems] + elems = [webkitelem.WebKitElement(e) for e in elems] filterfunc = webelem.FILTERS.get(group, lambda e: True) elems = [e for e in elems if filterfunc(e)] assert bool(elems) == matching +class TestWebKitElement: -class TestWebElementWrapper: - - """Generic tests for WebElementWrapper. + """Generic tests for WebKitElement. Note: For some methods, there's a dedicated test class with more involved tests. @@ -235,13 +235,13 @@ class TestWebElementWrapper: def test_nullelem(self): """Test __init__ with a null element.""" - with pytest.raises(webelem.IsNullError): + with pytest.raises(webkitelem.IsNullError): get_webelem(null=True) def test_double_wrap(self, elem): - """Test wrapping a WebElementWrapper.""" + """Test wrapping a WebKitElement.""" with pytest.raises(TypeError) as excinfo: - webelem.WebElementWrapper(elem) + webkitelem.WebKitElement(elem) assert str(excinfo.value) == "Trying to wrap a wrapper!" @pytest.mark.parametrize('code', [ @@ -257,7 +257,7 @@ class TestWebElementWrapper: lambda e: e.document_element(), lambda e: e.create_inside('span'), lambda e: e.find_first('span'), - lambda e: e.style_property('visibility', QWebElement.ComputedStyle), + lambda e: e.style_property('visibility', strategy='computed'), lambda e: e.text(), lambda e: e.set_text('foo'), lambda e: e.set_inner_xml(''), @@ -285,16 +285,16 @@ class TestWebElementWrapper: """Make sure methods check if the element is vanished.""" elem._elem.isNull.return_value = True elem._elem.tagName.return_value = 'span' - with pytest.raises(webelem.IsNullError): + with pytest.raises(webkitelem.IsNullError): code(elem) def test_str(self, elem): assert str(elem) == 'text' @pytest.mark.parametrize('is_null, expected', [ - (False, ""), - (True, ''), ]) def test_repr(self, elem, is_null, expected): @@ -334,7 +334,7 @@ class TestWebElementWrapper: def test_eq(self): one = get_webelem() - two = webelem.WebElementWrapper(one._elem) + two = webkitelem.WebKitElement(one._elem) assert one == two def test_eq_other_type(self): @@ -422,7 +422,7 @@ class TestWebElementWrapper: mock.assert_called_with(*args) def test_style_property(self, elem): - assert elem.style_property('foo', QWebElement.ComputedStyle) == 'bar' + assert elem.style_property('foo', strategy='computed') == 'bar' def test_document_element(self, stubs): doc_elem = get_webelem() @@ -430,14 +430,14 @@ class TestWebElementWrapper: elem = get_webelem(frame=frame) doc_elem_ret = elem.document_element() - assert isinstance(doc_elem_ret, webelem.WebElementWrapper) + assert isinstance(doc_elem_ret, webkitelem.WebKitElement) assert doc_elem_ret == doc_elem def test_find_first(self, elem): result = get_webelem() elem._elem.findFirst.return_value = result._elem find_result = elem.find_first('') - assert isinstance(find_result, webelem.WebElementWrapper) + assert isinstance(find_result, webkitelem.WebKitElement) assert find_result == result def test_create_inside(self, elem): @@ -727,7 +727,7 @@ def test_focus_element(stubs): frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100)) elem = get_webelem() frame.focus_elem = elem._elem - assert webelem.focus_elem(frame)._elem is elem._elem + assert webkitelem.focus_elem(frame)._elem is elem._elem class TestRectOnView: @@ -739,7 +739,7 @@ class TestRectOnView: This is needed for all the tests calling rect_on_view or is_visible. """ config_stub.data = {'ui': {'zoom-text-only': 'true'}} - monkeypatch.setattr('qutebrowser.browser.webkit.webelem.config', + monkeypatch.setattr('qutebrowser.browser.webkit.webkitelem.config', config_stub) return config_stub @@ -821,7 +821,7 @@ class TestGetChildFrames: def test_single_frame(self, stubs): """Test get_child_frames with a single frame without children.""" frame = stubs.FakeChildrenFrame() - children = webelem.get_child_frames(frame) + children = webkitelem.get_child_frames(frame) assert len(children) == 1 assert children[0] is frame frame.childFrames.assert_called_once_with() @@ -836,7 +836,7 @@ class TestGetChildFrames: child1 = stubs.FakeChildrenFrame() child2 = stubs.FakeChildrenFrame() parent = stubs.FakeChildrenFrame([child1, child2]) - children = webelem.get_child_frames(parent) + children = webkitelem.get_child_frames(parent) assert len(children) == 3 assert children[0] is parent assert children[1] is child1 @@ -858,7 +858,7 @@ class TestGetChildFrames: first = [stubs.FakeChildrenFrame(second[0:2]), stubs.FakeChildrenFrame(second[2:4])] root = stubs.FakeChildrenFrame(first) - children = webelem.get_child_frames(root) + children = webkitelem.get_child_frames(root) assert len(children) == 7 assert children[0] is root for frame in [root] + first + second: @@ -873,7 +873,7 @@ class TestIsEditable: def stubbed_config(self, config_stub, monkeypatch): """Fixture to create a config stub with an input section.""" config_stub.data = {'input': {}} - monkeypatch.setattr('qutebrowser.browser.webkit.webelem.config', + monkeypatch.setattr('qutebrowser.browser.webkit.webkitelem.config', config_stub) return config_stub