Source code for pywwt.qt

# Copyright 2018-2021 the .NET Foundation
# Licensed under the BSD license

"""
This file defines the WWT Qt widget.

Most of the code here deals with differences between WebEngine and WebKit
(either of which may be available in Qt) and also deals with figuring out how we
know once WWT is set up.
"""

import json
import time

from qtpy.QtWebEngineWidgets import QWebEngineView, QWebEnginePage, WEBENGINE
from qtpy import QtWidgets, QtGui, QtCore, PYQT6, PYSIDE6

from .app import get_qapp
from .core import BaseWWTWidget
from .logger import logger
from .data_server import get_data_server

__all__ = ['WWTQtClient']

APP_LIVENESS_DEADLINE = 10  # seconds


class WWTWebEngineView(QWebEngineView):
    # Pass drag and drop events back up to the parent
    # as this is needed for cases where applications
    # embed a PyWWT Qt widget.

    def dragEnterEvent(self, event):
        if self.parent() is None:
            super(WWTWebEngineView, self).dragEnterEvent(event)
        else:
            return self.parent().dragEnterEvent(event)

    def dragMoveEvent(self, event):
        if self.parent() is None:
            super(WWTWebEngineView, self).dragMoveEvent(event)
        else:
            return self.parent().dragMoveEvent(event)

    def dragLeaveEvent(self, event):
        if self.parent() is None:
            super(WWTWebEngineView, self).dragLeaveEvent(event)
        else:
            return self.parent().dragLeaveEvent(event)

    def dropEvent(self, event):
        if self.parent() is None:
            super(WWTWebEngineView, self).dropEvent(event)
        else:
            return self.parent().dropEvent(event)


class WWTQWebEnginePage(QWebEnginePage):
    """
    Subclass of QWebEnginePage that abstracts the JavaScript invocation
    mechanism.
    """

    app_message_callback = None

    wwt_ready = QtCore.Signal()
    """A signal raised when the WWT app first becomes ready.

    The glue-wwt plugin requires this signal, so we can't move or remove it.
    """

    def __init__(self, parent=None):
        super(WWTQWebEnginePage, self).__init__(parent=parent)

        if WEBENGINE:
            self._js_response_received = False
            self._js_response = None
        else:
            self._frame = self.mainFrame()

    if WEBENGINE:

        def javaScriptConsoleMessage(
            self,
            level=None,
            message=None,
            line_number=None,
            source_id=None
        ):
            try:
                context = 'level={0}, line_number={1}, source_id={2}'.format(level, line_number, source_id)
                self._common_console_handler(message, context)
            except:  # noqa: E722
                logger.exception('unhandled Python exception in Qt webengine javaScriptConsoleMessage')  # noqa

        def _process_js_response(self, result):
            self._js_response_received = True
            self._js_response = result

        def runJavaScript(self, code):
            app = get_qapp()
            self._js_response_received = False
            self._js_response = None
            super(WWTQWebEnginePage, self).runJavaScript(code, self._process_js_response)

            while not self._js_response_received:
                app.processEvents()

            return self._js_response

    else:

        def javaScriptConsoleMessage(
            self,
            message=None,
            line_number=None,
            source_id=None
        ):
            try:
                context = 'line_number={0}, source_id={1}'.format(line_number, source_id)
                self._common_console_handler(message, context)
            except:  # noqa: E722
                logger.exception('unhandled Python exception in Qt webkit javaScriptConsoleMessage')  # noqa

        def runJavaScript(self, code):
            return self._frame.evaluateJavaScript(code)

    def _common_console_handler(self, message, context):
        if message.startswith('pywwtMessage:'):
            try:
                payload = json.loads(message[13:])
            except Exception as e:
                logger.warning('invalid pywwtMessage JSON: %s', e)
                return

            if self.app_message_callback is None:
                logger.warning('received app message, but no handler available; message: %s', payload)
            else:
                try:
                    self.app_message_callback(payload)
                except Exception:
                    logger.exception('error handling app message; payload: %s', payload)  # noqa
        else:
            logger.debug('JS console message: %s (%s)', message, context)


class WWTQtWidget(QtWidgets.QWidget):
    def __init__(self, url, parent=None):
        super(WWTQtWidget, self).__init__(parent=parent)

        self.page = WWTQWebEnginePage()
        if PYQT6 or PYSIDE6:
            self.web = WWTWebEngineView(self.page)
        else:
            self.web = WWTWebEngineView()
            self.page.setView(self.web)
            self.web.setPage(self.page)
        self.web.setUrl(QtCore.QUrl(url))

        layout = QtWidgets.QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)
        layout.addWidget(self.web)

    # More mouse-drag helpers

    def dragEnterEvent(self, event):
        if self.parent() is None:
            super(WWTQtWidget, self).dragEnterEvent(event)
        else:
            return self.parent().dragEnterEvent(event)

    def dragMoveEvent(self, event):
        if self.parent() is None:
            super(WWTQtWidget, self).dragMoveEvent(event)
        else:
            return self.parent().dragMoveEvent(event)

    def dragLeaveEvent(self, event):
        if self.parent() is None:
            super(WWTQtWidget, self).dragLeaveEvent(event)
        else:
            return self.parent().dragLeaveEvent(event)

    def dropEvent(self, event):
        if self.parent() is None:
            super(WWTQtWidget, self).dropEvent(event)
        else:
            return self.parent().dropEvent(event)


[docs] class WWTQtClient(BaseWWTWidget): """ A client to create and drive the Qt widget. Parameters ---------- block_until_ready : `bool` Tells Python to wait for WorldWide Telescope to open before proceeding with any following script (default: `True`). size : `tuple` Sets size of widget in pixels (default: (600, 600)). hide_all_chrome : optional `bool` Configures the WWT frontend to hide all user-interface "chrome". Defaults to true to maintain compatibility with the historical pywwt user experience. """ def __init__(self, block_until_ready=False, size=None, hide_all_chrome=True): app = get_qapp() self._data_server = get_data_server() wwt_url = self._data_server.static_url('qtwrapper.html') self.widget = WWTQtWidget(url=wwt_url) if size is not None: self.widget.resize(*size) self.widget.page.app_message_callback = self._on_app_message self.widget.show() super(WWTQtClient, self).__init__( hide_all_chrome=hide_all_chrome, ) # Start polling for the app to start responding to messages self._last_pong_timestamp = 0 self._timer = QtCore.QTimer() self._timer.timeout.connect(self._check_ready) self._timer.start(1000) # TODO: this should be more generic if block_until_ready: while True: app.processEvents() if self._appAlive: break def _check_ready(self): # If this Qt signal callback function raises an unhandled exception, it # can crash the whole process! Which is ridiculous but let's try to do # our part to make that not happen. # # Cf: https://doc.qt.io/qt-5/exceptionsafety.html#signals-and-slots try: self._check_ready_inner() except: # noqa: E722 logger.exception('unhandled exception in Qt check-ready callback') def _check_ready_inner(self): # Send the ping. We have some extra paranoia here in case funky # sequencing can happen in the stop() method. if self.widget is not None and self.widget.page is not None: self._actually_send_msg({ 'type': 'wwt_ping_pong', 'sessionId': 'qt', 'threadId': str(time.time()), }) # Evaluate pong status, with a hack for glue-wwt -- we need to emit the # wwt_ready signal on our `page` field. It hardcodes the location of the # field so we can't rationalize this API. alive = (time.time() - self._last_pong_timestamp) < APP_LIVENESS_DEADLINE if not self._appAlive and alive: self.widget.page.wwt_ready.emit() self._on_app_status_change(alive=alive) def _on_app_message(self, payload): ptype = payload.get('type') if ptype == 'wwt_ping_pong': try: ts = float(payload['threadId']) except Exception: logger.exception('invalid timestamp in pywwt Qt pingpong response') else: self._last_pong_timestamp = ts else: self._on_app_message_received(payload)
[docs] def wait(self, duration=None): """ Prevents WorldWide Telescope from closing once Python reaches the end of a given script. Parameters ---------- duration : int or float or None How many seconds to wait for. By default, this waits until the Qt window is closed. """ app = get_qapp() if duration is None: app.exec_() else: time1 = time.time() while time.time() - time1 < duration: app.processEvents()
def _actually_send_msg(self, payload): jmsg = json.dumps(payload) return self.widget.page.runJavaScript("pywwtSendMessage({0});".format(jmsg)) def _serve_file(self, filename, extension=''): return self._data_server.serve_file(filename, extension=extension)
[docs] def render(self, filename): """ Saves a screenshot of the viewer's current state. Parameters ---------- filename : `str` The desired name of the image file to be saved. """ image = QtGui.QImage(self.widget.size(), QtGui.QImage.Format_RGB32) painter = QtGui.QPainter(image) self.widget.render(painter) image.save(filename) painter.end()
[docs] def close(self): self._timer.stop() self.widget.page = None self.widget.web = None self.widget.close() self.widget = None