Merge branch 'webelem'

This commit is contained in:
Florian Bruhin 2016-07-28 12:38:37 +02:00
commit a1fd161a4a
7 changed files with 305 additions and 293 deletions

View File

@ -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),

View File

@ -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);

View File

@ -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('<span></span>')
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 <link rel="prev(ious)|next">
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(
'<font color="{}">{}</font>{}'.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):

View File

@ -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')

View File

@ -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 <a> 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 <a> 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])

View File

@ -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()

View File

@ -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])