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