feat/plotly-canvas #5
3 changed files with 73 additions and 2 deletions
feat: surface the Plotly page console in the Console dock
A plotly failure inside the WebEngine page was invisible from Python - the react promise just rejected and the canvas looked frozen with no evidence. PlotlyCanvas now installs a QWebEnginePage that forwards javaScriptConsoleMessage into the Console dock with the right severity (error/warning/info), so the next silent failure is diagnosable.
commit
7cf323df23
|
|
@ -18,11 +18,13 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QUrl, Signal
|
||||
from PySide6.QtCore import QObject, QUrl, Signal
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineCore import QWebEnginePage
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
|
|
@ -47,6 +49,34 @@ _VIEW_DIRECTIONS = {
|
|||
}
|
||||
|
||||
|
||||
class _LoggingPage(QWebEnginePage):
|
||||
"""A web page that forwards the JS console to the app's console dock.
|
||||
|
||||
A plotly failure inside the page is otherwise invisible from Python (the
|
||||
``react`` promise just rejects), which makes a frozen canvas very hard to
|
||||
diagnose.
|
||||
"""
|
||||
|
||||
def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._log = log
|
||||
|
||||
def javaScriptConsoleMessage(
|
||||
self,
|
||||
level: QWebEnginePage.JavaScriptConsoleMessageLevel,
|
||||
message: str,
|
||||
line_number: int,
|
||||
source_id: str,
|
||||
) -> None:
|
||||
names = {
|
||||
QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error",
|
||||
QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning",
|
||||
}
|
||||
severity = names.get(level, "info")
|
||||
source = source_id.rsplit("/", 1)[-1] or "plotly"
|
||||
self._log(f"[web] {source}:{line_number} {message}", severity)
|
||||
|
||||
|
||||
class _CameraShim:
|
||||
"""Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere."""
|
||||
|
||||
|
|
@ -126,8 +156,10 @@ class PlotlyCanvas(QWidget):
|
|||
parent: QWidget | None = None,
|
||||
style: RenderStyle | None = None,
|
||||
selection: SelectionState | None = None,
|
||||
log: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._log = log
|
||||
self._style = style or RenderStyle()
|
||||
self.selection = selection or SelectionState(self)
|
||||
self._builder = PlotlyTraceBuilder(self._style)
|
||||
|
|
@ -153,6 +185,8 @@ class PlotlyCanvas(QWidget):
|
|||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._web = QWebEngineView(self)
|
||||
if self._log is not None:
|
||||
self._web.setPage(_LoggingPage(self._log, self._web))
|
||||
self._channel = QWebChannel(self._web.page())
|
||||
self._bridge = _Bridge(self)
|
||||
self._channel.registerObject("otkoBridge", self._bridge)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,12 @@ class MainWindow(
|
|||
# Imported lazily so a plotly-less install still boots PyVista.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
|
||||
canvas = PlotlyCanvas(
|
||||
self,
|
||||
style=self._style,
|
||||
selection=self._selection,
|
||||
log=self._log_canvas_message,
|
||||
)
|
||||
else:
|
||||
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
||||
self._canvases[backend] = canvas
|
||||
|
|
@ -149,6 +154,16 @@ class MainWindow(
|
|||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||
return self._canvas_backend
|
||||
|
||||
def _log_canvas_message(self, message: str, severity: str = "info") -> None:
|
||||
"""Route a JS console line from the Plotly page to the Console dock.
|
||||
|
||||
The dock may not exist yet while canvases are being constructed, so
|
||||
the lookup is guarded.
|
||||
"""
|
||||
dock = getattr(self, "_console_dock", None)
|
||||
if dock is not None:
|
||||
dock.log(message, severity=severity)
|
||||
|
||||
# ── plot style ───────────────────────────────────────────────────
|
||||
def _load_plot_style(self) -> RenderStyle:
|
||||
"""Style from the last session, ignoring anything unrecognised."""
|
||||
|
|
|
|||
|
|
@ -99,3 +99,25 @@ def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # t
|
|||
payload, preserve = _parse_call(calls[-1])
|
||||
assert preserve is True, "colour changes must not re-frame the view"
|
||||
assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""A plotly failure inside the page must not be invisible from Python."""
|
||||
from PySide6.QtWebEngineCore import QWebEnginePage
|
||||
|
||||
from otko.views.canvas_plotly.plotly_canvas import _LoggingPage
|
||||
|
||||
messages: list[tuple[str, str]] = []
|
||||
page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg)))
|
||||
|
||||
levels = QWebEnginePage.JavaScriptConsoleMessageLevel
|
||||
page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html")
|
||||
page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html")
|
||||
page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "")
|
||||
|
||||
assert messages == [
|
||||
("error", "[web] plot.html:7 boom"),
|
||||
("warning", "[web] plot.html:8 careful"),
|
||||
("info", "[web] plotly:9 hello"),
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue