1"""
2This module holds our customized WebView that integrates HTML, CSS & JS
3into Qt. WebviewWidget provides a somewhat uniform interface (_WebViewBase)
4around either WebEngineView (extends QWebEngineView) or WebKitView
5(extends QWebView), as available.
6"""
7import os
8from os.path import abspath, dirname, join
9import time
10import threading
11import warnings
12import inspect
13
14from collections.abc import Iterable, Mapping, Set, Sequence
15from itertools import count
16from numbers import Integral, Real
17from random import random
18from urllib.parse import urljoin
19from urllib.request import pathname2url
20
21import numpy as np
22import sip
23
24from AnyQt.QtCore import Qt, QObject, QFile, QTimer, QUrl, QSize, QEventLoop, \
25    pyqtProperty, pyqtSlot, pyqtSignal
26from AnyQt.QtGui import QColor
27from AnyQt.QtWidgets import QSizePolicy, QWidget, qApp
28
29try:
30    from AnyQt.QtWebKitWidgets import QWebView
31    HAVE_WEBKIT = True
32except ImportError:
33    HAVE_WEBKIT = False
34
35try:
36    from AnyQt.QtWebEngineWidgets import QWebEngineView, QWebEngineScript
37    from AnyQt.QtWebChannel import QWebChannel
38    HAVE_WEBENGINE = True
39except ImportError:
40    HAVE_WEBENGINE = False
41
42
43_WEBVIEW_HELPERS = join(dirname(__file__), '_webview', 'helpers.js')
44_WEBENGINE_INIT_WEBCHANNEL = join(dirname(__file__), '_webview', 'init-webengine-webchannel.js')
45
46_ORANGE_DEBUG = os.environ.get('ORANGE_DEBUG')
47
48
49def _inherit_docstrings(cls):
50    """Inherit methods' docstrings from first superclass that defines them"""
51    for method in cls.__dict__.values():
52        if inspect.isfunction(method) and method.__doc__ is None:
53            for parent in cls.__mro__[1:]:
54                __doc__ = getattr(parent, method.__name__, None).__doc__
55                if __doc__:
56                    method.__doc__ = __doc__
57                    break
58    return cls
59
60
61class _QWidgetJavaScriptWrapper(QObject):
62    def __init__(self, parent):
63        super().__init__(parent)
64        self.__parent = parent
65
66    @pyqtSlot()
67    def load_really_finished(self):
68        self.__parent._load_really_finished()
69
70    @pyqtSlot()
71    def hideWindow(self):
72        w = self.__parent
73        while isinstance(w, QWidget):
74            if w.windowFlags() & (Qt.Window | Qt.Dialog):
75                return w.hide()
76            w = w.parent() if callable(w.parent) else w.parent
77
78
79if HAVE_WEBENGINE:
80    class WebEngineView(QWebEngineView):
81        """
82        A QWebEngineView initialized to support communication with JS code.
83
84        Parameters
85        ----------
86        parent : QWidget
87            Parent widget object.
88        bridge : QObject
89            The "bridge" object exposed as a global object ``pybridge`` in
90            JavaScript. Any methods desired to be accessible from JS need
91            to be decorated with ``@QtCore.pyqtSlot(<*args>, result=<type>)``
92            decorator.
93            Note: do not use QWidget instances as a bridge, use a minimal
94            QObject subclass implementing the required interface.
95        """
96
97        # Prefix added to objects exposed via WebviewWidget.exposeObject()
98        # This caters to this class' subclass
99        _EXPOSED_OBJ_PREFIX = '__ORANGE_'
100
101        def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs):
102            debug = debug or _ORANGE_DEBUG
103            if debug:
104                port = os.environ.setdefault('QTWEBENGINE_REMOTE_DEBUGGING', '12088')
105                warnings.warn(
106                    'To debug QWebEngineView, set environment variable '
107                    'QTWEBENGINE_REMOTE_DEBUGGING={port} and then visit '
108                    'http://127.0.0.1:{port}/ in a Chromium-based browser. '
109                    'See https://doc.qt.io/qt-5/qtwebengine-debugging.html '
110                    'This has also been done for you.'.format(port=port))
111            super().__init__(parent,
112                             sizeHint=QSize(500, 400),
113                             sizePolicy=QSizePolicy(QSizePolicy.Expanding,
114                                                    QSizePolicy.Expanding),
115                             **kwargs)
116            self.bridge = bridge
117            self.debug = debug
118            with open(_WEBVIEW_HELPERS, encoding="utf-8") as f:
119                self._onloadJS(f.read(),
120                               name='webview_helpers',
121                               injection_point=QWebEngineScript.DocumentCreation)
122
123            qtwebchannel_js = QFile("://qtwebchannel/qwebchannel.js")
124            if qtwebchannel_js.open(QFile.ReadOnly):
125                source = bytes(qtwebchannel_js.readAll()).decode("utf-8")
126                with open(_WEBENGINE_INIT_WEBCHANNEL, encoding="utf-8") as f:
127                    init_webchannel_src = f.read()
128                self._onloadJS(source + init_webchannel_src %
129                               dict(exposeObject_prefix=self._EXPOSED_OBJ_PREFIX),
130                               name='webchannel_init',
131                               injection_point=QWebEngineScript.DocumentCreation)
132            else:
133                warnings.warn(
134                    "://qtwebchannel/qwebchannel.js is not readable.",
135                    RuntimeWarning)
136
137            self._onloadJS(';window.__load_finished = true;',
138                           name='load_finished',
139                           injection_point=QWebEngineScript.DocumentReady)
140
141            channel = QWebChannel(self)
142            if bridge is not None:
143                if isinstance(bridge, QWidget):
144                    warnings.warn(
145                        "Don't expose QWidgets in WebView. Construct minimal "
146                        "QObjects instead.", DeprecationWarning,
147                        stacklevel=2)
148                channel.registerObject("pybridge", bridge)
149
150            channel.registerObject('__bridge', _QWidgetJavaScriptWrapper(self))
151
152            self.page().setWebChannel(channel)
153
154        def _onloadJS(self, code, name='', injection_point=QWebEngineScript.DocumentReady):
155            script = QWebEngineScript()
156            script.setName(name or ('script_' + str(random())[2:]))
157            script.setSourceCode(code)
158            script.setInjectionPoint(injection_point)
159            script.setWorldId(script.MainWorld)
160            script.setRunsOnSubFrames(False)
161            self.page().scripts().insert(script)
162            self.loadStarted.connect(
163                lambda: self.page().scripts().insert(script))
164
165        def runJavaScript(self, javascript, resultCallback=None):
166            """
167            Parameters
168            ----------
169            javascript : str
170                javascript code.
171            resultCallback : Optional[(object) -> None]
172                When the script has been executed the `resultCallback` will
173                be called with the result of the last executed statement.
174            """
175            if resultCallback is not None:
176                self.page().runJavaScript(javascript, resultCallback)
177            else:
178                self.page().runJavaScript(javascript)
179
180
181if HAVE_WEBKIT:
182    class WebKitView(QWebView):
183        """
184        Construct a new QWebView widget that has no history and
185        supports loading from local URLs.
186
187        Parameters
188        ----------
189        parent: QWidget
190            The parent widget.
191        bridge: QObject
192            The QObject to use as a parent. This object is also exposed
193            as ``window.pybridge`` in JavaScript.
194        html: str
195            The HTML to load the view with.
196        debug: bool
197            Whether to enable inspector tools on right click.
198        **kwargs:
199            Passed to QWebView.
200        """
201        def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs):
202            super().__init__(parent,
203                             sizeHint=QSize(500, 400),
204                             sizePolicy=QSizePolicy(QSizePolicy.Expanding,
205                                                    QSizePolicy.Expanding),
206                             **kwargs)
207
208            if isinstance(parent, QWidget) and parent.layout() is not None:
209                parent.layout().addWidget(self)  # TODO REMOVE
210
211            self.bridge = bridge
212            self.frame = None
213            debug = debug or _ORANGE_DEBUG
214            self.debug = debug
215
216            if isinstance(bridge, QWidget):
217                warnings.warn(
218                    "Don't expose QWidgets in WebView. Construct minimal "
219                    "QObjects instead.", DeprecationWarning,
220                    stacklevel=2)
221
222            def _onload(_ok):
223                if _ok:
224                    self.frame = self.page().mainFrame()
225                    self.frame.javaScriptWindowObjectCleared.connect(
226                        lambda: self.frame.addToJavaScriptWindowObject('pybridge', bridge))
227                    with open(_WEBVIEW_HELPERS, encoding="utf-8") as f:
228                        self.frame.evaluateJavaScript(f.read())
229
230            self.loadFinished.connect(_onload)
231            _onload(True)
232
233            history = self.history()
234            history.setMaximumItemCount(0)
235            settings = self.settings()
236            settings.setMaximumPagesInCache(0)
237            settings.setAttribute(settings.LocalContentCanAccessFileUrls, True)
238            settings.setAttribute(settings.LocalContentCanAccessRemoteUrls, False)
239
240            if debug:
241                settings.setAttribute(settings.LocalStorageEnabled, True)
242                settings.setAttribute(settings.DeveloperExtrasEnabled, True)
243                settings.setObjectCacheCapacities(4e6, 4e6, 4e6)
244                settings.enablePersistentStorage()
245
246        def runJavaScript(self, javascript, resultCallback=None):
247            result = self.page().mainFrame().evaluateJavaScript(javascript)
248            if resultCallback is not None:
249                # Emulate the QtWebEngine's interface and return the result
250                # in a event queue invoked callback
251                QTimer.singleShot(0, lambda: resultCallback(result))
252
253
254def _to_primitive_types(d):
255    # pylint: disable=too-many-return-statements
256    if isinstance(d, QWidget):
257        raise ValueError("Don't expose QWidgets in WebView. Construct minimal "
258                         "QObjects instead.")
259    if isinstance(d, Integral):
260        return int(d)
261    if isinstance(d, Real):
262        return float(d)
263    if isinstance(d, (bool, np.bool_)):
264        return bool(d)
265    if isinstance(d, (str, QObject)):
266        return d
267    if isinstance(d, np.ndarray):
268        return d.tolist()
269    if isinstance(d, Mapping):
270        return {k: _to_primitive_types(d[k]) for k in d}
271    if isinstance(d, Set):
272        return {k: 1 for k in d}
273    if isinstance(d, (Sequence, Iterable)):
274        return [_to_primitive_types(i) for i in d]
275    if d is None:
276        return None
277    if isinstance(d, QColor):
278        return d.name()
279    raise TypeError(
280        'object must consist of primitive types '
281        '(allowed: int, float, str, bool, list, '
282        'dict, set, numpy.ndarray, ...). Type is: ' + d.__class__)
283
284
285class _WebViewBase:
286    def _evalJS(self, code):
287        """Evaluate JavaScript code and return the result of the last statement."""
288        raise NotImplementedError
289
290    def onloadJS(self, code):
291        """Run JS on document load."""
292        raise NotImplementedError
293
294    def html(self):
295        """Return HTML contents of the top frame.
296
297        Warnings
298        --------
299        In the case of Qt WebEngine implementation, this function calls:
300
301            QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
302
303        until the page's HTML contents is made available (through IPC).
304        """
305        raise NotImplementedError
306
307    def exposeObject(self, name, obj):
308        """Expose the object `obj` as ``window.<name>`` in JavaScript.
309
310        If the object contains any string values that start and end with
311        literal ``/**/``, those are evaluated as JS expressions the result
312        value replaces the string in the object.
313
314        The exposure, as defined here, represents a snapshot of object at
315        the time of execution. Any future changes on the original Python
316        object are not visible in its JavaScript counterpart.
317
318        Parameters
319        ----------
320        name: str
321            The global name the object is exposed as.
322        obj: object
323            The object to expose. Must contain only primitive types, such as:
324            int, float, str, bool, list, dict, set, numpy.ndarray ...
325        """
326        raise NotImplementedError
327
328    def __init__(self):
329        self.__is_init = False
330        self.__js_queue = []
331
332    @pyqtSlot()
333    def _load_really_finished(self):
334        """Call this from JS when the document is ready."""
335        self.__is_init = True
336
337    def dropEvent(self, event):
338        """Prevent loading of drag-and-drop dropped file"""
339        pass
340
341    def evalJS(self, code):
342        """
343        Evaluate JavaScript code synchronously (or sequentially, at least).
344
345        Parameters
346        ----------
347        code : str
348            The JavaScript code to evaluate in main page frame. The scope is
349            not assured. Assign properties to window if you want to make them
350            available elsewhere.
351
352        Warnings
353        --------
354        In the case of Qt WebEngine implementation, this function calls:
355
356            QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
357
358        until the page is fully loaded, all the objects exposed via
359        exposeObject() method are indeed exposed in JS, and the code `code`
360        has finished evaluating.
361        """
362        def _later():
363            if not self.__is_init and self.__js_queue:
364                return QTimer.singleShot(1, _later)
365            if self.__js_queue:
366                # '/n' is required when the last line is a comment
367                code = '\n;'.join(self.__js_queue)
368                self.__js_queue.clear()
369                self._evalJS(code)
370
371        # WebView returns the result of the last evaluated expression.
372        # This result may be too complex an object to safely receive on this
373        # end, so instead, just make it return 0.
374        code += ';0;'
375        self.__js_queue.append(code)
376        QTimer.singleShot(1, _later)
377
378    def svg(self):
379        """ Return SVG string of the first SVG element on the page, or
380        raise ValueError if not any. """
381        html = self.html()
382        return html[html.index('<svg '):html.index('</svg>') + 6]
383
384    def setHtml(self, html, base_url=''):
385        """Set the HTML content of the current webframe to `html`
386        (an UTF-8 string)."""
387        super().setHtml(html, QUrl(base_url))
388
389    @staticmethod
390    def toFileURL(local_path):
391        """Return local_path as file:// URL"""
392        return urljoin('file:', pathname2url(abspath(local_path)))
393
394    def setUrl(self, url):
395        """Point the current frame to URL url."""
396        super().setUrl(QUrl(url))
397
398    def contextMenuEvent(self, event):
399        """ Also disable context menu unless debug."""
400        if self.debug:
401            super().contextMenuEvent(event)
402
403
404def wait(until: callable, timeout=5000):
405    """Process events until condition is satisfied
406
407    Parameters
408    ----------
409    until: callable
410        Returns True when condition is satisfied.
411    timeout: int
412        Milliseconds to wait until TimeoutError is raised.
413    """
414    started = time.perf_counter()
415    while not until():
416        qApp.processEvents(QEventLoop.ExcludeUserInputEvents)
417        if (time.perf_counter() - started) * 1000 > timeout:
418            raise TimeoutError()
419
420
421if HAVE_WEBKIT:
422
423    class _JSObject(QObject):
424        """ This class hopefully prevent options data from being
425        marshalled into a string-like dumb (JSON) object when
426        passed into JavaScript. Or at least relies on Qt to do it as
427        optimally as it knows to."""
428
429        def __init__(self, parent, name, obj):
430            super().__init__(parent)
431            self._obj = dict(obj=obj)
432
433        @pyqtProperty('QVariantMap')
434        def pop_object(self):
435            return self._obj
436
437    class WebviewWidget(_WebViewBase, WebKitView):
438        def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs):
439            # WebEngine base WebviewWidget has js_timeout parameter, since
440            # user do not know which one will get and passing js_timeout to
441            # WebKitView causes error we should remove
442            kwargs.pop("js_timeout", None)
443            WebKitView.__init__(self, parent, bridge, debug=debug, **kwargs)
444            _WebViewBase.__init__(self)
445
446            def load_finished():
447                if not sip.isdeleted(self):
448                    self.frame.addToJavaScriptWindowObject(
449                        '__bridge', _QWidgetJavaScriptWrapper(self))
450                    self._evalJS('setTimeout(function(){'
451                                 '__bridge.load_really_finished(); }, 100);')
452
453            self.loadFinished.connect(load_finished)
454
455        @pyqtSlot()
456        def _load_really_finished(self):
457            # _WebViewBase's (super) method not visible in JS for some reason
458            super()._load_really_finished()
459
460        def _evalJS(self, code):
461            return self.frame.evaluateJavaScript(code)
462
463        def onloadJS(self, code):
464            self.frame.loadFinished.connect(
465                lambda: WebviewWidget.evalJS(self, code))
466
467        def html(self):
468            return self.frame.toHtml()
469
470        def exposeObject(self, name, obj):
471            obj = _to_primitive_types(obj)
472            jsobj = _JSObject(self, name, obj)
473            self.frame.addToJavaScriptWindowObject('__js_object_' + name, jsobj)
474            WebviewWidget.evalJS(self, '''
475                window.{0} = window.__js_object_{0}.pop_object.obj;
476                fixupPythonObject({0}); 0;
477            '''.format(name))
478
479
480elif HAVE_WEBENGINE:
481    class IdStore:
482        """Generates and stores unique ids.
483
484        Used in WebviewWidget._evalJS below to match scheduled js executions
485        and returned results. WebEngine operations are async, so locking is
486        used to guard against problems that could occur if multiple executions
487        ended at exactly the same time.
488        """
489
490        def __init__(self):
491            self.id = 0
492            self.lock = threading.Lock()
493            self.ids = dict()
494
495        def create(self):
496            with self.lock:
497                self.id += 1
498                return self.id
499
500        def store(self, id, value):
501            with self.lock:
502                self.ids[id] = value
503
504        def __contains__(self, id):
505            return id in self.ids
506
507        def pop(self, id):
508            with self.lock:
509                return self.ids.pop(id, None)
510
511
512    class _JSObjectChannel(QObject):
513        """ This class hopefully prevent options data from being
514        marshalled into a string-like dumb (JSON) object when
515        passed into JavaScript. Or at least relies on Qt to do it as
516        optimally as it knows to."""
517
518        # JS webchannel listens to this signal
519        objectChanged = pyqtSignal('QVariantMap')
520
521        def __init__(self, parent):
522            super().__init__(parent)
523            self._obj = None
524            self._id_gen = count()
525            self._objects = {}
526
527        def send_object(self, name, obj):
528            if isinstance(obj, QObject):
529                raise ValueError(
530                    "QWebChannel doesn't transmit QObject instances. If you "
531                    "need a QObject available in JavaScript, pass it as a "
532                    "bridge in WebviewWidget constructor.")
533            id = next(self._id_gen)
534            value = self._objects[id] = dict(id=id, name=name, obj=obj)
535            # Wait till JS is connected to receive objects
536            wait(until=lambda: self.receivers(self.objectChanged))
537            self.objectChanged.emit(value)
538
539        @pyqtSlot(int)
540        def mark_exposed(self, id):
541            del self._objects[id]
542
543        def is_all_exposed(self):
544            return len(self._objects) == 0
545
546
547    _NOTSET = object()
548
549
550    class WebviewWidget(_WebViewBase, WebEngineView):
551        _html = _NOTSET
552
553        def __init__(self, parent=None, bridge=None, *, js_timeout=5000, debug=False, **kwargs):
554            WebEngineView.__init__(self, parent, bridge, debug=debug, **kwargs)
555            _WebViewBase.__init__(self)
556
557            # Tracks objects exposed in JS via exposeObject(). JS notifies once
558            # the object has indeed been exposed (i.e. the new object is available
559            # is JS) because the whole thing is async.
560            # This is needed to stall any evalJS() calls which may expect
561            # the objects being available (but could otherwise be executed before
562            # the objects are exposed in JS).
563            self._jsobject_channel = jsobj = _JSObjectChannel(self)
564            self.page().webChannel().registerObject(
565                '__js_object_channel', jsobj)
566            self._results = IdStore()
567            self.js_timeout = js_timeout
568
569        def _evalJS(self, code):
570            wait(until=self._jsobject_channel.is_all_exposed)
571            if sip.isdeleted(self):
572                return None
573            result = self._results.create()
574            self.runJavaScript(code, lambda x: self._results.store(result, x))
575            wait(until=lambda: result in self._results,
576                 timeout=self.js_timeout)
577            return self._results.pop(result)
578
579        def onloadJS(self, code):
580            self._onloadJS(code, injection_point=QWebEngineScript.Deferred)
581
582        def html(self):
583            self.page().toHtml(lambda html: setattr(self, '_html', html))
584            wait(until=lambda: self._html is not _NOTSET or sip.isdeleted(self))
585            html, self._html = self._html, _NOTSET
586            return html
587
588        def exposeObject(self, name, obj):
589            obj = _to_primitive_types(obj)
590            self._jsobject_channel.send_object(name, obj)
591
592        def setHtml(self, html, base_url=''):
593            # TODO: remove once anaconda will provide PyQt without this bug.
594            #
595            # At least on some installations of PyQt 5.6.0 with anaconda
596            # WebViewWidget grabs focus on setHTML which can be quite annoying.
597            # For example, if you have a line edit as filter and show results
598            # in WebWiew, then WebView grabs focus after every typed character.
599            #
600            # http://stackoverflow.com/questions/36609489
601            # https://bugreports.qt.io/browse/QTBUG-52999
602            initial_state = self.isEnabled()
603            self.setEnabled(False)
604            super().setHtml(html, base_url)
605            self.setEnabled(initial_state)
606