diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 5886d851e..996edc842 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -21,7 +21,7 @@ import itertools -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QPoint +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QPoint, QSizeF from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget, QLayout @@ -482,6 +482,7 @@ class AbstractTab(QWidget): new_tab_requested = pyqtSignal(QUrl) url_changed = pyqtSignal(QUrl) shutting_down = pyqtSignal() + contents_size_changed = pyqtSignal(QSizeF) def __init__(self, win_id, parent=None): self.win_id = win_id @@ -632,6 +633,15 @@ class AbstractTab(QWidget): def set_html(self, html, base_url): raise NotImplementedError + def find_all_elements(self, selector, *, only_visible=False): + """Find all HTML elements matching a given selector. + + Args: + selector: The CSS selector to search for. + only_visible: Only show elements which are visible on screen. + """ + raise NotImplementedError + def __repr__(self): try: url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index c91cfca2b..3e5581ff3 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -503,21 +503,13 @@ class CommandDispatcher: if widget.backend == usertypes.Backend.QtWebEngine: raise cmdexc.CommandError(":navigate prev/next is not " "supported yet with QtWebEngine") - page = widget._widget.page() # pylint: disable=protected-access - frame = page.currentFrame() - if frame is None: - raise cmdexc.CommandError("No frame focused!") - else: - frame = None hintmanager = objreg.get('hintmanager', scope='tab', tab='current') if where == 'prev': - assert frame is not None - hintmanager.follow_prevnext(frame, url, prev=True, tab=tab, + hintmanager.follow_prevnext(widget, url, prev=True, tab=tab, background=bg, window=window) elif where == 'next': - assert frame is not None - hintmanager.follow_prevnext(frame, url, prev=False, tab=tab, + hintmanager.follow_prevnext(widget, url, prev=False, tab=tab, background=bg, window=window) elif where == 'up': self._navigate_up(url, tab, bg, window) @@ -1441,10 +1433,7 @@ class CommandDispatcher: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): raise cmdexc.CommandError("Focused element is not editable!") - if elem.is_content_editable(): - text = str(elem) - else: - text = elem.evaluateJavaScript('this.value') + text = elem.text(use_js=True) ed = editor.ExternalEditor(self._win_id, self._tabbed_browser) ed.editing_finished.connect(functools.partial( self.on_editing_finished, elem)) @@ -1460,15 +1449,7 @@ class CommandDispatcher: text: The new text to insert. """ try: - if elem.is_content_editable(): - log.misc.debug("Filling element {} via setPlainText.".format( - elem.debug_text())) - elem.setPlainText(text) - else: - log.misc.debug("Filling element {} via javascript.".format( - elem.debug_text())) - text = webelem.javascript_escape(text) - elem.evaluateJavaScript("this.value='{}'".format(text)) + elem.set_text(text, use_js=True) except webelem.IsNullError: raise cmdexc.CommandError("Element vanished while editing!") @@ -1494,7 +1475,7 @@ class CommandDispatcher: log.misc.debug("Pasting primary selection into element {}".format( elem.debug_text())) - elem.evaluateJavaScript(""" + elem.run_js_async(""" var sel = '{}'; var event = document.createEvent('TextEvent'); event.initTextEvent('textInput', true, true, null, sel); diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index be2b1bcef..d59267c92 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -64,8 +64,6 @@ class HintContext: Attributes: frames: The QWebFrames to use. - destroyed_frames: id()'s of QWebFrames which have been destroyed. - (Workaround for https://github.com/The-Compiler/qutebrowser/issues/152) all_elems: A list of all (elem, label) namedtuples ever created. elems: A mapping from key strings to (elem, label) namedtuples. May contain less elements than `all_elems` due to filtering. @@ -82,7 +80,6 @@ class HintContext: to_follow: The link to follow when enter is pressed. args: Custom arguments for userscript/spawn rapid: Whether to do rapid hinting. - mainframe: The main QWebFrame where we started hinting in. tab: The WebTab object we started hinting in. group: The group of web elements to hint. """ @@ -95,9 +92,7 @@ class HintContext: self.to_follow = None self.rapid = False self.frames = [] - self.destroyed_frames = [] self.args = [] - self.mainframe = None self.tab = None self.group = None @@ -176,25 +171,9 @@ class HintManager(QObject): """Clean up after hinting.""" for elem in self._context.all_elems: try: - elem.label.removeFromDocument() + elem.label.remove_from_document() except webelem.IsNullError: pass - for f in self._context.frames: - log.hints.debug("Disconnecting frame {}".format(f)) - if id(f) in self._context.destroyed_frames: - # WORKAROUND for - # https://github.com/The-Compiler/qutebrowser/issues/152 - log.hints.debug("Frame has been destroyed, ignoring.") - continue - try: - f.contentsSizeChanged.disconnect(self.on_contents_size_changed) - except TypeError: - # It seems we can get this here: - # TypeError: disconnect() failed between - # 'contentsSizeChanged' and 'on_contents_size_changed' - # See # https://github.com/The-Compiler/qutebrowser/issues/263 - pass - log.hints.debug("Disconnected.") text = self._get_text() message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) @@ -335,16 +314,16 @@ class HintManager(QObject): def _is_hidden(self, elem): """Check if the element is hidden via display=none.""" - display = elem.styleProperty('display', QWebElement.InlineStyle) + display = elem.style_property('display', QWebElement.InlineStyle) return display == 'none' def _show_elem(self, elem): """Show a given element.""" - elem.setStyleProperty('display', 'inline !important') + elem.set_style_property('display', 'inline !important') def _hide_elem(self, elem): """Hide a given element.""" - elem.setStyleProperty('display', 'none !important') + elem.set_style_property('display', 'none !important') def _set_style_properties(self, elem, label): """Set the hint CSS on the element given. @@ -373,7 +352,7 @@ class HintManager(QObject): attrs.append(('text-transform', 'none !important')) for k, v in attrs: - label.setStyleProperty(k, v) + label.set_style_property(k, v) self._set_style_position(elem, label) def _set_style_position(self, elem, label): @@ -389,8 +368,8 @@ class HintManager(QObject): top = rect.y() log.hints.vdebug("Drawing label '{!r}' at {}/{} for element '{!r}' " "(no_js: {})".format(label, left, top, elem, no_js)) - label.setStyleProperty('left', '{}px !important'.format(left)) - label.setStyleProperty('top', '{}px !important'.format(top)) + label.set_style_property('left', '{}px !important'.format(left)) + label.set_style_property('top', '{}px !important'.format(top)) def _draw_label(self, elem, string): """Draw a hint label over an element. @@ -402,22 +381,16 @@ class HintManager(QObject): Return: The newly created label element """ - doc = elem.webFrame().documentElement() - # 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 - # then use lastChild() to get a reference to it. - # See: http://stackoverflow.com/q/7364852/2085149 - body = doc.findFirst('body') - if not body.isNull(): - parent = body - else: + doc = elem.document_element() + body = doc.find_first('body') + if body is None: parent = doc - parent.appendInside('') - label = webelem.WebElementWrapper(parent.lastChild()) + else: + parent = body + label = parent.create_inside('span') label['class'] = 'qutehint' self._set_style_properties(elem, label) - label.setPlainText(string) + label.set_text(string) return label def _show_url_error(self): @@ -490,7 +463,7 @@ class HintManager(QObject): self.mouse_event.emit(evt) if elem.is_text_input() and elem.is_editable(): QTimer.singleShot(0, functools.partial( - elem.webFrame().page().triggerAction, + elem.frame().page().triggerAction, QWebPage.MoveToEndOfDocument)) QTimer.singleShot(0, self.stop_hinting.emit) @@ -559,7 +532,7 @@ class HintManager(QObject): download_manager = objreg.get('download-manager', scope='window', window=self._win_id) - download_manager.get(url, page=elem.webFrame().page(), + download_manager.get(url, page=elem.frame().page(), prompt_download_directory=prompt) def _call_userscript(self, elem, context): @@ -574,7 +547,7 @@ class HintManager(QObject): env = { 'QUTE_MODE': 'hints', 'QUTE_SELECTED_TEXT': str(elem), - 'QUTE_SELECTED_HTML': elem.toOuterXml(), + 'QUTE_SELECTED_HTML': elem.outer_xml(), } url = self._resolve_url(elem, context.baseurl) if url is not None: @@ -623,13 +596,12 @@ class HintManager(QObject): qtutils.ensure_valid(url) return url - def _find_prevnext(self, frame, prev=False): + def _find_prevnext(self, tab, prev=False): """Find a prev/next element in frame.""" # First check for - elems = frame.findAllElements(webelem.SELECTORS[webelem.Group.links]) + elems = tab.find_all_elements(webelem.SELECTORS[webelem.Group.links]) rel_values = ('prev', 'previous') if prev else ('next') for e in elems: - e = webelem.WebElementWrapper(e) try: rel_attr = e['rel'] except KeyError: @@ -639,9 +611,8 @@ class HintManager(QObject): e.debug_text(), rel_attr)) return e # Then check for regular links/buttons. - elems = frame.findAllElements( + elems = tab.find_all_elements( webelem.SELECTORS[webelem.Group.prevnext]) - elems = [webelem.WebElementWrapper(e) for e in elems] filterfunc = webelem.FILTERS[webelem.Group.prevnext] elems = [e for e in elems if filterfunc(e)] @@ -662,12 +633,6 @@ class HintManager(QObject): log.hints.vdebug("No match on '{}'!".format(text)) return None - def _connect_frame_signals(self): - """Connect the contentsSizeChanged signals to all frames.""" - for f in self._context.frames: - log.hints.debug("Connecting frame {}".format(f)) - f.contentsSizeChanged.connect(self.on_contents_size_changed) - def _check_args(self, target, *args): """Check the arguments passed to start() and raise if they're wrong. @@ -690,14 +655,9 @@ class HintManager(QObject): def _init_elements(self): """Initialize the elements and labels based on the context set.""" - elems = [] - for f in self._context.frames: - elems += f.findAllElements(webelem.SELECTORS[self._context.group]) - elems = [e for e in elems - if webelem.is_visible(e, self._context.mainframe)] - # We wrap the elements late for performance reasons, as wrapping 1000s - # of elements (with ~50 methods each) just takes too much time... - elems = [webelem.WebElementWrapper(e) for e in elems] + selector = webelem.SELECTORS[self._context.group] + elems = self._context.tab.find_all_elements(selector, + only_visible=True) filterfunc = webelem.FILTERS.get(self._context.group, lambda e: True) elems = [e for e in elems if filterfunc(e)] if not elems: @@ -724,12 +684,12 @@ class HintManager(QObject): # Do multi-word matching return all(word in elemstr for word in filterstr.split()) - def follow_prevnext(self, frame, baseurl, prev=False, tab=False, + def follow_prevnext(self, browsertab, baseurl, prev=False, tab=False, background=False, window=False): """Click a "previous"/"next" element on the page. Args: - frame: The frame where the element is in. + browsertab: The WebKitTab/WebEngineTab of the page. baseurl: The base URL of the current tab. prev: True to open a "previous" link, False to open a "next" link. tab: True to open in a new tab, False for the current tab. @@ -737,7 +697,7 @@ class HintManager(QObject): window: True to open in a new window, False for the current one. """ from qutebrowser.mainwindow import mainwindow - elem = self._find_prevnext(frame, prev) + elem = self._find_prevnext(browsertab, prev) if elem is None: raise cmdexc.CommandError("No {} links found!".format( "prev" if prev else "forward")) @@ -820,11 +780,6 @@ class HintManager(QObject): tab = tabbed_browser.currentWidget() if tab is None: raise cmdexc.CommandError("No WebView available yet!") - # FIXME:qtwebengine have a proper API for this - page = tab._widget.page() # pylint: disable=protected-access - mainframe = page.mainFrame() - if mainframe is None: - raise cmdexc.CommandError("No frame focused!") mode_manager = objreg.get('mode-manager', scope='window', window=self._win_id) if mode_manager.mode == usertypes.KeyMode.hint: @@ -852,20 +807,13 @@ class HintManager(QObject): self._context.baseurl = tabbed_browser.current_url() except qtutils.QtValueError: raise cmdexc.CommandError("No URL set for this page yet!") - self._context.frames = webelem.get_child_frames(mainframe) - for frame in self._context.frames: - # WORKAROUND for - # https://github.com/The-Compiler/qutebrowser/issues/152 - frame.destroyed.connect(functools.partial( - self._context.destroyed_frames.append, id(frame))) + self._context.tab = tab self._context.args = args - self._context.mainframe = mainframe self._context.group = group self._init_elements() message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) message_bridge.set_text(self._get_text()) - self._connect_frame_signals() modeman.enter(self._win_id, usertypes.KeyMode.hint, 'HintManager.start') @@ -878,7 +826,7 @@ class HintManager(QObject): matched = string[:len(keystr)] rest = string[len(keystr):] match_color = config.get('colors', 'hints.fg.match') - elem.label.setInnerXml( + elem.label.set_inner_xml( '{}{}'.format( match_color, matched, rest)) if self._is_hidden(elem.label): @@ -913,7 +861,7 @@ class HintManager(QObject): strings = self._hint_strings(elems) self._context.elems = {} for elem, string in zip(elems, strings): - elem.label.setInnerXml(string) + elem.label.set_inner_xml(string) self._context.elems[string] = elem keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) @@ -1017,7 +965,7 @@ class HintManager(QObject): Target.spawn: self._spawn, } elem = self._context.elems[keystr].elem - if elem.webFrame() is None: + if elem.frame() is None: message.error(self._win_id, "This element has no webframe.", immediately=True) @@ -1042,7 +990,7 @@ class HintManager(QObject): self.filter_hints(None) # Undo keystring highlighting for string, elem in self._context.elems.items(): - elem.label.setInnerXml(string) + elem.label.set_inner_xml(string) handler() @cmdutils.register(instance='hintmanager', scope='tab', hide=True, @@ -1068,9 +1016,9 @@ class HintManager(QObject): log.hints.debug("Contents size changed...!") for e in self._context.all_elems: try: - if e.elem.webFrame() is None: + if e.elem.frame() is None: # This sometimes happens for some reason... - e.label.removeFromDocument() + e.label.remove_from_document() continue self._set_style_position(e.elem, e.label) except webelem.IsNullError: @@ -1146,7 +1094,7 @@ class WordHinter: }) return (attr_extractors[attr](elem) - for attr in extractable_attrs[elem.tagName()] + for attr in extractable_attrs[elem.tag_name()] if attr in elem or attr == "text") def tag_words_to_hints(self, words): diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 96c7e562e..4e0a82066 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -410,6 +410,10 @@ class WebEngineTab(browsertab.AbstractTab): def clear_ssl_errors(self): log.stub() + def find_all_elements(self, selector, *, only_visible=False): + log.stub() + return [] + def _connect_signals(self): view = self._widget page = view.page() @@ -425,3 +429,7 @@ class WebEngineTab(browsertab.AbstractTab): view.iconChanged.connect(self.icon_changed) except AttributeError: log.stub('iconChanged, on Qt < 5.7') + try: + page.contentsSizeChanged.connect(self.contents_size_changed) + except AttributeError: + log.stub('contentsSizeChanged, on Qt < 5.7') diff --git a/qutebrowser/browser/webkit/webelem.py b/qutebrowser/browser/webkit/webelem.py index 97210bebd..5de299f77 100644 --- a/qutebrowser/browser/webkit/webelem.py +++ b/qutebrowser/browser/webkit/webelem.py @@ -28,7 +28,6 @@ Module attributes: """ import collections.abc -import functools from PyQt5.QtCore import QRect, QUrl from PyQt5.QtWebKit import QWebElement @@ -83,40 +82,6 @@ class WebElementWrapper(collections.abc.MutableMapping): if elem.isNull(): raise IsNullError('{} is a null element!'.format(elem)) self._elem = elem - for name in ['addClass', 'appendInside', 'appendOutside', - 'attributeNS', 'classes', 'clone', 'document', - 'encloseContentsWith', 'encloseWith', - 'evaluateJavaScript', 'findAll', 'findFirst', - 'firstChild', 'geometry', 'hasAttributeNS', - 'hasAttributes', 'hasClass', 'hasFocus', 'lastChild', - 'localName', 'namespaceUri', 'nextSibling', 'parent', - 'prefix', 'prependInside', 'prependOutside', - 'previousSibling', 'removeAllChildren', - 'removeAttributeNS', 'removeClass', 'removeFromDocument', - 'render', 'replace', 'setAttributeNS', 'setFocus', - 'setInnerXml', 'setOuterXml', 'setPlainText', - 'setStyleProperty', 'styleProperty', 'tagName', - 'takeFromDocument', 'toInnerXml', 'toOuterXml', - 'toggleClass', 'webFrame', '__eq__', '__ne__']: - # We don't wrap some methods for which we have better alternatives: - # - Mapping access for attributeNames/hasAttribute/setAttribute/ - # attribute/removeAttribute. - # - isNull is checked automagically. - # - str(...) instead of toPlainText - # For the rest, we create a wrapper which checks if the element is - # null. - - method = getattr(self._elem, name) - - def _wrapper(meth, *args, **kwargs): - self._check_vanished() - return meth(*args, **kwargs) - - wrapper = functools.partial(_wrapper, method) - # We used to do functools.update_wrapper here, but for some reason - # when using hints with many links, this accounted for nearly 50% - # of the time when profiling, which is unacceptable. - setattr(self, name, wrapper) def __str__(self): self._check_vanished() @@ -162,20 +127,92 @@ class WebElementWrapper(collections.abc.MutableMapping): if self._elem.isNull(): raise IsNullError('Element {} vanished!'.format(self._elem)) - def is_visible(self, mainframe): - """Check whether the element is currently visible on the screen. + 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) + + 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 + # then use lastChild() to get a reference to it. + # See: http://stackoverflow.com/q/7364852/2085149 + self._check_vanished() + self._elem.appendInside('<{}>'.format(tagname, tagname)) + return WebElementWrapper(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) + + def style_property(self, name, strategy): + """Get the element style resolved with the given strategy.""" + self._check_vanished() + return self._elem.styleProperty(name, strategy) + + def text(self, *, use_js=False): + """Get the plain text content for this element. Args: - mainframe: The main QWebFrame. - - Return: - True if the element is visible, False otherwise. + use_js: Whether to use javascript if the element isn't + content-editable. """ - return is_visible(self._elem, mainframe) + self._check_vanished() + if self.is_content_editable() or not use_js: + return self._elem.toPlainText() + else: + return self._elem.evaluateJavaScript('this.value') - def rect_on_view(self, **kwargs): - """Get the geometry of the element relative to the webview.""" - return rect_on_view(self._elem, **kwargs) + 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( + self.debug_text())) + self._elem.setPlainText(text) + else: + log.misc.debug("Filling element {} via javascript.".format( + self.debug_text())) + text = javascript_escape(text) + 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.""" @@ -304,6 +341,141 @@ class WebElementWrapper(collections.abc.MutableMapping): 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() + result = self._elem.evaluateJavaScript(code) + if callback is not None: + callback(result) + + 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 + """ + self._check_vanished() + + # First try getting the element rect via JS, as that's usually more + # accurate + if elem_geometry is None and not no_js: + rects = self._elem.evaluateJavaScript("this.getClientRects()") + text = utils.compact_text(self._elem.toOuterXml(), 500) + log.hints.vdebug("Client rectangles of element '{}': {}".format( + text, rects)) + for i in range(int(rects.get("length", 0))): + rect = rects[str(i)] + width = rect.get("width", 0) + height = rect.get("height", 0) + if width > 1 and height > 1: + # fix coordinates according to zoom level + zoom = self._elem.webFrame().zoomFactor() + if not config.get('ui', 'zoom-text-only') and adjust_zoom: + rect["left"] *= zoom + rect["top"] *= zoom + width *= zoom + height *= zoom + rect = QRect(rect["left"], rect["top"], width, height) + frame = self._elem.webFrame() + while frame is not None: + # Translate to parent frames' position (scroll position + # is taken care of inside getClientRects) + rect.translate(frame.geometry().topLeft()) + frame = frame.parentFrame() + return rect + + # No suitable rects found via JS, try via the QWebElement API + if elem_geometry is None: + geometry = self._elem.geometry() + else: + geometry = elem_geometry + frame = self._elem.webFrame() + rect = QRect(geometry) + while frame is not None: + rect.translate(frame.geometry().topLeft()) + rect.translate(frame.scrollPosition() * -1) + frame = frame.parentFrame() + # We deliberately always adjust the zoom here, even with + # adjust_zoom=False + if elem_geometry is None: + zoom = self._elem.webFrame().zoomFactor() + if not config.get('ui', 'zoom-text-only'): + rect.moveTo(rect.left() / zoom, rect.top() / zoom) + rect.setWidth(rect.width() / zoom) + rect.setHeight(rect.height() / zoom) + return rect + + def is_visible(self, mainframe): + """Check if the given element is visible in the given frame.""" + self._check_vanished() + # CSS attributes which hide an element + hidden_attributes = { + 'visibility': 'hidden', + 'display': 'none', + } + for k, v in hidden_attributes.items(): + if self._elem.styleProperty(k, QWebElement.ComputedStyle) == v: + return False + elem_geometry = self._elem.geometry() + if not elem_geometry.isValid() and elem_geometry.x() == 0: + # Most likely an invisible link + return False + # First check if the element is visible on screen + elem_rect = self.rect_on_view(elem_geometry=elem_geometry) + mainframe_geometry = mainframe.geometry() + if elem_rect.isValid(): + visible_on_screen = mainframe_geometry.intersects(elem_rect) + else: + # We got an invalid rectangle (width/height 0/0 probably), but this + # can still be a valid link. + visible_on_screen = mainframe_geometry.contains( + elem_rect.topLeft()) + # Then check if it's visible in its frame if it's not in the main + # frame. + elem_frame = self._elem.webFrame() + framegeom = QRect(elem_frame.geometry()) + if not framegeom.isValid(): + visible_in_frame = False + elif elem_frame.parentFrame() is not None: + framegeom.moveTo(0, 0) + framegeom.translate(elem_frame.scrollPosition()) + if elem_geometry.isValid(): + visible_in_frame = framegeom.intersects(elem_geometry) + else: + # We got an invalid rectangle (width/height 0/0 probably), but + # this can still be a valid link. + visible_in_frame = framegeom.contains(elem_geometry.topLeft()) + else: + visible_in_frame = visible_on_screen + return all([visible_on_screen, visible_in_frame]) + + + def javascript_escape(text): """Escape values special to javascript in strings. @@ -361,134 +533,3 @@ def focus_elem(frame): """ elem = frame.findFirstElement(SELECTORS[Group.focus]) return WebElementWrapper(elem) - - -def rect_on_view(elem, *, elem_geometry=None, adjust_zoom=True, no_js=False): - """Get the geometry of the element relative to the webview. - - We need this as a standalone function (as opposed to a WebElementWrapper - method) because we want to run is_visible before wrapping when hinting for - performance reasons. - - 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: The QWebElement to get the rect for. - 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 - """ - if elem.isNull(): - raise IsNullError("Got called on a null element!") - - # First try getting the element rect via JS, as that's usually more - # accurate - if elem_geometry is None and not no_js: - rects = elem.evaluateJavaScript("this.getClientRects()") - text = utils.compact_text(elem.toOuterXml(), 500) - log.hints.vdebug("Client rectangles of element '{}': {}".format(text, - rects)) - for i in range(int(rects.get("length", 0))): - rect = rects[str(i)] - width = rect.get("width", 0) - height = rect.get("height", 0) - if width > 1 and height > 1: - # fix coordinates according to zoom level - zoom = elem.webFrame().zoomFactor() - if not config.get('ui', 'zoom-text-only') and adjust_zoom: - rect["left"] *= zoom - rect["top"] *= zoom - width *= zoom - height *= zoom - rect = QRect(rect["left"], rect["top"], width, height) - frame = elem.webFrame() - while frame is not None: - # Translate to parent frames' position - # (scroll position is taken care of inside getClientRects) - rect.translate(frame.geometry().topLeft()) - frame = frame.parentFrame() - return rect - - # No suitable rects found via JS, try via the QWebElement API - if elem_geometry is None: - geometry = elem.geometry() - else: - geometry = elem_geometry - frame = elem.webFrame() - rect = QRect(geometry) - while frame is not None: - rect.translate(frame.geometry().topLeft()) - rect.translate(frame.scrollPosition() * -1) - frame = frame.parentFrame() - # We deliberately always adjust the zoom here, even with adjust_zoom=False - if elem_geometry is None: - zoom = elem.webFrame().zoomFactor() - if not config.get('ui', 'zoom-text-only'): - rect.moveTo(rect.left() / zoom, rect.top() / zoom) - rect.setWidth(rect.width() / zoom) - rect.setHeight(rect.height() / zoom) - return rect - - -def is_visible(elem, mainframe): - """Check if the given element is visible in the frame. - - We need this as a standalone function (as opposed to a WebElementWrapper - method) because we want to check this before wrapping when hinting for - performance reasons. - - Args: - elem: The QWebElement to check. - mainframe: The QWebFrame in which the element should be visible. - """ - if elem.isNull(): - raise IsNullError("Got called on a null element!") - # CSS attributes which hide an element - hidden_attributes = { - 'visibility': 'hidden', - 'display': 'none', - } - for k, v in hidden_attributes.items(): - if elem.styleProperty(k, QWebElement.ComputedStyle) == v: - return False - elem_geometry = elem.geometry() - if not elem_geometry.isValid() and elem_geometry.x() == 0: - # Most likely an invisible link - return False - # First check if the element is visible on screen - elem_rect = rect_on_view(elem, elem_geometry=elem_geometry) - mainframe_geometry = mainframe.geometry() - if elem_rect.isValid(): - visible_on_screen = mainframe_geometry.intersects(elem_rect) - else: - # We got an invalid rectangle (width/height 0/0 probably), but this - # can still be a valid link. - visible_on_screen = mainframe_geometry.contains(elem_rect.topLeft()) - # Then check if it's visible in its frame if it's not in the main - # frame. - elem_frame = elem.webFrame() - framegeom = QRect(elem_frame.geometry()) - if not framegeom.isValid(): - visible_in_frame = False - elif elem_frame.parentFrame() is not None: - framegeom.moveTo(0, 0) - framegeom.translate(elem_frame.scrollPosition()) - if elem_geometry.isValid(): - visible_in_frame = framegeom.intersects(elem_geometry) - else: - # We got an invalid rectangle (width/height 0/0 probably), but - # this can still be a valid link. - visible_in_frame = framegeom.contains(elem_geometry.topLeft()) - else: - visible_in_frame = visible_on_screen - return all([visible_on_screen, visible_in_frame]) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 899a46573..15c670567 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -25,12 +25,12 @@ import xml.etree.ElementTree from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer from PyQt5.QtGui import QKeyEvent -from PyQt5.QtWebKitWidgets import QWebPage +from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab -from qutebrowser.browser.webkit import webview, tabhistory +from qutebrowser.browser.webkit import webview, tabhistory, webelem from qutebrowser.utils import qtutils, objreg, usertypes, utils @@ -557,6 +557,22 @@ class WebKitTab(browsertab.AbstractTab): def set_html(self, html, base_url): self._widget.setHtml(html, base_url) + def find_all_elements(self, selector, *, only_visible=False): + mainframe = self._widget.page().mainFrame() + if mainframe is None: + raise browsertab.WebTabError("No frame focused!") + + elems = [] + frames = webelem.get_child_frames(mainframe) + for f in frames: + for elem in f.findAllElements(selector): + elems.append(webelem.WebElementWrapper(elem)) + + if only_visible: + elems = [e for e in elems if e.is_visible(mainframe)] + + return elems + @pyqtSlot() def _on_frame_load_finished(self): """Make sure we emit an appropriate status when loading finished. @@ -572,6 +588,14 @@ class WebKitTab(browsertab.AbstractTab): """Emit iconChanged with a QIcon like QWebEngineView does.""" self.icon_changed.emit(self._widget.icon()) + @pyqtSlot(QWebFrame) + def _on_frame_created(self, frame): + """Connect the contentsSizeChanged signal of each frame.""" + # FIXME:qtwebengine those could theoretically regress: + # https://github.com/The-Compiler/qutebrowser/issues/152 + # https://github.com/The-Compiler/qutebrowser/issues/263 + frame.contentsSizeChanged.connect(self.contents_size_changed) + def _connect_signals(self): view = self._widget page = view.page() diff --git a/tests/unit/browser/webkit/test_webelem.py b/tests/unit/browser/webkit/test_webelem.py index b87a2f529..aaeb3c42e 100644 --- a/tests/unit/browser/webkit/test_webelem.py +++ b/tests/unit/browser/webkit/test_webelem.py @@ -402,7 +402,7 @@ class TestRemoveBlankTarget: elem = [None] * depth elem[0] = get_webelem(tagname='div') for i in range(1, depth): - elem[i] = get_webelem(tagname='div', parent=elem[i-1]) + elem[i] = get_webelem(tagname='div', parent=elem[i-1]._elem) elem[i]._elem.encloseWith(elem[i-1]._elem) elem[-1].remove_blank_target() for i in range(depth): @@ -672,10 +672,10 @@ class TestRectOnView: def test_passed_geometry(self, stubs, js_rect): """Make sure geometry isn't called when a geometry is passed.""" frame = stubs.FakeWebFrame(QRect(0, 0, 200, 200)) - raw_elem = get_webelem(frame=frame, js_rect_return=js_rect)._elem + elem = get_webelem(frame=frame, js_rect_return=js_rect) rect = QRect(10, 20, 30, 40) - assert webelem.rect_on_view(raw_elem, elem_geometry=rect) == rect - assert not raw_elem.geometry.called + assert elem.rect_on_view(elem_geometry=rect) == rect + assert not elem._elem.geometry.called @pytest.mark.parametrize('js_rect', [None, {}]) @pytest.mark.parametrize('zoom_text_only', [True, False])