fix: stop the Plotly hover handler redrawing on every mouse move
plotly_hover/plotly_unhover fire continuously while the mouse moves, and the unhover handler restyled the snap marker unconditionally - even with snapping off and nothing visible. That meant a full Plotly.restyle plus redraw per mouse event, which re-fired hover until the page died with 'RangeError: Maximum call stack size exceeded' and the canvas froze (reported live after opening a model). The marker is now updated only when its visible state actually changes: no marker when snapping is off, and a restyle only when the snapped point differs from the one already shown. Deliberately synchronous - timers are throttled to about a second by WebEngine when the page is not compositing, which stalled the preview. Measured: 200 mouse-move events went from 200 restyles to 0; 200 hovers on one grid dot cost a single restyle.
This commit is contained in:
parent
7cf323df23
commit
5262d31e33
2 changed files with 127 additions and 9 deletions
93
tests/gui/test_plotly_hover.py
Normal file
93
tests/gui/test_plotly_hover.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Regression tests for the Plotly hover/snap-marker contract.
|
||||
|
||||
``plotly_hover``/``plotly_unhover`` fire continuously while the mouse moves.
|
||||
Touching the plot on each one made it redraw per mouse move, re-firing hover
|
||||
until the stack blew (``RangeError: Maximum call stack size exceeded``) and the
|
||||
canvas froze. The marker is therefore coalesced and only touched when its
|
||||
shown/hidden state actually changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("plotly")
|
||||
|
||||
from otko.services import load_project
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
||||
|
||||
#: 200 mouse-move events over a *non-snap* point with snapping armed.
|
||||
_CHURN_PROBE = """(function(){
|
||||
var gd = document.getElementById('plot');
|
||||
var n = 0; var original = Plotly.restyle;
|
||||
Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); };
|
||||
var point = { data: gd.data[0], customdata: [0, 0, 0] };
|
||||
try {
|
||||
for (var i = 0; i < 200; i++) {
|
||||
gd.emit('plotly_hover', { points: [point] });
|
||||
gd.emit('plotly_unhover', {});
|
||||
}
|
||||
return n;
|
||||
} finally { Plotly.restyle = original; }
|
||||
})()"""
|
||||
|
||||
_HOVER_MARKER_X = (
|
||||
"JSON.stringify((document.getElementById('plot').data"
|
||||
".find(function(d){return d.meta && d.meta.kind === 'hover';}) || {}).x)"
|
||||
)
|
||||
|
||||
_EMIT_SNAP_HOVER = """(function(){
|
||||
var gd = document.getElementById('plot');
|
||||
var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0];
|
||||
var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] };
|
||||
gd.emit('plotly_hover', { points: [point] });
|
||||
return true;
|
||||
})()"""
|
||||
|
||||
|
||||
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
canvas = PlotlyCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
canvas.show_project(load_project(EXAMPLES / name))
|
||||
qtbot.waitUntil(lambda: canvas._ready, timeout=30000)
|
||||
qtbot.wait(1500) # let the first Plotly.react settle
|
||||
return canvas
|
||||
|
||||
|
||||
def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def]
|
||||
box: dict[str, object] = {}
|
||||
canvas._web.page().runJavaScript(script, lambda value: box.update(value=value))
|
||||
qtbot.waitUntil(lambda: "value" in box, timeout=timeout)
|
||||
return box["value"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The unfixed version restyled once per hover/unhover event."""
|
||||
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
||||
|
||||
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
||||
|
||||
assert restyles == 0, f"mouse movement caused {restyles} plot restyles"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_snap_marker_shows_and_clears(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]"
|
||||
|
||||
canvas.set_snap_preview_enabled(True)
|
||||
qtbot.wait(100)
|
||||
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
||||
qtbot.wait(250)
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) != "[]", "snap target not shown"
|
||||
|
||||
_run_js(canvas, qtbot, "document.getElementById('plot').emit('plotly_unhover',{})")
|
||||
qtbot.wait(250)
|
||||
assert _run_js(canvas, qtbot, _HOVER_MARKER_X) == "[]", "snap target not cleared"
|
||||
Loading…
Reference in a new issue