1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
4#
5# This file is part of qutebrowser.
6#
7# qutebrowser is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# qutebrowser is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.
19
20"""QtWebKit specific part of the web element API."""
21
22from typing import cast, TYPE_CHECKING, Iterator, List, Optional, Set
23
24from PyQt5.QtCore import QRect, Qt
25from PyQt5.QtWebKit import QWebElement, QWebSettings
26from PyQt5.QtWebKitWidgets import QWebFrame
27
28from qutebrowser.config import config
29from qutebrowser.utils import log, utils, javascript, usertypes
30from qutebrowser.browser import webelem
31
32if TYPE_CHECKING:
33    from qutebrowser.browser.webkit import webkittab
34
35
36class IsNullError(webelem.Error):
37
38    """Gets raised by WebKitElement if an element is null."""
39
40
41class WebKitElement(webelem.AbstractWebElement):
42
43    """A wrapper around a QWebElement."""
44
45    _tab: 'webkittab.WebKitTab'
46
47    def __init__(self, elem: QWebElement, tab: 'webkittab.WebKitTab') -> None:
48        super().__init__(tab)
49        if isinstance(elem, self.__class__):
50            raise TypeError("Trying to wrap a wrapper!")
51        if elem.isNull():
52            raise IsNullError('{} is a null element!'.format(elem))
53        self._elem = elem
54
55    def __str__(self) -> str:
56        self._check_vanished()
57        return self._elem.toPlainText()
58
59    def __eq__(self, other: object) -> bool:
60        if not isinstance(other, WebKitElement):
61            return NotImplemented
62        return self._elem == other._elem
63
64    def __getitem__(self, key: str) -> str:
65        self._check_vanished()
66        if key not in self:
67            raise KeyError(key)
68        return self._elem.attribute(key)
69
70    def __setitem__(self, key: str, val: str) -> None:
71        self._check_vanished()
72        self._elem.setAttribute(key, val)
73
74    def __delitem__(self, key: str) -> None:
75        self._check_vanished()
76        if key not in self:
77            raise KeyError(key)
78        self._elem.removeAttribute(key)
79
80    def __contains__(self, key: object) -> bool:
81        assert isinstance(key, str)
82        self._check_vanished()
83        return self._elem.hasAttribute(key)
84
85    def __iter__(self) -> Iterator[str]:
86        self._check_vanished()
87        yield from self._elem.attributeNames()
88
89    def __len__(self) -> int:
90        self._check_vanished()
91        return len(self._elem.attributeNames())
92
93    def _check_vanished(self) -> None:
94        """Raise an exception if the element vanished (is null)."""
95        if self._elem.isNull():
96            raise IsNullError('Element {} vanished!'.format(self._elem))
97
98    def has_frame(self) -> bool:
99        self._check_vanished()
100        return self._elem.webFrame() is not None
101
102    def geometry(self) -> QRect:
103        self._check_vanished()
104        return self._elem.geometry()
105
106    def classes(self) -> Set[str]:
107        self._check_vanished()
108        return set(self._elem.classes())
109
110    def tag_name(self) -> str:
111        """Get the tag name for the current element."""
112        self._check_vanished()
113        return self._elem.tagName().lower()
114
115    def outer_xml(self) -> str:
116        """Get the full HTML representation of this element."""
117        self._check_vanished()
118        return self._elem.toOuterXml()
119
120    def is_content_editable_prop(self) -> bool:
121        self._check_vanished()
122        val = self._elem.evaluateJavaScript('this.isContentEditable || false')
123        assert isinstance(val, bool)
124        return val
125
126    def value(self) -> webelem.JsValueType:
127        self._check_vanished()
128        val = self._elem.evaluateJavaScript('this.value')
129        assert isinstance(val, (int, float, str, type(None))), val
130        return val
131
132    def set_value(self, value: webelem.JsValueType) -> None:
133        self._check_vanished()
134        if self._tab.is_deleted():
135            raise webelem.OrphanedError("Tab containing element vanished")
136        if self.is_content_editable():
137            log.webelem.debug("Filling {!r} via set_text.".format(self))
138            assert isinstance(value, str)
139            self._elem.setPlainText(value)
140        else:
141            log.webelem.debug("Filling {!r} via javascript.".format(self))
142            value = javascript.to_js(value)
143            self._elem.evaluateJavaScript("this.value={}".format(value))
144
145    def dispatch_event(self, event: str,
146                       bubbles: bool = False,
147                       cancelable: bool = False,
148                       composed: bool = False) -> None:
149        self._check_vanished()
150        log.webelem.debug("Firing event on {!r} via javascript.".format(self))
151        self._elem.evaluateJavaScript(
152            "this.dispatchEvent(new Event({}, "
153            "{{'bubbles': {}, 'cancelable': {}, 'composed': {}}}))"
154            .format(javascript.to_js(event),
155                    javascript.to_js(bubbles),
156                    javascript.to_js(cancelable),
157                    javascript.to_js(composed)))
158
159    def caret_position(self) -> int:
160        """Get the text caret position for the current element."""
161        self._check_vanished()
162        pos = self._elem.evaluateJavaScript('this.selectionStart')
163        if pos is None:
164            return 0
165        return int(pos)
166
167    def insert_text(self, text: str) -> None:
168        self._check_vanished()
169        if not self.is_editable(strict=True):
170            raise webelem.Error("Element is not editable!")
171        log.webelem.debug("Inserting text into element {!r}".format(self))
172        self._elem.evaluateJavaScript("""
173            var text = {};
174            var event = document.createEvent("TextEvent");
175            event.initTextEvent("textInput", true, true, null, text);
176            this.dispatchEvent(event);
177        """.format(javascript.to_js(text)))
178
179    def _parent(self) -> Optional['WebKitElement']:
180        """Get the parent element of this element."""
181        self._check_vanished()
182        elem = cast(Optional[QWebElement], self._elem.parent())
183        if elem is None or elem.isNull():
184            return None
185
186        return WebKitElement(elem, tab=self._tab)
187
188    def _rect_on_view_js(self) -> Optional[QRect]:
189        """Javascript implementation for rect_on_view."""
190        # FIXME:qtwebengine maybe we can reuse this?
191        rects = self._elem.evaluateJavaScript("this.getClientRects()")
192        if rects is None:  # pragma: no cover
193            # On e.g. Void Linux with musl libc, the stack size is too small
194            # for jsc, and running JS will fail. If that happens, fall back to
195            # the Python implementation.
196            # https://github.com/qutebrowser/qutebrowser/issues/1641
197            return None
198
199        text = utils.compact_text(self._elem.toOuterXml(), 500)
200        log.webelem.vdebug(  # type: ignore[attr-defined]
201            "Client rectangles of element '{}': {}".format(text, rects))
202
203        for i in range(int(rects.get("length", 0))):
204            rect = rects[str(i)]
205            width = rect.get("width", 0)
206            height = rect.get("height", 0)
207            if width > 1 and height > 1:
208                # fix coordinates according to zoom level
209                zoom = self._elem.webFrame().zoomFactor()
210                if not config.val.zoom.text_only:
211                    rect["left"] *= zoom
212                    rect["top"] *= zoom
213                    width *= zoom
214                    height *= zoom
215                rect = QRect(int(rect["left"]), int(rect["top"]),
216                             int(width), int(height))
217
218                frame = cast(Optional[QWebFrame], self._elem.webFrame())
219                while frame is not None:
220                    # Translate to parent frames' position (scroll position
221                    # is taken care of inside getClientRects)
222                    rect.translate(frame.geometry().topLeft())
223                    frame = frame.parentFrame()
224
225                return rect
226
227        return None
228
229    def _rect_on_view_python(self, elem_geometry: Optional[QRect]) -> QRect:
230        """Python implementation for rect_on_view."""
231        if elem_geometry is None:
232            geometry = self._elem.geometry()
233        else:
234            geometry = elem_geometry
235        rect = QRect(geometry)
236
237        frame = cast(Optional[QWebFrame], self._elem.webFrame())
238        while frame is not None:
239            rect.translate(frame.geometry().topLeft())
240            rect.translate(frame.scrollPosition() * -1)
241            frame = cast(Optional[QWebFrame], frame.parentFrame())
242
243        return rect
244
245    def rect_on_view(self, *, elem_geometry: QRect = None,
246                     no_js: bool = False) -> QRect:
247        """Get the geometry of the element relative to the webview.
248
249        Uses the getClientRects() JavaScript method to obtain the collection of
250        rectangles containing the element and returns the first rectangle which
251        is large enough (larger than 1px times 1px). If all rectangles returned
252        by getClientRects() are too small, falls back to elem.rect_on_view().
253
254        Skipping of small rectangles is due to <a> elements containing other
255        elements with "display:block" style, see
256        https://github.com/qutebrowser/qutebrowser/issues/1298
257
258        Args:
259            elem_geometry: The geometry of the element, or None.
260                           Calling QWebElement::geometry is rather expensive so
261                           we want to avoid doing it twice.
262            no_js: Fall back to the Python implementation
263        """
264        self._check_vanished()
265
266        # First try getting the element rect via JS, as that's usually more
267        # accurate
268        if elem_geometry is None and not no_js:
269            rect = self._rect_on_view_js()
270            if rect is not None:
271                return rect
272
273        # No suitable rects found via JS, try via the QWebElement API
274        return self._rect_on_view_python(elem_geometry)
275
276    def _is_hidden_css(self) -> bool:
277        """Check if the given element is hidden via CSS."""
278        attr_values = {
279            attr: self._elem.styleProperty(attr, QWebElement.ComputedStyle)
280            for attr in ['visibility', 'display', 'opacity']
281        }
282        invisible = attr_values['visibility'] == 'hidden'
283        none_display = attr_values['display'] == 'none'
284        zero_opacity = attr_values['opacity'] == '0'
285
286        is_framework = ('ace_text-input' in self.classes() or
287                        'custom-control-input' in self.classes())
288        return invisible or none_display or (zero_opacity and not is_framework)
289
290    def _is_visible(self, mainframe: QWebFrame) -> bool:
291        """Check if the given element is visible in the given frame.
292
293        This is not public API because it can't be implemented easily here with
294        QtWebEngine, and is only used via find_css(..., only_visible=True) via
295        the tab API.
296        """
297        self._check_vanished()
298        if self._is_hidden_css():
299            return False
300
301        elem_geometry = self._elem.geometry()
302        if not elem_geometry.isValid() and elem_geometry.x() == 0:
303            # Most likely an invisible link
304            return False
305        # First check if the element is visible on screen
306        elem_rect = self.rect_on_view(elem_geometry=elem_geometry)
307        mainframe_geometry = mainframe.geometry()
308        if elem_rect.isValid():
309            visible_on_screen = mainframe_geometry.intersects(elem_rect)
310        else:
311            # We got an invalid rectangle (width/height 0/0 probably), but this
312            # can still be a valid link.
313            visible_on_screen = mainframe_geometry.contains(
314                elem_rect.topLeft())
315        # Then check if it's visible in its frame if it's not in the main
316        # frame.
317        elem_frame = self._elem.webFrame()
318        framegeom = QRect(elem_frame.geometry())
319        if not framegeom.isValid():
320            visible_in_frame = False
321        elif elem_frame.parentFrame() is not None:
322            framegeom.moveTo(0, 0)
323            framegeom.translate(elem_frame.scrollPosition())
324            if elem_geometry.isValid():
325                visible_in_frame = framegeom.intersects(elem_geometry)
326            else:
327                # We got an invalid rectangle (width/height 0/0 probably), but
328                # this can still be a valid link.
329                visible_in_frame = framegeom.contains(elem_geometry.topLeft())
330        else:
331            visible_in_frame = visible_on_screen
332        return all([visible_on_screen, visible_in_frame])
333
334    def remove_blank_target(self) -> None:
335        elem: Optional[WebKitElement] = self
336        for _ in range(5):
337            if elem is None:
338                break
339            if elem.is_link():
340                if elem.get('target', None) == '_blank':
341                    elem['target'] = '_top'
342                break
343            elem = elem._parent()  # pylint: disable=protected-access
344
345    def delete(self) -> None:
346        self._elem.evaluateJavaScript('this.remove();')
347
348    def _move_text_cursor(self) -> None:
349        if self.is_text_input() and self.is_editable():
350            self._tab.caret.move_to_end_of_document()
351
352    def _requires_user_interaction(self) -> bool:
353        return False
354
355    def _click_editable(self, click_target: usertypes.ClickTarget) -> None:
356        ok = self._elem.evaluateJavaScript('this.focus(); true;')
357        if ok:
358            self._move_text_cursor()
359        else:
360            log.webelem.debug("Failed to focus via JS, falling back to event")
361            self._click_fake_event(click_target)
362
363    def _click_js(self, click_target: usertypes.ClickTarget) -> None:
364        settings = QWebSettings.globalSettings()
365        attribute = QWebSettings.JavascriptCanOpenWindows
366        could_open_windows = settings.testAttribute(attribute)
367        settings.setAttribute(attribute, True)
368        ok = self._elem.evaluateJavaScript('this.click(); true;')
369        settings.setAttribute(attribute, could_open_windows)
370        if not ok:
371            log.webelem.debug("Failed to click via JS, falling back to event")
372            self._click_fake_event(click_target)
373
374    def _click_fake_event(self, click_target: usertypes.ClickTarget,
375                          button: Qt.MouseButton = Qt.LeftButton) -> None:
376        self._tab.data.override_target = click_target
377        super()._click_fake_event(click_target)
378
379
380def get_child_frames(startframe: QWebFrame) -> List[QWebFrame]:
381    """Get all children recursively of a given QWebFrame.
382
383    Loosely based on https://blog.nextgenetics.net/?e=64
384
385    Args:
386        startframe: The QWebFrame to start with.
387
388    Return:
389        A list of children QWebFrame, or an empty list.
390    """
391    results = []
392    frames = [startframe]
393    while frames:
394        new_frames: List[QWebFrame] = []
395        for frame in frames:
396            results.append(frame)
397            new_frames += frame.childFrames()
398        frames = new_frames
399    return results
400