From d1dc043fc7fc2fe1e4da5cc5b0368da94eb3a018 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:52:50 -0500 Subject: [PATCH 1/7] Refactor: Add Playwright tests for 2D title rendering and blitting behavior --- .../tests/test_interactive/test_blit_audit.py | 496 ++++++++ .../test_events_regression.py | 1078 +++++++++++++++++ .../tests/test_interactive/test_title.py | 157 +++ .../test_markers/test_marker_transforms.py | 224 ++++ 4 files changed, 1955 insertions(+) create mode 100644 anyplotlib/tests/test_interactive/test_blit_audit.py create mode 100644 anyplotlib/tests/test_interactive/test_events_regression.py create mode 100644 anyplotlib/tests/test_interactive/test_title.py create mode 100644 anyplotlib/tests/test_markers/test_marker_transforms.py diff --git a/anyplotlib/tests/test_interactive/test_blit_audit.py b/anyplotlib/tests/test_interactive/test_blit_audit.py new file mode 100644 index 00000000..5feb745f --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_blit_audit.py @@ -0,0 +1,496 @@ +""" +tests/test_interactive/test_blit_audit.py +========================================== + +Playwright tests that audit canvas redraws and verify blitting behaviour. + +What we are testing +-------------------- +1. **Blit cache correctness** — The ``blitCache`` in ``figure_esm.js`` keyed + on ``(b64, lutKey, w, h)`` must be a genuine cache: adding a marker must + NOT create a new ``OffscreenCanvas`` (GPU texture), while changes to the + LUT parameters (display_min/max, scale_mode, cmap) MUST create a new one. + +2. **No flash on marker add** — Since markers live on a separate + ``markersCanvas`` layer, adding a marker should only clear-and-redraw that + layer. The base ``plotCanvas`` texture must be preserved (blitted, not + rebuilt). + +3. **Draw-call auditing** — Each ``model.set(panel__json, ...)`` call + triggers exactly one ``draw2d`` invocation. We count draw calls via an + injected Proxy on ``window._aplTiming`` that increments + ``window._aplDrawCount[id]`` on every timing assignment. + +Instrumentation strategy +------------------------- +Two counters are injected via ``page.add_init_script()`` before any page JS: + +**OffscreenCanvas counter** — wraps the global class: + + window._aplBitmapRebuildCount = 0 + class _TrackedOffscreen extends OffscreenCanvas { + constructor(w, h) { super(w, h); window._aplBitmapRebuildCount++; } + } + globalThis.OffscreenCanvas = _TrackedOffscreen; + +After the initial render this counter equals 1. Each blit-cache miss bumps +it by 1; a cache hit leaves it unchanged. + +**Draw-call counter** — intercepts ``_aplTiming[id]`` property assignments: + + window._aplTiming = new Proxy({}, { + set(target, key, value) { + window._aplDrawCount[key]++; + ... + } + }); + +``_recordFrame`` in ``figure_esm.js`` sets ``window._aplTiming[id]`` every +draw when ``n >= 2`` (rolling buffer has at least 2 entries). The very first +draw (n=1) is not counted, so ``_aplDrawCount[id] = total_draws - 1``. +Delta tests are used throughout to avoid dependence on this off-by-one. +""" +from __future__ import annotations + +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl + +# --------------------------------------------------------------------------- +# Init script: injects both counters before page JS runs +# --------------------------------------------------------------------------- + +_INSTRUMENTATION_SCRIPT = """ +(function () { + // ── OffscreenCanvas rebuild counter ─────────────────────────────────────── + window._aplBitmapRebuildCount = 0; + const _OrigOffscreen = globalThis.OffscreenCanvas; + class _TrackedOffscreen extends _OrigOffscreen { + constructor(w, h) { + super(w, h); + window._aplBitmapRebuildCount++; + } + } + globalThis.OffscreenCanvas = _TrackedOffscreen; + + // ── Draw-call counter via _aplTiming Proxy ──────────────────────────────── + // _recordFrame() in figure_esm.js does: + // if (!window._aplTiming) window._aplTiming = {}; // skipped: proxy is truthy + // window._aplTiming[p.id] = { count: n, ... }; // triggers our setter + // This fires on every draw after the rolling buffer reaches n >= 2. + window._aplDrawCount = {}; + window._aplTiming = new Proxy({}, { + set: function(target, key, value) { + if (typeof key === 'string') { + window._aplDrawCount[key] = (window._aplDrawCount[key] || 0) + 1; + } + return Reflect.set(target, key, value); + } + }); +})(); +""" + + +# --------------------------------------------------------------------------- +# blit_page fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def blit_page(_pw_browser): + """Like ``bench_page`` but injects rebuild + draw-call counters. + + Uses ``page.add_init_script()`` to wrap ``OffscreenCanvas`` and + ``window._aplTiming`` *before* the page's ``render()`` function runs. + + Usage:: + + def test_something(blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32))) + page = blit_page(fig) + assert _get_rebuild_count(page) == 1 + """ + from anyplotlib.tests.conftest import _build_interact_html + + _pages: list = [] + _paths: list = [] + + def _open(widget): + html = _build_interact_html(widget) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + _paths.append(tmp) + + page = _pw_browser.new_page() + _pages.append(page) + # Inject counters BEFORE navigation so they wrap globals at startup. + page.add_init_script(_INSTRUMENTATION_SCRIPT) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=30_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + + for page in _pages: + try: + page.close() + except Exception: + pass + for path in _paths: + path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# JS helpers +# --------------------------------------------------------------------------- + +def _get_rebuild_count(page) -> int: + """Number of OffscreenCanvas instances created (= bitmap rebuilds).""" + return page.evaluate("() => window._aplBitmapRebuildCount") + + +def _get_draw_count(page, panel_id: str) -> int: + """Monotonic draw-call count for *panel_id* via the _aplTiming proxy. + + Returns 0 after the initial render (n=1, proxy not yet set) and + increments by 1 for each subsequent draw. Delta comparisons are + therefore reliable: draw_after - draw_before == draws_triggered. + """ + return page.evaluate( + "([id]) => (window._aplDrawCount && window._aplDrawCount[id] || 0)", + [panel_id], + ) + + +def _set_panel_state(page, panel_id: str, update: dict) -> None: + """Merge *update* into the panel state and push to the model synchronously.""" + page.evaluate( + """([id, patch]) => { + const key = 'panel_' + id + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + Object.assign(st, patch); + window._aplModel.set(key, JSON.stringify(st)); + }""", + [panel_id, update], + ) + + +def _add_circle_markers(page, panel_id: str, offsets=None) -> None: + """Append a circle marker group to the panel state (no image data change).""" + if offsets is None: + offsets = [[16, 16]] + page.evaluate( + """([id, offsets]) => { + const key = 'panel_' + id + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + const existing = st.markers || []; + existing.push({ + type: 'circles', + offsets: offsets, + sizes: [5], + color: '#ff0000', + }); + st.markers = existing; + window._aplModel.set(key, JSON.stringify(st)); + }""", + [panel_id, offsets], + ) + + +def _wait_raf(page) -> None: + """Wait two rAF ticks so canvas compositing catches up.""" + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Blit cache correctness +# ══════════════════════════════════════════════════════════════════════════════ + +class TestBlitCacheCorrectness: + """The blit cache key (b64 string + LUT params) must be honoured.""" + + def _make_page(self, blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + return page, plot + + def test_initial_render_creates_one_bitmap(self, blit_page): + """After initial render exactly one OffscreenCanvas has been created.""" + page, plot = self._make_page(blit_page) + count = _get_rebuild_count(page) + assert count == 1, ( + f"Expected 1 OffscreenCanvas after initial render, got {count}" + ) + + def test_adding_marker_does_not_rebuild_bitmap(self, blit_page): + """Adding a marker uses the cached bitmap — no new OffscreenCanvas. + + This is the core 'no flash' assertion: markers live on a separate + canvas layer, so the base image texture must not be invalidated. + """ + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _add_circle_markers(page, plot._id) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Adding a marker must NOT create a new OffscreenCanvas " + f"(before={count_before}, after={count_after})" + ) + + def test_adding_multiple_markers_does_not_rebuild_bitmap(self, blit_page): + """Adding N markers sequentially causes 0 extra OffscreenCanvas creations.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + for i in range(5): + _add_circle_markers(page, plot._id, offsets=[[i * 5, i * 5]]) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Adding 5 markers must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + def test_lut_change_invalidates_cache(self, blit_page): + """Changing display_min (LUT key) creates exactly one new OffscreenCanvas.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_min": -0.5}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before + 1, ( + f"Changing display_min must trigger one bitmap rebuild " + f"(before={count_before}, after={count_after})" + ) + + def test_lut_change_then_marker_add_reuses_new_bitmap(self, blit_page): + """After a LUT rebuild, subsequent marker adds still hit the cache.""" + page, plot = self._make_page(blit_page) + + # Invalidate cache with LUT change + _set_panel_state(page, plot._id, {"display_min": -0.5}) + count_after_lut = _get_rebuild_count(page) + + # Marker add must reuse the updated bitmap + _add_circle_markers(page, plot._id) + _wait_raf(page) + + count_after_marker = _get_rebuild_count(page) + assert count_after_marker == count_after_lut, ( + "After LUT rebuild, marker add must still use the cached bitmap. " + f"(after_lut={count_after_lut}, after_marker={count_after_marker})" + ) + + def test_display_max_change_invalidates_cache(self, blit_page): + """Changing display_max also invalidates the blit cache.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_max": 2.0}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after > count_before, ( + "Changing display_max must trigger a bitmap rebuild" + ) + + def test_pan_does_not_rebuild_bitmap(self, blit_page): + """Changing center_x/y (pan) does not rebuild the bitmap.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"center_x": 0.6, "center_y": 0.4}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Pan (center_x/y change) must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + def test_zoom_does_not_rebuild_bitmap(self, blit_page): + """Changing zoom does not rebuild the bitmap.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"zoom": 2.0}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Zoom change must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Draw-call count audit +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDrawCallAudit: + """Each state mutation must trigger exactly one draw2d call. + + Draw counts use _aplDrawCount which increments on every _aplTiming[id] + assignment (after n≥2 frames). The very first draw (n=1) is not counted, + so deltas are used: draw_after - draw_before == draws_triggered_by_action. + """ + + def _make_page(self, blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + return page, plot + + def test_draw_count_baseline_after_initial_render(self, blit_page): + """After initial render only, draw count = 0 (1 draw occurred, n=1 < threshold).""" + page, plot = self._make_page(blit_page) + count = _get_draw_count(page, plot._id) + assert count == 0, ( + f"After initial render, draw count must be 0 (n=1 not yet counted). " + f"Got {count} — indicates unexpected extra draws during setup." + ) + + def test_marker_add_triggers_exactly_one_draw(self, blit_page): + """Adding a single marker triggers exactly one additional draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _add_circle_markers(page, plot._id) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + f"Adding a marker must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after}, delta={draw_after - draw_before})" + ) + + def test_n_marker_adds_trigger_n_draws(self, blit_page): + """Adding N markers sequentially triggers exactly N draw2d calls.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + n = 5 + for i in range(n): + _add_circle_markers(page, plot._id, offsets=[[i * 4, i * 4]]) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + n, ( + f"Adding {n} markers must trigger exactly {n} draws " + f"(before={draw_before}, after={draw_after}, delta={draw_after - draw_before})" + ) + + def test_lut_change_triggers_exactly_one_draw(self, blit_page): + """A LUT parameter change triggers exactly one draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _set_panel_state(page, plot._id, {"display_min": -0.5}) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + f"LUT change must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after})" + ) + + def test_pan_triggers_exactly_one_draw(self, blit_page): + """A Python-side pan update triggers exactly one draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _set_panel_state(page, plot._id, {"center_x": 0.6}) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + "Python-side pan update must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after})" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# No-flash integration test +# ══════════════════════════════════════════════════════════════════════════════ + +class TestNoFlashOnMarkerAdd: + """End-to-end: adding a marker must not flash (no bitmap rebuild + 1 draw).""" + + def test_no_flash_single_marker(self, blit_page): + """Single marker add: one extra draw, zero extra bitmap rebuilds.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.random.default_rng(0).standard_normal((64, 64)).astype(np.float32) + ) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + draw_before = _get_draw_count(page, plot._id) + + _add_circle_markers(page, plot._id, offsets=[[32, 32]]) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + draw_after = _get_draw_count(page, plot._id) + + assert rebuild_after == rebuild_before, ( + "Adding a marker must not rebuild the GPU bitmap (would cause a flash). " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) + assert draw_after == draw_before + 1, ( + f"Expected exactly 1 new draw call, got {draw_after - draw_before}" + ) + + def test_no_flash_multiple_markers_on_real_image(self, blit_page): + """Multiple marker adds on a real image: zero bitmap rebuilds throughout.""" + rng = np.random.default_rng(42) + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(rng.standard_normal((128, 128)).astype(np.float32)) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + + for i in range(4): + _add_circle_markers( + page, plot._id, + offsets=[[int(rng.integers(10, 118)), int(rng.integers(10, 118))]] + ) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + assert rebuild_after == rebuild_before, ( + "4 sequential marker adds must not rebuild the bitmap. " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) + + def test_flash_does_occur_on_lut_change(self, blit_page): + """Sanity: changing LUT params DOES create a new OffscreenCanvas.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_min": -1.0, "display_max": 1.0}) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + assert rebuild_after > rebuild_before, ( + "LUT change must create a new OffscreenCanvas (confirms counter works). " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) diff --git a/anyplotlib/tests/test_interactive/test_events_regression.py b/anyplotlib/tests/test_interactive/test_events_regression.py new file mode 100644 index 00000000..fdac7e88 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_events_regression.py @@ -0,0 +1,1078 @@ +""" +tests/test_interactive/test_events_regression.py +================================================= + +Regression tests for event isolation in figure_esm.js. + +Core invariants verified here +------------------------------ +1. double_click fires on dblclick and is NOT consumed/suppressed by the + pan/drag machinery or the single-click candidate logic. +2. A true drag (significant movement) does NOT emit pointer_down; it emits + pointer_up instead. +3. A short single click emits exactly one pointer_down (no spurious extras). +4. Right-click (button=2) does not trigger the left-click event path. +5. The wheel event fires independently of click/drag state. +6. Event ordering on a double-click: pointer_down ×2 → double_click. +7. A drag followed immediately by a double-click: double_click still fires. + +Coordinate system (mirrors figure_esm.js constants) +---------------------------------------------------- + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 + For a 400×300 fig: plot area = {x:66, y:20, w:330, h:246} + (page coords = canvas coords + GRID_PAD) +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, +) + + +def _clear_events(page) -> None: + """Clear the accumulated event list without re-wrapping the model setter.""" + page.evaluate("() => { window._aplAllEvents = []; }") + +FIG_W, FIG_H = 400, 300 + +# Large enough move to clear the 4 px² drag threshold (>4 px in one direction). +DRAG_DISTANCE = 40 + + +# ── page factories ───────────────────────────────────────────────────────────── + +def _make_2d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +def _make_1d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128))) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +def _make_3d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + Z = X ** 2 + Y ** 2 + plot = ax.plot_surface(X, Y, Z) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +# ══════════════════════════════════════════════════════════════════════════════ +# Double-click isolation +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDoubleClickIsolation: + """double_click must fire even when the pan/drag machinery is active.""" + + # ── Click-cascade prerequisites (expose the e.preventDefault() bug) ─────── + # + # Playwright's page.mouse.dblclick() injects dblclick via CDP (clickCount=2), + # bypassing the browser's click → dblclick cascade entirely. To detect the + # real regression we must verify the prerequisite: that `click` fires after + # mousedown + mouseup. Chrome suppresses `click` when mousedown calls + # e.preventDefault(), which breaks every real user double-click. + + def test_click_fires_after_mousedown_2d(self, interact_page): + """click fires after mousedown+mouseup on the 2D canvas (dblclick prerequisite). + + Chrome spec: mousedown.preventDefault() suppresses the subsequent click. + Without click, the browser's dblclick cascade breaks for real users. + This test directly verifies the precondition: no e.preventDefault() in + the 2D pan mousedown must allow click to propagate. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 2D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick " + "for real users. Fix: remove preventDefault from the 2D pan mousedown." + ) + + def test_click_fires_after_mousedown_1d(self, interact_page): + """click fires after mousedown+mouseup on the 1D canvas (dblclick prerequisite).""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 1D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick." + ) + + def test_click_fires_after_mousedown_3d(self, interact_page): + """click fires after mousedown+mouseup on the 3D canvas (dblclick prerequisite).""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 3D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick." + ) + + # ── Synthetic dblclick tests (page.mouse.dblclick uses CDP clickCount=2) ── + + def test_dblclick_fires_on_2d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 2D panel.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on dblclick" + assert events[0].get("button") == 0, "double_click button should be 0" + + def test_dblclick_fires_on_1d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 1D panel.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on 1D dblclick" + + def test_dblclick_fires_on_3d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 3D panel.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on 3D dblclick" + + def test_dblclick_fires_after_preceding_drag(self, interact_page): + """double_click still fires after a preceding drag sequence. + + This guards the regression where the isPanning flag or the drag + document-level listener could prevent subsequent dblclick events. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Perform a drag first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + + # Now double-click: double_click must still fire + _clear_events(page) + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, ( + "double_click must fire after a preceding drag — " + "isPanning flag must not suppress dblclick" + ) + + def test_dblclick_has_correct_coordinates(self, interact_page): + """double_click payload carries plausible x/y coordinates.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1 + e = events[0] + # x/y should be within the canvas bounds (0..FIG_W, 0..FIG_H) + assert "x" in e and "y" in e, "double_click must carry x, y fields" + assert 0 <= e["x"] <= FIG_W, f"double_click x={e['x']} out of range" + assert 0 <= e["y"] <= FIG_H, f"double_click y={e['y']} out of range" + + def test_double_click_event_order(self, interact_page): + """On dblclick: pointer_down fires before double_click. + + The expected sequence is: pointer_down(×1-2) then double_click. + We verify that the last event in the sequence is double_click (not + the first), so the double_click is never emitted before its preceding + single-click path has had a chance to run. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + all_events = _get_events(page) + # At minimum: pointer_down events and double_click + event_types = [e.get("event_type") for e in all_events] + assert "double_click" in event_types, "double_click must be in event sequence" + last_relevant = [t for t in event_types if t in ("pointer_down", "double_click")] + assert last_relevant, "Expected pointer_down and/or double_click events" + assert last_relevant[-1] == "double_click", ( + f"double_click must be the last in the click sequence, got {last_relevant}" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Drag vs click distinction +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDragVsClick: + """Drag and single-click are mutually exclusive event paths on 2D panels.""" + + def test_single_click_emits_pointer_down(self, interact_page): + """A short stationary click emits exactly one pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + f"Expected exactly 1 pointer_down on single click, got {len(events)}" + ) + + def test_significant_drag_does_not_emit_pointer_down(self, interact_page): + """A drag with significant motion clears the click candidate → no pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + # Move well past the 4 px threshold + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + pd_events = _get_events(page, "pointer_down") + assert len(pd_events) == 0, ( + f"Drag must not emit pointer_down (click candidate should be cleared), " + f"got {len(pd_events)} pointer_down events" + ) + + def test_significant_drag_emits_pointer_up(self, interact_page): + """A drag emits pointer_up on release.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + pu_events = _get_events(page, "pointer_up") + assert len(pu_events) >= 1, "Drag must emit at least one pointer_up on release" + + def test_drag_then_click_emits_pointer_down(self, interact_page): + """After a drag completes, a subsequent short click fires pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Drag first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + + # Reset event collector + _clear_events(page) + + # Short click + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + "After a drag, a short click must still emit pointer_down" + ) + + def test_small_movement_still_registers_as_click(self, interact_page): + """Movement within the 2 px click threshold still triggers pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + # Move less than 2 px — within the distance² ≤ 25 threshold + page.mouse.move(px + 1, py + 1, steps=2) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + "Tiny movement within click threshold must still produce pointer_down" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Button filtering +# ══════════════════════════════════════════════════════════════════════════════ + +class TestButtonFiltering: + """Non-primary buttons must not trigger the 2D left-click event path.""" + + def test_right_click_does_not_emit_pointer_down(self, interact_page): + """Right-click (button=2) on a 2D panel does not emit pointer_down. + + The mousedown handler returns early for button !== 0, so no + clickCandidate is set and pointer_down must not fire. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py, button="right") + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "Right-click must not emit pointer_down (button !== 0 guard)" + ) + + def test_middle_click_does_not_emit_pointer_down(self, interact_page): + """Middle-click (button=1) on a 2D panel does not emit pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py, button="middle") + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "Middle-click must not emit pointer_down (button !== 0 guard)" + ) + + def test_left_click_emits_pointer_down(self, interact_page): + """Sanity-check: left-click still emits pointer_down after button tests.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, "Left-click must emit pointer_down" + + +# ══════════════════════════════════════════════════════════════════════════════ +# Wheel independence +# ══════════════════════════════════════════════════════════════════════════════ + +class TestWheelIndependence: + """Wheel events fire independently of click/drag state.""" + + def test_wheel_after_click_still_fires(self, interact_page): + """wheel event fires correctly after a preceding click.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(50) + + _clear_events(page) + page.mouse.move(px, py) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "wheel must fire after a preceding click" + assert "dy" in events[0], "wheel event must carry dy field" + + def test_wheel_during_drag_does_not_suppress_dblclick(self, interact_page): + """wheel event during an active pan does not block subsequent dblclick.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Drag + wheel + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=5) + page.mouse.wheel(0, 120) + page.mouse.up() + page.wait_for_timeout(100) + + _clear_events(page) + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire after wheel+drag sequence" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1D panel event specifics +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPlot1DEvents: + """1D panel event path regression tests.""" + + def test_1d_single_click_emits_pointer_down_when_near_line(self, interact_page): + """Short 1D click near the plotted line emits pointer_down.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + # Flat line at y=0; the plot centre is near the line. + ax.plot(np.zeros(128)) + page = interact_page(fig) + _clear_events(page) + + px, py = _plot_center_page() + page.mouse.click(px, py) + page.wait_for_timeout(150) + + # pointer_down fires when the hit-test finds the line; if not found + # the event is simply not emitted — so we verify count is 0 or 1. + events = _get_events(page, "pointer_down") + # Not asserting exact count because line hit depends on render geometry. + # Key guarantee: no error raised, and no spurious extra pointer_down events. + assert isinstance(events, list) + + def test_1d_drag_does_not_emit_pointer_down(self, interact_page): + """A 1D drag larger than 5 px does not emit pointer_down.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "1D drag must not emit pointer_down (distance guard)" + ) + + def test_1d_dblclick_fires_double_click(self, interact_page): + """1D panel dblclick emits double_click, not blocked by pan state.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "1D dblclick must emit double_click" + + def test_1d_pointer_up_fires_on_drag(self, interact_page): + """1D drag emits pointer_up on release.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "1D drag must emit pointer_up on release" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 3D panel event specifics +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPlot3DEvents: + """3D panel event regression tests.""" + + def test_3d_dblclick_fires_double_click(self, interact_page): + """3D panel dblclick emits double_click despite drag being active.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "3D dblclick must emit double_click" + + def test_3d_drag_emits_pointer_move(self, interact_page): + """3D drag emits pointer_move events (not blocked by drag state).""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, "3D drag must emit pointer_move events" + + def test_3d_dblclick_fires_after_drag(self, interact_page): + """3D double_click fires after a preceding drag sequence.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + # Drag first + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + + _clear_events(page) + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, ( + "3D double_click must fire after preceding drag" + ) + + def test_3d_wheel_fires_independently(self, interact_page): + """3D wheel event fires even during/after a drag.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "3D wheel must fire" + assert "dy" in events[0] + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pointer enter / leave +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPointerEnterLeave: + """pointer_enter and pointer_leave must fire independently of click/drag.""" + + def test_pointer_enter_fires_after_drag(self, interact_page): + """pointer_enter fires when entering after a drag on another part of the page.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Leave canvas, do a drag outside, then re-enter + page.mouse.move(0, 0) + page.wait_for_timeout(50) + page.mouse.move(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_enter") + assert len(events) >= 1, "pointer_enter must fire on canvas entry" + + def test_pointer_leave_fires_after_drag(self, interact_page): + """pointer_leave fires when leaving even if a drag is in progress.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(30) + _clear_events(page) + + # Move outside the figure entirely + page.mouse.move(0, 0) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "pointer_leave must fire on canvas exit" + + +# ══════════════════════════════════════════════════════════════════════════════ +# HAADF STEM nanoparticle picker regression +# ══════════════════════════════════════════════════════════════════════════════ + +class TestParticlePickerDblClick: + """Regression tests mirroring the HAADF STEM nanoparticle picker example. + + The picker's ``_on_double_click`` handler starts with:: + + if event.xdata is None or event.ydata is None: + return + + So if the JS ``double_click`` event payload does not include ``xdata`` and + ``ydata``, every pick silently fails. These tests reproduce that exact + failure mode. + """ + + def test_dblclick_payload_includes_xdata_ydata(self, interact_page): + """double_click event on a 2D imshow carries non-None xdata and ydata. + + Root cause: the dblclick handler in figure_esm.js was emitting only + canvas-pixel ``x``/``y``, not the image-space ``xdata``/``ydata`` + that Python handlers receive as ``event.xdata``/``event.ydata``. + The particle picker's guard ``if event.xdata is None: return`` meant + every double-click was silently dropped. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on dblclick" + e = events[0] + assert "xdata" in e, "double_click payload must include xdata" + assert "ydata" in e, "double_click payload must include ydata" + assert e["xdata"] is not None, "xdata must not be None" + assert e["ydata"] is not None, "ydata must not be None" + + def test_dblclick_xdata_ydata_are_image_coords(self, interact_page): + """xdata/ydata in double_click are image-space coordinates (0..N range). + + For a 64×64 image, a click at the canvas centre should produce + xdata and ydata near 32 (the image midpoint). + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1 + e = events[0] + # Image is 64×64; centre click should land roughly in the middle half. + assert 10 <= e["xdata"] <= 54, ( + f"xdata={e['xdata']:.1f} out of expected range for 64×64 image centre click" + ) + assert 10 <= e["ydata"] <= 54, ( + f"ydata={e['ydata']:.1f} out of expected range for 64×64 image centre click" + ) + + def test_dblclick_with_circles_markers_present(self, interact_page): + """double_click still carries xdata/ydata when circles markers are on the plot. + + The particle picker adds candidate circles before any interaction. + This test ensures markers don't interfere with the dblclick coordinate + computation. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + # Mirror the particle picker: add candidate circles + candidates = np.array([[16.0, 16.0], [48.0, 48.0], [32.0, 32.0]]) + plot.add_circles(candidates, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire with circles present" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None with circles present" + assert e.get("ydata") is not None, "ydata must not be None with circles present" + + def test_dblclick_after_pan_carries_xdata_ydata(self, interact_page): + """After a pan (which shifts the viewport), dblclick still carries xdata/ydata. + + The particle picker is used with zoom/pan interactions before picking. + xdata/ydata must track the panned viewport, not the raw canvas offset. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + + # Pan the viewport + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py + 20, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + _clear_events(page) + + # Now double-click — xdata/ydata must reflect the panned position + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire after a pan" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None after pan" + assert e.get("ydata") is not None, "ydata must not be None after pan" + + +# ══════════════════════════════════════════════════════════════════════════════ +# HAADF STEM nanoparticle picker — dwell/settle regression +# ══════════════════════════════════════════════════════════════════════════════ + +class TestParticlePickerDwell: + """Regression tests mirroring the particle picker's pointer_settled handler. + + The picker's ``_on_settled`` starts with:: + + if event.xdata is None or event.ydata is None: + return + + So ``pointer_settled`` must include ``xdata``/``ydata`` for the dwell + inspection to work. These tests reproduce that exact failure mode and + guard the fix. + """ + + def _make_picker_page(self, interact_page, ms: int = 200): + """Build a page that mirrors the particle picker setup. + + Uses ms=200 so the test doesn't have to wait the full 300 ms of the + real example. The panel state is serialised into the standalone HTML + so JS sees ``pointer_settled_ms = 200`` without needing a Python kernel. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + # Mirrors the picker: add candidate circles + candidates = np.array([[16.0, 16.0], [48.0, 48.0], [32.0, 32.0]]) + plot.add_circles(candidates, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + # Register a dummy handler so pointer_settled_ms is baked into state + plot.add_event_handler(lambda e: None, "pointer_settled", ms=ms, delta=6) + page = interact_page(fig) + _collect_events(page) + return page, plot + + def test_settled_payload_includes_xdata_ydata(self, interact_page): + """pointer_settled event on a 2D imshow carries non-None xdata and ydata. + + Root cause: the setTimeout callback in figure_esm.js was emitting only + canvas-pixel ``x``/``y``. The particle picker's guard + ``if event.xdata is None: return`` therefore caused every dwell + inspection to be silently skipped. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + # Move into the plot area and hold still — wait for the event + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled must fire after dwell" + e = events[0] + assert "xdata" in e, "pointer_settled payload must include xdata" + assert "ydata" in e, "pointer_settled payload must include ydata" + assert e["xdata"] is not None, "xdata must not be None" + assert e["ydata"] is not None, "ydata must not be None" + + def test_settled_xdata_ydata_are_image_coords(self, interact_page): + """xdata/ydata in pointer_settled are image-space coordinates (0..N range). + + For a 64×64 image, a dwell at the canvas centre should produce + xdata and ydata near 32. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1 + e = events[0] + assert 10 <= e["xdata"] <= 54, ( + f"xdata={e['xdata']:.1f} out of expected range for 64×64 image centre dwell" + ) + assert 10 <= e["ydata"] <= 54, ( + f"ydata={e['ydata']:.1f} out of expected range for 64×64 image centre dwell" + ) + + def test_settled_fires_after_configured_ms(self, interact_page): + """pointer_settled fires after the configured dwell period (ms=200). + + Guards the full pipeline: Python sets pointer_settled_ms in state → + state is serialised to HTML → JS reads it and arms the setTimeout → + event fires after the dwell period with dwell_ms >= 200. + """ + page, plot = self._make_picker_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Verify JS received the configured ms value + ms_in_js = page.evaluate( + f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert ms_in_js == 200, f"JS pointer_settled_ms should be 200, got {ms_in_js}" + + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + e = events[0] + assert "dwell_ms" in e, "pointer_settled must carry dwell_ms" + assert e["dwell_ms"] >= 200, ( + f"dwell_ms={e['dwell_ms']:.0f} should be >= 200" + ) + assert e.get("xdata") is not None, "xdata must be present after dwell" + assert e.get("ydata") is not None, "ydata must be present after dwell" + + def test_settled_not_fired_while_moving(self, interact_page): + """pointer_settled does not fire while the pointer keeps moving. + + The particle picker should only inspect a candidate when the user + deliberately hovers over it — not during panning. + """ + page, plot = self._make_picker_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Keep moving for ~240 ms (less than 200 ms settle threshold between moves) + page.mouse.move(px, py) + for _ in range(8): + px += 5 + page.mouse.move(px, py) + page.wait_for_timeout(25) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled must not fire while pointer is continuously moving" + ) + + def test_settled_fires_after_pan_with_xdata_ydata(self, interact_page): + """After a pan, pointer_settled still carries correct xdata/ydata. + + The particle picker is frequently used after navigating the image. + The settled event must report the panned position, not the original + canvas position. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + # Pan the viewport first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py + 20, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + _clear_events(page) + + # Now hold still over the same canvas position + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled must fire after pan + dwell" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None after pan" + assert e.get("ydata") is not None, "ydata must not be None after pan" + + +# ══════════════════════════════════════════════════════════════════════════════ +# Marker pixel-centre alignment (_imgToCanvas2d +0.5 fix) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestMarkerPixelCenterAlignment: + """Circle markers must be drawn at (ix+0.5)*scale, not ix*scale. + + Each rendered image pixel i occupies canvas [i*scale, (i+1)*scale). + Its visual centre is at (i+0.5)*scale. Previously _imgToCanvas2d used + ix*scale (the leading/top-left edge), so every marker appeared shifted + 0.5*scale pixels up and to the left — visibly wrong when zoomed in. + + This regression test directly samples the markersCanvas pixel at the + point that lies on the circle ring only when the centre is correct. + """ + + def test_circle_drawn_at_pixel_center(self, interact_page): + """Circle at image pixel (8,8) is rendered at canvas centre (136,136). + + Setup: 16×16 image. 2D panels always reserve PAD_T=12px at the top, + so to get scale=16 we need imgW=imgH=256, which requires: + FIG_W=256, FIG_H=256+12=268 (no axes → no left/bottom gutters) + imgW=256, imgH=268-12=256 → scale=min(256/16,256/16)=16 + + correct centre = (8+0.5)*16 = 136 + old wrong centre = 8*16 = 128 + + A radius-0.5 circle (canvas radius 8) centred at (136,136) has its + ring passing through canvas (144,136). The old wrong circle would + have its ring passing through canvas (136,128) instead. + We sample (144,136) and require non-zero alpha. + """ + PAD_T = 12 + IMG_W = IMG_H = 16 + FIG_W = IMG_W * 16 # 256 — so imgW = FIG_W = 256, scale=16 + FIG_H = IMG_H * 16 + PAD_T # 268 — so imgH = FIG_H - PAD_T = 256, scale=16 + + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG_H, IMG_W))) + # radius=0.5 image-px → 8 canvas-px at scale=16 + plot.add_circles(np.array([[8.0, 8.0]]), radius=0.5) + + page = interact_page(fig) + page.wait_for_timeout(300) + + alpha = page.evaluate("""() => { + const dpr = window.devicePixelRatio || 1; + // markersCanvas: pointer-events:none, z-index:6, visible + const mk = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.pointerEvents === 'none' && + c.style.zIndex === '6' && + c.style.display !== 'none' && + c.width > 0); + if (!mk) return -1; + const ctx = mk.getContext('2d'); + // If circle centre is at (136,136), the ring (r=8) passes through (144,136). + // Check a 3px neighbourhood to be robust against sub-pixel rendering. + let maxAlpha = 0; + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const bx = Math.round((144 + dx) * dpr); + const by = Math.round((136 + dy) * dpr); + const d = ctx.getImageData(bx, by, 1, 1).data; + maxAlpha = Math.max(maxAlpha, d[3]); + } + } + return maxAlpha; + }""") + + assert alpha > 0, ( + "Circle ring should appear near canvas (144, 136) when the centre " + "is at (8+0.5)*16=136. alpha=0 means _imgToCanvas2d is still " + "placing the circle at the leading edge (8*16=128) instead of the " + "pixel centre (8.5*16=136)." + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Modifier keys in key_down and pointer_down events +# ══════════════════════════════════════════════════════════════════════════════ + +class TestModifierKeys: + """Verify that modifier keys (ctrl, shift, alt) appear in event payloads. + + The JS _modifiers() helper always runs; these tests lock that invariant + so future refactors can't silently drop modifier detection. + """ + + def test_shift_modifier_in_key_down(self, interact_page): + """Shift+a fires key_down with modifiers=['shift'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('Shift+a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for Shift+a" + assert 'shift' in key_events[-1].get('modifiers', []), ( + "Shift key must appear in modifiers list" + ) + + def test_ctrl_modifier_in_key_down(self, interact_page): + """Ctrl+a fires key_down with modifiers=['ctrl'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('Control+a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for Ctrl+a" + assert 'ctrl' in key_events[-1].get('modifiers', []), ( + "Ctrl key must appear in modifiers list" + ) + + def test_no_modifier_on_plain_key(self, interact_page): + """Plain key press carries an empty modifiers list.""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for plain 'a'" + assert key_events[-1].get('modifiers', None) == [], ( + "Plain key must have empty modifiers list" + ) + + def test_shift_modifier_in_pointer_down(self, interact_page): + """pointer_down with Shift held carries modifiers=['shift'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + _clear_events(page) + page.keyboard.down('Shift') + page.mouse.click(cx, cy) + page.keyboard.up('Shift') + page.wait_for_timeout(80) + ptr_events = _get_events(page, 'pointer_down') + assert ptr_events, "pointer_down must fire on click" + assert 'shift' in ptr_events[-1].get('modifiers', []), ( + "Shift held during click must appear in pointer_down modifiers" + ) diff --git a/anyplotlib/tests/test_interactive/test_title.py b/anyplotlib/tests/test_interactive/test_title.py new file mode 100644 index 00000000..b8d05101 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_title.py @@ -0,0 +1,157 @@ +""" +Playwright tests verifying 2D title rendering. + +Title rendering +--------------- +2D image panels always reserve a PAD_T (12 px) strip at the top, matching 1D +behaviour. ``set_title(...)`` draws text in that strip via a dedicated +``titleCanvas`` (z-index 8) above the plotCanvas. The title must be visible +(non-zero alpha pixels) regardless of whether physical axes are provided. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _title_pixel_count(page) -> int: + """Count non-transparent pixels in the titleCanvas (z-index:8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const ctx = tc.getContext('2d'); + const d = ctx.getImageData(0, 0, tc.width, tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) { if (d[i] > 0) n++; } + return n; + }""") + + +def _title_canvas_info(page) -> dict: + """Return display/position/size info about the titleCanvas.""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return null; + return { + display: tc.style.display, + top: tc.style.top, + left: tc.style.left, + cssWidth: tc.style.width, + cssHeight: tc.style.height, + physW: tc.width, + physH: tc.height, + }; + }""") + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2D title rendering +# ══════════════════════════════════════════════════════════════════════════════ + +class TestTitle2DRendering: + """Title text must appear above the image in the PAD_T strip.""" + + def test_title_canvas_visible_without_axes(self, interact_page): + """titleCanvas is display:block for imshow WITHOUT explicit axes.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Plain imshow title") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None, "titleCanvas not found (z-index:8 canvas missing)" + assert info["display"] == "block", ( + f"titleCanvas must be display:block, got {info['display']!r}" + ) + + def test_title_canvas_visible_with_axes(self, interact_page): + """titleCanvas is display:block for imshow WITH explicit axes.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + units="nm", + ) + plot.set_title("Physical axes title") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None + assert info["display"] == "block" + + def test_title_text_renders_pixels(self, interact_page): + """set_title() produces non-transparent pixels in the titleCanvas.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Hello World") + page = interact_page(fig) + page.wait_for_timeout(200) + + n = _title_pixel_count(page) + assert n > 0, ( + "set_title() must produce visible pixels in titleCanvas. " + f"Got {n} non-zero alpha pixels — title is not rendering." + ) + + def test_empty_title_produces_no_pixels(self, interact_page): + """An empty (unset) title leaves titleCanvas transparent.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + # No set_title call + page = interact_page(fig) + page.wait_for_timeout(200) + + n = _title_pixel_count(page) + assert n == 0, ( + f"Empty title must leave titleCanvas transparent, got {n} pixels" + ) + + def test_title_canvas_in_top_strip(self, interact_page): + """titleCanvas top=0 and height=PAD_T (12 px) — sits above the image.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Position check") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None + assert info["top"] == "0px", ( + f"titleCanvas must sit at top:0, got top={info['top']!r}" + ) + assert info["cssHeight"] == "12px", ( + f"titleCanvas height must be PAD_T=12px, got {info['cssHeight']!r}" + ) + + def test_title_above_image_not_overlapping(self, interact_page): + """titleCanvas sits in the 12px gutter above the plotCanvas (no overlap). + + The plotCanvas must start at top ≥ 12px so the title strip is + unobstructed. + """ + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("No overlap check") + page = interact_page(fig) + page.wait_for_timeout(200) + + plot_canvas_top = page.evaluate("""() => { + // z-index auto = plotCanvas (the image canvas) + const canvases = Array.from(document.querySelectorAll('canvas')); + const pc = canvases.find(c => !c.style.zIndex && c.style.position === 'absolute'); + return pc ? pc.style.top : null; + }""") + + assert plot_canvas_top is not None, "plotCanvas not found" + top_px = int(plot_canvas_top.replace("px", "")) + assert top_px >= 12, ( + f"plotCanvas top must be >= 12px (PAD_T) so title is above image, " + f"got top={top_px}px" + ) diff --git a/anyplotlib/tests/test_markers/test_marker_transforms.py b/anyplotlib/tests/test_markers/test_marker_transforms.py new file mode 100644 index 00000000..25aeb63b --- /dev/null +++ b/anyplotlib/tests/test_markers/test_marker_transforms.py @@ -0,0 +1,224 @@ +""" +tests/test_markers/test_marker_transforms.py +============================================= +Tests for the coordinate transform parameter on marker collections. + +Exercises: transform="data" (default), transform="axes", transform="display", +invalid transform, all add_* methods on both Plot1D and Plot2D, and that +set() preserves the transform. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.markers import MarkerGroup + + +def _push_noop(): + pass + + +def _group(mtype, **kwargs): + return MarkerGroup(mtype, "g1", kwargs, _push_noop) + + +def _make_plot2d(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((32, 32))) + + +def _make_plot1d(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(32)) + + +# --------------------------------------------------------------------------- +# MarkerGroup — wire-format round-trips +# --------------------------------------------------------------------------- + +class TestTransformWireFormat: + + def test_transform_default_is_data(self): + g = _group("circles", offsets=[[1.0, 2.0]], radius=5) + w = g.to_wire("gid") + assert w["transform"] == "data" + + def test_transform_axes_round_trips(self): + g = _group("texts", offsets=[[0.05, 0.95]], texts=["(3, 7)"], + transform="axes") + w = g.to_wire("gid") + assert w["transform"] == "axes" + + def test_transform_display_round_trips(self): + g = _group("circles", offsets=[[8.0, 8.0]], transform="display") + w = g.to_wire("gid") + assert w["transform"] == "display" + + def test_transform_data_explicit(self): + g = _group("rectangles", offsets=[[0.0, 0.0]], widths=10, heights=10, + transform="data") + w = g.to_wire("gid") + assert w["transform"] == "data" + + def test_all_2d_types_emit_transform(self): + types_and_kwargs = [ + ("circles", dict(offsets=[[1, 2]], radius=5)), + ("arrows", dict(offsets=[[1, 2]], U=1, V=1)), + ("ellipses", dict(offsets=[[1, 2]], widths=4, heights=3)), + ("lines", dict(segments=[[[0, 0], [1, 1]]])), + ("rectangles", dict(offsets=[[1, 2]], widths=4, heights=3)), + ("squares", dict(offsets=[[1, 2]], widths=4)), + ("polygons", dict(vertices_list=[[[0,0],[1,0],[0.5,1]]])), + ("texts", dict(offsets=[[1, 2]], texts=["hi"])), + ] + for mtype, kwargs in types_and_kwargs: + g = _group(mtype, transform="axes", **kwargs) + w = g.to_wire("gid") + assert w["transform"] == "axes", f"Failed for type {mtype!r}" + + def test_1d_types_emit_transform(self): + types_and_kwargs = [ + ("points", dict(offsets=[1.0, 2.0])), + ("vlines", dict(offsets=[1.0, 2.0])), + ("hlines", dict(offsets=[1.0, 2.0])), + ] + for mtype, kwargs in types_and_kwargs: + g = _group(mtype, transform="axes", **kwargs) + w = g.to_wire("gid") + assert w["transform"] == "axes", f"Failed for type {mtype!r}" + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +class TestTransformValidation: + + def test_invalid_transform_raises_on_init(self): + with pytest.raises(ValueError, match="transform"): + _group("circles", offsets=[[1, 2]], transform="screen") + + def test_invalid_transform_raises_on_set(self): + g = _group("circles", offsets=[[1, 2]]) + with pytest.raises(ValueError, match="transform"): + g.set(transform="bad") + + def test_valid_transforms_do_not_raise(self): + for tfm in ("data", "axes", "display"): + _group("circles", offsets=[[1, 2]], transform=tfm) # no error + + +# --------------------------------------------------------------------------- +# set() preserves transform +# --------------------------------------------------------------------------- + +class TestTransformPreservedOnSet: + + def test_set_does_not_reset_transform(self): + g = _group("circles", offsets=[[1, 2]], radius=5, transform="axes") + g.set(radius=10) + w = g.to_wire("gid") + assert w["transform"] == "axes" + + def test_set_can_update_transform(self): + g = _group("circles", offsets=[[1, 2]], transform="axes") + g.set(transform="display") + w = g.to_wire("gid") + assert w["transform"] == "display" + + +# --------------------------------------------------------------------------- +# Plot2D add_* methods accept transform kwarg +# --------------------------------------------------------------------------- + +class TestPlot2DTransformKwarg: + + def setup_method(self): + self.plot = _make_plot2d() + + def test_add_circles_transform_axes(self): + g = self.plot.add_circles([[10, 10]], name="c", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_points_transform_axes(self): + g = self.plot.add_points([[10, 10]], name="p", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_texts_transform_axes(self): + g = self.plot.add_texts([[0.05, 0.95]], ["label"], name="t", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_rectangles_transform_display(self): + g = self.plot.add_rectangles([[5, 5]], widths=10, heights=10, name="r", + transform="display") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "display" + + def test_add_arrows_transform_axes(self): + g = self.plot.add_arrows([[5, 5]], U=1, V=1, name="a", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_ellipses_transform_axes(self): + g = self.plot.add_ellipses([[5, 5]], widths=4, heights=3, name="e", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_lines_transform_axes(self): + g = self.plot.add_lines([[[0, 0], [1, 1]]], name="l", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_squares_transform_axes(self): + g = self.plot.add_squares([[5, 5]], widths=4, name="s", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_polygons_transform_axes(self): + verts = [[[0, 0], [1, 0], [0.5, 1]]] + g = self.plot.add_polygons(verts, name="pg", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_default_transform_is_data(self): + g = self.plot.add_texts([[5, 5]], ["hi"], name="t2") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "data" + + +# --------------------------------------------------------------------------- +# Plot1D add_* methods accept transform kwarg +# --------------------------------------------------------------------------- + +class TestPlot1DTransformKwarg: + + def setup_method(self): + self.plot = _make_plot1d() + + def test_add_vlines_transform_axes(self): + self.plot.add_vlines([0.5], name="v", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_hlines_transform_axes(self): + self.plot.add_hlines([0.5], name="h", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_texts_transform_axes(self): + self.plot.add_texts([[0.05, 0.95]], ["label"], name="t", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_default_transform_is_data(self): + self.plot.add_vlines([0.5], name="v2") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "data" From f3feb0e55385852dc7413a8ba2ad7d5cfb5c46fa Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:03:08 -0500 Subject: [PATCH 2/7] Refactor: Remove unused Playwright browser fixture and update testpaths in pytest configuration --- anyplotlib/tests/conftest.py | 15 - .../test_sphinx_anywidget.py | 92 --- .../tests/test_interactive/test_callbacks.py | 536 ------------------ pyproject.toml | 7 +- 4 files changed, 5 insertions(+), 645 deletions(-) delete mode 100644 anyplotlib/tests/test_documentation/test_sphinx_anywidget.py delete mode 100644 anyplotlib/tests/test_interactive/test_callbacks.py diff --git a/anyplotlib/tests/conftest.py b/anyplotlib/tests/conftest.py index 42843058..b1b3faa7 100644 --- a/anyplotlib/tests/conftest.py +++ b/anyplotlib/tests/conftest.py @@ -123,21 +123,6 @@ def _set_baselines_path(request): ) -# --------------------------------------------------------------------------- -# Playwright browser (one Chromium instance for the whole test session) -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def _pw_browser(): - """Yield a headless Chromium browser for the whole test session.""" - from playwright.sync_api import sync_playwright - - with sync_playwright() as pw: - browser = pw.chromium.launch(headless=True) - yield browser - browser.close() - - # --------------------------------------------------------------------------- # HTML builder with readiness sentinel # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py b/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py deleted file mode 100644 index 67f59218..00000000 --- a/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -tests/test_sphinx_anywidget.py -================================ - -Smoke tests for the ``anyplotlib.sphinx_anywidget`` extension. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import anyplotlib as apl -import anyplotlib.figure as _af -from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup # noqa: F401 -from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective # noqa: F401 -from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px -from anyplotlib.sphinx_anywidget._scraper import ( - _INTERACTIVE_RE, - _find_widget, - _iframe_html, -) -from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel # noqa: F401 - - -# ── fixtures ────────────────────────────────────────────────────────────────── - -@pytest.fixture -def simple_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.sin(np.linspace(0, 6.28, 64))) - return fig - - -# ── standalone HTML builder ─────────────────────────────────────────────────── - -def test_standalone_html_contains_awi_state(simple_fig): - html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") - assert "awi_state" in html, "Missing awi_state listener" - - -def test_standalone_html_contains_fig_id(simple_fig): - html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") - assert '"tf"' in html, "Missing fig_id in HTML" - - -def test_widget_px(simple_fig): - w, h = _widget_px(simple_fig) - assert w == 416, f"Expected 416 got {w}" - - -# ── iframe HTML helper ──────────────────────────────────────────────────────── - -def test_iframe_html_interactive_has_activate_btn(): - b = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) - assert "awi-activate-btn" in b, "Missing activate button" - - -def test_iframe_html_static_no_activate_btn(): - s = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) - assert "awi-activate-btn" not in s, "Should not have activate btn on static" - - -# ── no stale push hook ──────────────────────────────────────────────────────── - -def test_no_pyodide_push_hook(): - assert not hasattr(_af, "_pyodide_push_hook"), "_pyodide_push_hook should be gone" - - -# ── _find_widget ────────────────────────────────────────────────────────────── - -def test_find_widget_finds_figure(simple_fig): - found = _find_widget({"fig": simple_fig, "x": 42}) - assert found is simple_fig, "Should find Figure" - - -def test_find_widget_returns_none_for_non_widget(): - assert _find_widget({"x": 42}) is None - - -# ── # Interactive detection ─────────────────────────────────────────────────── - -def test_interactive_re_matches_inline_comment(): - assert _INTERACTIVE_RE.search("fig # Interactive\n"), "Should match" - - -def test_interactive_re_matches_lowercase(): - assert _INTERACTIVE_RE.search("fig # interactive"), "Should match lowercase" - - -def test_interactive_re_no_false_positives(): - assert not _INTERACTIVE_RE.search("fig # not a match"), "Should not match" diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py deleted file mode 100644 index 4b33731a..00000000 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ /dev/null @@ -1,536 +0,0 @@ -"""Tests for the redesigned Event dataclass and CallbackRegistry.""" -from __future__ import annotations -import time -import pytest -import numpy as np -import anyplotlib as apl -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin - - -# ── Event dataclass ─────────────────────────────────────────────────────────── - -class TestEvent: - def test_required_fields(self): - e = Event(event_type="pointer_down", source=None) - assert e.event_type == "pointer_down" - assert e.source is None - - def test_time_stamp_auto_set(self): - before = time.perf_counter() - e = Event(event_type="pointer_down") - after = time.perf_counter() - assert before <= e.time_stamp <= after - - def test_modifiers_default_empty_list(self): - e = Event(event_type="pointer_move") - assert e.modifiers == [] - assert isinstance(e.modifiers, list) - - def test_pointer_fields_default_none(self): - e = Event(event_type="pointer_move") - assert e.x is None - assert e.y is None - assert e.button is None - assert e.buttons == 0 - assert e.xdata is None - assert e.ydata is None - assert e.ray is None - assert e.line_id is None - assert e.dwell_ms is None - - def test_wheel_fields_default_none(self): - e = Event(event_type="wheel") - assert e.dx is None - assert e.dy is None - - def test_key_field_default_none(self): - e = Event(event_type="key_down") - assert e.key is None - - def test_bar_fields_default_none(self): - e = Event(event_type="pointer_down") - assert e.bar_index is None - assert e.value is None - assert e.x_label is None - assert e.group_index is None - - def test_stop_propagation_default_false(self): - e = Event(event_type="pointer_down") - assert e.stop_propagation is False - - def test_all_fields_settable(self): - e = Event( - event_type="pointer_down", - source="plot", - modifiers=["ctrl", "shift"], - x=100, y=200, - button=0, buttons=1, - xdata=3.14, ydata=2.71, - line_id="abc12345", - bar_index=2, value=99.5, x_label="Jan", group_index=1, - dx=10.0, dy=-5.0, - key="q", - ) - assert e.modifiers == ["ctrl", "shift"] - assert e.x == 100 - assert e.xdata == 3.14 - assert e.line_id == "abc12345" - assert e.bar_index == 2 - assert e.key == "q" - assert e.dx == 10.0 - assert e.dy == -5.0 - - def test_no_data_dict_attribute(self): - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") - - def test_repr_includes_event_type(self): - e = Event(event_type="pointer_down", x=10, y=20) - assert "pointer_down" in repr(e) - - def test_stop_propagation_not_in_repr(self): - e = Event(event_type="pointer_down", stop_propagation=True) - assert "stop_propagation" not in repr(e) - - -class TestCallbackRegistry: - def test_connect_returns_int_cid(self): - reg = CallbackRegistry() - cid = reg.connect("pointer_down", lambda e: None) - assert isinstance(cid, int) - - def test_fire_calls_handler(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_fire_only_matching_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("pointer_up", lambda e: calls.append("up")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_disconnect_by_cid(self): - reg = CallbackRegistry() - calls = [] - cid = reg.connect("pointer_down", lambda e: calls.append(1)) - reg.disconnect(cid) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_silent_if_not_found(self): - reg = CallbackRegistry() - reg.disconnect(999) # should not raise - - def test_wildcard_receives_all_types(self): - reg = CallbackRegistry() - calls = [] - reg.connect("*", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - reg.fire(Event("wheel")) - assert calls == ["pointer_down", "key_down", "wheel"] - - def test_priority_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("second"), order=1) - reg.connect("pointer_down", lambda e: order.append("first"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["first", "second"] - - def test_same_priority_fires_in_registration_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("a"), order=0) - reg.connect("pointer_down", lambda e: order.append("b"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["a", "b"] - - def test_stop_propagation(self): - reg = CallbackRegistry() - calls = [] - def handler_a(e): - calls.append("a") - e.stop_propagation = True - reg.connect("pointer_down", handler_a, order=0) - reg.connect("pointer_down", lambda e: calls.append("b"), order=1) - reg.fire(Event("pointer_down")) - assert calls == ["a"] - - def test_disconnect_fn_by_reference(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(1) - reg.connect("pointer_down", fn) - reg.disconnect_fn(fn) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_fn_specific_type(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.disconnect_fn(fn, "pointer_down") - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_bool_true_when_handlers_present(self): - reg = CallbackRegistry() - assert not bool(reg) - reg.connect("pointer_down", lambda e: None) - assert bool(reg) - - def test_invalid_event_type_raises(self): - reg = CallbackRegistry() - with pytest.raises(ValueError, match="Invalid event_type"): - reg.connect("on_click", lambda e: None) - - def test_connect_same_fn_multiple_types(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] - - -class TestPauseHold: - def test_pause_drops_events(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] - - def test_pause_handlers_intact_after_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) - assert calls == [1] - - def test_pause_all_types_when_no_args(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("key_down", lambda e: calls.append("key")) - with reg.pause_events(): - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - assert calls == [] - - def test_pause_only_specified_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append("move")) - reg.connect("pointer_down", lambda e: calls.append("down")) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_pause_nested_same_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) # still paused — outer not exited - reg.fire(Event("pointer_move")) # now fires - assert calls == [1] - - def test_hold_buffers_and_flushes_on_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_settled")) - reg.fire(Event("pointer_settled")) - assert calls == [] # buffered, not fired yet - assert calls == [1, 1] # flushed on exit - - def test_hold_fires_non_held_types_immediately(self): - reg = CallbackRegistry() - move_calls = [] - settled_calls = [] - reg.connect("pointer_move", lambda e: move_calls.append(1)) - reg.connect("pointer_settled", lambda e: settled_calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_move")) # not held → immediate - reg.fire(Event("pointer_settled")) # held → buffered - assert move_calls == [1] - assert settled_calls == [1] # flushed on exit - - def test_hold_events_in_order(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(e.x)) - with reg.hold_events(): - reg.fire(Event("pointer_settled", x=1)) - reg.fire(Event("pointer_settled", x=2)) - reg.fire(Event("pointer_settled", x=3)) - assert calls == [1, 2, 3] - - def test_pause_wins_over_hold(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.hold_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] # dropped, not buffered then flushed - - -class _FakePlot(_EventMixin): - """Minimal plot stub for testing _EventMixin.""" - def __init__(self): - self.callbacks = CallbackRegistry() - self._settled_config = (0, 0) - - def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._settled_config = (ms, delta) - - -class TestEventMixin: - def test_functional_form_single_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_functional_form_multi_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] - - def test_decorator_form_single_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_move") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_move")) - assert calls == ["pointer_move"] - - def test_decorator_form_multi_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_down", "key_down") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("key_down")) - assert calls == ["pointer_down", "key_down"] - - def test_wildcard_decorator(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("*") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("wheel")) - assert calls == ["pointer_down", "wheel"] - - def test_remove_handler_by_fn(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(1) - plot.add_event_handler(fn, "pointer_down") - plot.remove_handler(fn) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_remove_handler_by_fn_specific_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.remove_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_remove_handler_by_cid(self): - plot = _FakePlot() - calls = [] - cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) - plot.remove_handler(cid) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_pointer_settled_configures_on_connect(self): - plot = _FakePlot() - plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) - assert plot._settled_config == (400, 5) - - def test_pointer_settled_clears_on_last_disconnect(self): - plot = _FakePlot() - fn = lambda e: None - plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - plot.remove_handler(fn) - assert plot._settled_config == (0, 0) - - def test_ms_delta_without_settled_raises(self): - plot = _FakePlot() - with pytest.raises(ValueError, match="ms/delta"): - plot.add_event_handler(lambda e: None, "pointer_down", ms=400) - - def test_pause_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_move") - with plot.pause_events("pointer_move"): - plot.callbacks.fire(Event("pointer_move")) - assert calls == [] - - def test_hold_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") - with plot.hold_events("pointer_settled"): - plot.callbacks.fire(Event("pointer_settled")) - assert calls == [] - assert calls == [1] - - -# ── regression: old API is gone ────────────────────────────────────────────── - - -class TestRegressionOldAPIGone: - """Confirm old decorator methods no longer exist on plots and widgets.""" - - def test_plot1d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_click") - - def test_plot1d_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_changed") - - def test_plot1d_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_release") - - def test_plot1d_no_on_key(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_key") - - def test_plot1d_no_disconnect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "disconnect") - - def test_plot2d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - assert not hasattr(plot, "on_click") - - def test_widget_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_changed") - - def test_widget_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_release") - - def test_event_no_phys_x(self): - from anyplotlib.callbacks import Event - e = Event(event_type="pointer_down", xdata=3.14) - assert not hasattr(e, "phys_x") - assert e.xdata == 3.14 - - def test_plot3d_no_on_click(self): - import numpy as np - x = np.linspace(-2, 2, 10) - XX, YY = np.meshgrid(x, x) - fig, ax = apl.subplots(1, 1) - plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) - assert not hasattr(plot, "on_click") - - def test_plotbar_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.bar(["A", "B"], [1.0, 2.0]) - assert not hasattr(plot, "on_click") - - def test_line1d_no_on_hover(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - line = plot.add_line(np.zeros(10)) - assert not hasattr(line, "on_hover") - - def test_line1d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - line = plot.add_line(np.zeros(10)) - assert not hasattr(line, "on_click") - - -# ── Phase 3 — Figure.close() ────────────────────────────────────────────────── - -class TestFigureClose: - - def test_close_in_valid_event_types(self): - assert "close" in VALID_EVENT_TYPES - - def test_figure_close_sets_closed_flag(self): - fig, ax = apl.subplots(1, 1) - ax.plot(np.zeros(10)) - assert not getattr(fig, "_closed", False) - fig.close() - assert fig._closed is True - - def test_figure_close_fires_event_on_plot(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - received = [] - plot.callbacks.connect("close", lambda e: received.append(e.event_type)) - fig.close() - assert received == ["close"] - - def test_figure_close_fires_on_all_panels(self): - fig, (ax1, ax2) = apl.subplots(1, 2) - p1 = ax1.plot(np.zeros(10)) - p2 = ax2.imshow(np.zeros((8, 8))) - counts = [0, 0] - p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) - p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) - fig.close() - assert counts == [1, 1] - - def test_figure_close_is_idempotent(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - received = [] - plot.callbacks.connect("close", lambda e: received.append(e)) - fig.close() - fig.close() - assert len(received) == 1 diff --git a/pyproject.toml b/pyproject.toml index 36f34829..86bea2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,12 +66,15 @@ dev = [ ] [tool.pytest.ini_options] -testpaths = ["anyplotlib/tests"] +testpaths = [ + "anyplotlib/tests", + "anyplotlib/sphinx_anywidget/tests", +] addopts = "--cov=anyplotlib --cov-report=xml --cov-report=term-missing" [tool.coverage.run] source = ["anyplotlib"] -omit = ["anyplotlib/tests/*", "Examples/*", "docs/*"] +omit = ["anyplotlib/tests/*", "anyplotlib/sphinx_anywidget/tests/*", "Examples/*", "docs/*"] [tool.coverage.report] exclude_lines = [ From 1b7f72768fb98f18a9762a32f2622cae4663a0f4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:03:35 -0500 Subject: [PATCH 3/7] Refactor: Add standalone pytest configuration and tests for sphinx_anywidget --- anyplotlib/sphinx_anywidget/tests/__init__.py | 0 anyplotlib/sphinx_anywidget/tests/conftest.py | 125 ++++++++ .../sphinx_anywidget/tests/test_directive.py | 220 ++++++++++++++ .../sphinx_anywidget/tests/test_init.py | 251 ++++++++++++++++ .../sphinx_anywidget/tests/test_repr_utils.py | 261 +++++++++++++++++ .../sphinx_anywidget/tests/test_scraper.py | 269 ++++++++++++++++++ .../tests/test_wheel_builder.py | 93 ++++++ 7 files changed, 1219 insertions(+) create mode 100644 anyplotlib/sphinx_anywidget/tests/__init__.py create mode 100644 anyplotlib/sphinx_anywidget/tests/conftest.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_directive.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_init.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_repr_utils.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_scraper.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py diff --git a/anyplotlib/sphinx_anywidget/tests/__init__.py b/anyplotlib/sphinx_anywidget/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/sphinx_anywidget/tests/conftest.py b/anyplotlib/sphinx_anywidget/tests/conftest.py new file mode 100644 index 00000000..ff8da296 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/conftest.py @@ -0,0 +1,125 @@ +""" +sphinx_anywidget/tests/conftest.py +==================================== + +Standalone pytest configuration for sphinx_anywidget tests. + +This conftest is designed to be self-contained so that when sphinx_anywidget +is extracted into its own package the tests move with no changes. + +Future standalone package name : sphinx-anywidget +Future dependencies : anywidget, playwright, pytest, numpy +""" +from __future__ import annotations + +import pathlib +import tempfile + +import numpy as np +import pytest + + +# --------------------------------------------------------------------------- +# Figure fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def simple_fig(): + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.imshow(np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)) + return fig + + +# --------------------------------------------------------------------------- +# Playwright browser (one Chromium instance for the whole test session) +# --------------------------------------------------------------------------- + +@pytest.fixture +def saw_browser(request): + """Headless Chromium browser for sphinx_anywidget Playwright tests. + + When running inside the combined anyplotlib test suite, reuses the + existing session-scoped ``_pw_browser`` fixture (from + ``anyplotlib/tests/conftest.py``) to avoid spawning a second + ``sync_playwright()`` context — two concurrent contexts fail in one + process. + + When running standalone (future separate package), creates its own + headless Chromium instance. + """ + pytest.importorskip("playwright", reason="playwright not installed") + + try: + # Combined suite path: _pw_browser is session-scoped and getfixturevalue + # initialises it on first access, then reuses it. No second + # sync_playwright() context is opened. + yield request.getfixturevalue("_pw_browser") + return + except pytest.FixtureLookupError: + pass + + # Standalone path: create our own browser. + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + + +# --------------------------------------------------------------------------- +# HTML page helper +# --------------------------------------------------------------------------- + +@pytest.fixture +def render_widget_page(saw_browser): + """Callable: open a widget's standalone HTML in a headless browser page. + + Returns a ``(page, tmp_path)`` pair. Caller is responsible for closing + the page when done (or use the ``render_page`` fixture instead). + """ + from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html + + _pages: list = [] + _paths: list = [] + + def _open(widget, *, fig_id="test_fig"): + html = build_standalone_html(widget, resizable=False, fig_id=fig_id) + # Inject readiness sentinel so we can wait for render completion. + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + _paths.append(tmp) + + page = saw_browser.new_page() + _pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + + for page in _pages: + try: + page.close() + except Exception: + pass + for path in _paths: + path.unlink(missing_ok=True) diff --git a/anyplotlib/sphinx_anywidget/tests/test_directive.py b/anyplotlib/sphinx_anywidget/tests/test_directive.py new file mode 100644 index 00000000..3d290339 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_directive.py @@ -0,0 +1,220 @@ +""" +sphinx_anywidget/tests/test_directive.py +========================================= + +Tests for ``sphinx_anywidget._directive``: + - ``_find_widget`` + - ``AnywidgetFigureDirective`` via a mock Sphinx environment +""" +from __future__ import annotations + +import pathlib +import tempfile +import textwrap + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._directive import _find_widget + + +# --------------------------------------------------------------------------- +# _find_widget (directive's copy) +# --------------------------------------------------------------------------- + +class TestFindWidgetDirective: + def test_finds_figure(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + found = _find_widget({"fig": fig, "x": 42}) + assert found is fig + + def test_returns_none_for_plain_values(self): + assert _find_widget({"x": 1, "y": "hello"}) is None + + def test_returns_last_widget(self): + fig1, ax1 = apl.subplots(1, 1) + ax1.plot(np.zeros(5)) + fig2, ax2 = apl.subplots(1, 1) + ax2.plot(np.zeros(5)) + found = _find_widget({"a": fig1, "b": fig2}) + assert found is fig2 + + def test_ignores_non_callable_repr_html(self): + class FakeWidget: + _repr_html_ = "not a callable" + _esm = "..." + assert _find_widget({"w": FakeWidget()}) is None + + +# --------------------------------------------------------------------------- +# Minimal Sphinx environment mock +# --------------------------------------------------------------------------- + +class MockConfig: + def __init__(self, confdir): + self.anywidget_pyodide_package = None + self.html_static_path = [] + self._confdir = confdir + + def __getattr__(self, name): + return None + + +class MockEnv: + def __init__(self, confdir, outdir): + self.config = MockConfig(confdir) + self.srcdir = str(confdir) + self.docname = "index" + + class _App: + def __init__(self, confdir, outdir): + self.confdir = str(confdir) + self.outdir = str(outdir) + self.config = MockConfig(confdir) + + self.app = _App(confdir, outdir) + + +class MockReporter: + def error(self, msg, *args, line=None): + from docutils import nodes + return nodes.system_message(msg, level=3, type="ERROR") + + +class MockState: + def __init__(self, confdir, outdir): + self.document = type("doc", (), { + "settings": type("s", (), { + "env": MockEnv(confdir, outdir), + })(), + })() + self.reporter = MockReporter() + + +# --------------------------------------------------------------------------- +# AnywidgetFigureDirective +# --------------------------------------------------------------------------- + +class TestAnywidgetFigureDirective: + def _make_directive(self, src_file: pathlib.Path, confdir, outdir, options=None): + from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective + from docutils.parsers.rst import Directive + + class ConcreteDirective(AnywidgetFigureDirective): + pass + + state = MockState(confdir, outdir) + d = ConcreteDirective.__new__(ConcreteDirective) + d.arguments = [str(src_file.relative_to(confdir))] + d.options = options or {} + d.content = [] + d.lineno = 1 + d.state = state + d.state_machine = None + d.reporter = state.reporter + return d + + def test_missing_file_returns_error_node(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + d = self._make_directive( + confdir / "nonexistent.py", confdir, outdir + ) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_valid_script_returns_raw_node(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "example.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(10)) + """)) + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) >= 1 + from docutils import nodes + assert any(isinstance(n, nodes.raw) for n in result) + + def test_no_widget_in_script_returns_error(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "no_widget.py" + script.write_text("x = 1 + 1\n") + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_failing_script_returns_error(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "broken.py" + script.write_text("raise ValueError('intentional failure')\n") + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_interactive_option_embeds_python_src(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "interactive_example.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + """)) + + d = self._make_directive(script, confdir, outdir, options={"interactive": None}) + result = d.run() + from docutils import nodes + raw_nodes = [n for n in result if isinstance(n, nodes.raw)] + assert raw_nodes + combined = " ".join(str(n) for n in raw_nodes) + assert "text/x-python" in combined or "awi-activate-btn" in combined + + def test_width_option_respected(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "wide.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(10)) + """)) + + d = self._make_directive(script, confdir, outdir, options={"width": "300"}) + result = d.run() + from docutils import nodes + raw_nodes = [n for n in result if isinstance(n, nodes.raw)] + assert raw_nodes diff --git a/anyplotlib/sphinx_anywidget/tests/test_init.py b/anyplotlib/sphinx_anywidget/tests/test_init.py new file mode 100644 index 00000000..e5d84ecf --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_init.py @@ -0,0 +1,251 @@ +""" +sphinx_anywidget/tests/test_init.py +===================================== + +Tests for ``sphinx_anywidget.__init__``: + - ``setup()`` + - ``_find_project_root()`` + - ``_infer_package_name()`` + - ``_copy_static_assets()`` + - ``_build_pyodide_wheel()`` + - No stale push hook on the figure module +""" +from __future__ import annotations + +import pathlib +import tempfile +import textwrap + +import pytest + +import anyplotlib.figure as _af +from anyplotlib.sphinx_anywidget import setup +from anyplotlib.sphinx_anywidget import ( + _copy_static_assets, + _build_pyodide_wheel, + _find_project_root, + _infer_package_name, +) + + +# --------------------------------------------------------------------------- +# Helpers / mocks +# --------------------------------------------------------------------------- + +class MockConfig: + def __init__(self, confdir): + self.anywidget_pyodide_package = None + self.html_static_path = [] + self._confdir = str(confdir) + + def __getattr__(self, name): + return None + + +class MockApp: + """Minimal Sphinx application stub.""" + + def __init__(self, confdir, outdir=None): + self.confdir = str(confdir) + self.outdir = str(outdir or confdir / "_build") + self.config = MockConfig(confdir) + self._directives = {} + self._js_files = [] + self._css_files = [] + self._config_values = {} + self._event_handlers = {} + + def add_config_value(self, name, default, rebuild): + self._config_values[name] = default + + def add_directive(self, name, cls): + self._directives[name] = cls + + def connect(self, event, handler): + self._event_handlers.setdefault(event, []).append(handler) + + def add_js_file(self, path, **kwargs): + self._js_files.append(path) + + def add_css_file(self, path, **kwargs): + self._css_files.append(path) + + +# --------------------------------------------------------------------------- +# setup() +# --------------------------------------------------------------------------- + +class TestSetup: + def test_returns_dict_with_version(self, tmp_path): + app = MockApp(tmp_path) + result = setup(app) + assert isinstance(result, dict) + assert "version" in result + + def test_registers_anywidget_figure_directive(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget-figure" in app._directives + + def test_registers_anywidget_config_value(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_pyodide_package" in app._config_values + + def test_adds_bridge_js(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_bridge.js" in app._js_files + + def test_adds_config_js(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_config.js" in app._js_files + + def test_adds_overlay_css(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_overlay.css" in app._css_files + + def test_connects_builder_inited_events(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "builder-inited" in app._event_handlers + assert len(app._event_handlers["builder-inited"]) >= 2 + + def test_parallel_safe_flags(self, tmp_path): + app = MockApp(tmp_path) + result = setup(app) + assert result.get("parallel_read_safe") is True + assert result.get("parallel_write_safe") is True + + +# --------------------------------------------------------------------------- +# _copy_static_assets +# --------------------------------------------------------------------------- + +class TestCopyStaticAssets: + def test_adds_static_src_to_html_static_path(self, tmp_path): + app = MockApp(tmp_path) + _copy_static_assets(app) + assert len(app.config.html_static_path) == 1 + assert pathlib.Path(app.config.html_static_path[0]).is_dir() + + def test_does_not_duplicate_existing_entry(self, tmp_path): + app = MockApp(tmp_path) + _copy_static_assets(app) + _copy_static_assets(app) + assert len(app.config.html_static_path) == 1 + + +# --------------------------------------------------------------------------- +# _build_pyodide_wheel +# --------------------------------------------------------------------------- + +class TestBuildPyodideWheel: + def test_no_package_writes_disabled_config(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + _build_pyodide_wheel(app) + config_js = confdir / "_static" / "anywidget_config.js" + assert config_js.exists() + content = config_js.read_text() + assert "null" in content or "Disabled" in content or "disabled" in content + + def test_explicit_package_writes_config_js(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + app.config.anywidget_pyodide_package = "mypackage" + _build_pyodide_wheel(app) + config_js = confdir / "_static" / "anywidget_config.js" + assert config_js.exists() + assert "mypackage" in config_js.read_text() + + def test_existing_wheel_skips_rebuild(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + app.config.anywidget_pyodide_package = "mypkg" + + wheels_dir = confdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + stable = wheels_dir / "mypkg-0.0.0-py3-none-any.whl" + stable.write_bytes(b"dummy wheel content") + mtime_before = stable.stat().st_mtime + + _build_pyodide_wheel(app) + assert stable.stat().st_mtime == mtime_before, "Should not rebuild existing wheel" + + +# --------------------------------------------------------------------------- +# _find_project_root +# --------------------------------------------------------------------------- + +class TestFindProjectRoot: + def test_finds_root_with_pyproject_toml(self, tmp_path): + project = tmp_path / "myproject" + project.mkdir() + (project / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') + docs = project / "docs" + docs.mkdir() + root = _find_project_root(docs) + assert root == project + + def test_finds_root_at_confdir_itself(self, tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') + root = _find_project_root(tmp_path) + assert root == tmp_path + + def test_fallback_to_parent_when_no_marker(self, tmp_path): + deep = tmp_path / "a" / "b" / "c" + deep.mkdir(parents=True) + root = _find_project_root(deep) + assert root == deep.parent + + def test_finds_setup_py(self, tmp_path): + (tmp_path / "setup.py").write_text("from setuptools import setup; setup()\n") + docs = tmp_path / "docs" + docs.mkdir() + root = _find_project_root(docs) + assert root == tmp_path + + +# --------------------------------------------------------------------------- +# _infer_package_name +# --------------------------------------------------------------------------- + +class TestInferPackageName: + def test_infers_from_pyproject_in_parent(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "mylib"\n') + docs = tmp_path / "docs" + docs.mkdir() + app = MockApp(docs) + name = _infer_package_name(app) + assert name == "mylib" + + def test_infers_from_pyproject_in_confdir(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "inconfdir"\n') + app = MockApp(tmp_path) + name = _infer_package_name(app) + assert name == "inconfdir" + + def test_returns_none_when_no_pyproject(self, tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + app = MockApp(docs) + name = _infer_package_name(app) + assert name is None + + +# --------------------------------------------------------------------------- +# Regression: no stale push hook +# --------------------------------------------------------------------------- + +def test_no_pyodide_push_hook_on_figure_module(): + assert not hasattr(_af, "_pyodide_push_hook"), ( + "_pyodide_push_hook should have been removed from the figure module" + ) diff --git a/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py b/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py new file mode 100644 index 00000000..8d437354 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py @@ -0,0 +1,261 @@ +""" +sphinx_anywidget/tests/test_repr_utils.py +========================================== + +Tests for ``sphinx_anywidget._repr_utils``: + - ``_widget_state`` — trait serialisation + - ``_widget_px`` — pixel dimension resolution + - ``build_standalone_html`` — self-contained HTML builder +""" +from __future__ import annotations + +import base64 +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._repr_utils import ( + _widget_px, + _widget_state, + build_standalone_html, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 240)) + ax.imshow(np.zeros((32, 32), dtype=np.float32)) + return fig + + +@pytest.fixture +def multi_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.zeros(32)) + axes[1].imshow(np.zeros((16, 16), dtype=np.float32)) + return fig + + +# --------------------------------------------------------------------------- +# _widget_state +# --------------------------------------------------------------------------- + +class TestWidgetState: + def test_returns_dict(self, line_fig): + state = _widget_state(line_fig) + assert isinstance(state, dict) + + def test_no_private_keys(self, line_fig): + state = _widget_state(line_fig) + for key in state: + assert not key.startswith("_"), f"Private key leaked: {key!r}" + + def test_layout_json_present(self, line_fig): + state = _widget_state(line_fig) + assert "layout_json" in state + + def test_bytes_trait_encoded_as_base64_buffer(self): + """bytes/bytearray traits are serialised as {buffer: base64} dicts.""" + import anywidget + import traitlets + + class ByteWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + data = traitlets.Bytes(b"\x00\x01\x02", sync=True) + + w = ByteWidget() + state = _widget_state(w) + assert "data" in state + encoded = state["data"] + assert isinstance(encoded, dict) + assert "buffer" in encoded + decoded = base64.b64decode(encoded["buffer"]) + assert decoded == b"\x00\x01\x02" + + def test_bytes_trait_empty_bytes(self): + import anywidget + import traitlets + + class EmptyBytesWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + buf = traitlets.Bytes(b"", sync=True) + + w = EmptyBytesWidget() + state = _widget_state(w) + assert isinstance(state["buf"], dict) + assert "buffer" in state["buf"] + assert base64.b64decode(state["buf"]["buffer"]) == b"" + + +# --------------------------------------------------------------------------- +# _widget_px +# --------------------------------------------------------------------------- + +class TestWidgetPx: + def test_figure_adds_padding(self, line_fig): + w, h = _widget_px(line_fig) + assert w == line_fig.fig_width + 16 + assert h == line_fig.fig_height + 16 + + def test_figure_400x300(self, line_fig): + w, h = _widget_px(line_fig) + assert w == 416 + assert h == 316 + + def test_display_override_attributes(self): + import anywidget + import traitlets + + class CustomWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + _display_width = 500 + _display_height = 250 + + cw = CustomWidget() + w, h = _widget_px(cw) + assert w == 500 + assert h == 250 + + def test_viewer_width_height_traits(self): + import anywidget + import traitlets + + class ViewerWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + viewer_width = traitlets.Int(300, sync=True) + viewer_height = traitlets.Int(200, sync=True) + + vw = ViewerWidget() + w, h = _widget_px(vw) + assert w == 320 + assert h == 220 + + def test_fallback_dimensions(self): + import anywidget + + class MinimalWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + + mw = MinimalWidget() + w, h = _widget_px(mw) + assert w == 560 + assert h == 340 + + def test_multi_panel_figure(self, multi_fig): + w, h = _widget_px(multi_fig) + assert w == multi_fig.fig_width + 16 + assert h == multi_fig.fig_height + 16 + + +# --------------------------------------------------------------------------- +# build_standalone_html +# --------------------------------------------------------------------------- + +class TestBuildStandaloneHtml: + def test_returns_string(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert isinstance(html, str) + + def test_contains_awi_state_listener(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert "awi_state" in html + + def test_contains_fig_id(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="myfig") + assert '"myfig"' in html + + def test_html_doctype(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert html.strip().startswith("") or " 0 + + def test_imshow_fig_serialises(self, imshow_fig): + html = build_standalone_html(imshow_fig, resizable=False, fig_id="img") + assert "awi_state" in html + + def test_html_is_valid_json_state(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + # Extract STATE JSON — it appears as a JS variable assignment + import re + m = re.search(r"const STATE\s*=\s*(\{.*?\});", html, re.DOTALL) + if m: + data = json.loads(m.group(1)) + assert "layout_json" in data + + +# --------------------------------------------------------------------------- +# Playwright: HTML renders correctly in browser +# --------------------------------------------------------------------------- + +class TestBuildStandaloneHtmlPlaywright: + def test_widget_root_visible(self, render_widget_page, line_fig): + """The rendered page contains a visible #widget-root element.""" + page = render_widget_page(line_fig, fig_id="pw_test") + root = page.locator("#widget-root") + assert root.count() == 1 + + def test_canvas_rendered(self, render_widget_page, line_fig): + """At least one canvas element is present after render.""" + page = render_widget_page(line_fig, fig_id="pw_canvas") + canvas_count = page.evaluate("() => document.querySelectorAll('canvas').length") + assert canvas_count >= 1 + + def test_model_state_accessible(self, saw_browser, line_fig): + """window._aplModel is available and has layout_json set.""" + import pathlib, tempfile + html = build_standalone_html(line_fig, resizable=False, fig_id="pw_model") + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ).replace( + "const model = makeModel(STATE);", + "const model = makeModel(STATE);\nwindow._aplModel = model;", + ) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + page = saw_browser.new_page() + try: + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + has_model = page.evaluate("() => typeof window._aplModel !== 'undefined'") + assert has_model + finally: + page.close() + tmp.unlink(missing_ok=True) diff --git a/anyplotlib/sphinx_anywidget/tests/test_scraper.py b/anyplotlib/sphinx_anywidget/tests/test_scraper.py new file mode 100644 index 00000000..ee410174 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_scraper.py @@ -0,0 +1,269 @@ +""" +sphinx_anywidget/tests/test_scraper.py +======================================== + +Tests for ``sphinx_anywidget._scraper``: + - Regex patterns (_INTERACTIVE_RE, _PYODIDE_PACKAGES_RE) + - ``_find_widget`` + - ``_iframe_html`` + - ``_make_thumbnail_png`` (Playwright — skipped if not installed) + - ``AnywidgetScraper`` unit tests +""" +from __future__ import annotations + +import importlib.util +import re + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._scraper import ( + MAX_DOC_WIDTH, + _INTERACTIVE_RE, + _PYODIDE_PACKAGES_RE, + _find_widget, + _iframe_html, + AnywidgetScraper, + ViewerScraper, +) +from anyplotlib.tests._png_utils import decode_png + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 250)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.imshow(np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)) + return fig + + +@pytest.fixture +def multi_panel_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.cos(np.linspace(0, 6.28, 64))) + axes[1].imshow(np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32)) + return fig + + +# --------------------------------------------------------------------------- +# _INTERACTIVE_RE +# --------------------------------------------------------------------------- + +class TestInteractiveRe: + def test_matches_inline_comment(self): + assert _INTERACTIVE_RE.search("fig # Interactive\n") + + def test_matches_lowercase(self): + assert _INTERACTIVE_RE.search("fig # interactive") + + def test_matches_uppercase(self): + assert _INTERACTIVE_RE.search("fig # INTERACTIVE") + + def test_matches_with_extra_whitespace(self): + assert _INTERACTIVE_RE.search("fig # Interactive \n") + + def test_no_false_positive_other_comment(self): + assert not _INTERACTIVE_RE.search("fig # not a match") + + def test_no_false_positive_mid_line(self): + assert not _INTERACTIVE_RE.search("# Interactive is nice") + + def test_matches_at_end_of_multiline_source(self): + src = "import numpy as np\nfig, ax = apl.subplots(1, 1)\nfig # Interactive" + assert _INTERACTIVE_RE.search(src) + + +# --------------------------------------------------------------------------- +# _PYODIDE_PACKAGES_RE +# --------------------------------------------------------------------------- + +class TestPyodidePackagesRe: + def test_matches_simple_list(self): + src = '_PYODIDE_PACKAGES = ["scipy", "pandas"]' + m = _PYODIDE_PACKAGES_RE.search(src) + assert m is not None + import ast + assert ast.literal_eval(m.group(1)) == ["scipy", "pandas"] + + def test_matches_empty_list(self): + src = "_PYODIDE_PACKAGES = []" + m = _PYODIDE_PACKAGES_RE.search(src) + assert m is not None + + def test_no_match_when_absent(self): + src = "import numpy as np\nfig, ax = apl.subplots(1, 1)" + assert _PYODIDE_PACKAGES_RE.search(src) is None + + +# --------------------------------------------------------------------------- +# _find_widget +# --------------------------------------------------------------------------- + +class TestFindWidget: + def test_finds_figure(self, line_fig): + found = _find_widget({"fig": line_fig, "x": 42}) + assert found is line_fig + + def test_returns_none_for_non_widget(self): + assert _find_widget({"x": 42, "y": "hello"}) is None + + def test_returns_last_widget(self, line_fig, imshow_fig): + found = _find_widget({"fig1": line_fig, "fig2": imshow_fig}) + assert found is imshow_fig + + def test_ignores_non_callable_repr_html(self): + class FakeWidget: + _repr_html_ = "not callable" + _esm = "..." + assert _find_widget({"w": FakeWidget()}) is None + + def test_finds_widget_without_esm_by_module(self): + class ModuleWidget: + def _repr_html_(self): + return "
" + def traits(self): + return {} + ModuleWidget.__module__ = "somewidget.core" + found = _find_widget({"w": ModuleWidget()}) + assert found is not None + + +# --------------------------------------------------------------------------- +# _iframe_html +# --------------------------------------------------------------------------- + +class TestIframeHtml: + def test_returns_string(self): + html = _iframe_html("test.html", 400, 300, fig_id="abc") + assert isinstance(html, str) + + def test_contains_iframe_src(self): + html = _iframe_html("test.html", 400, 300, fig_id="abc") + assert 'src="test.html"' in html + + def test_interactive_has_activate_btn(self): + html = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) + assert "awi-activate-btn" in html + + def test_static_no_activate_btn(self): + html = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) + assert "awi-activate-btn" not in html + + def test_fig_id_in_output(self): + html = _iframe_html("t.html", 400, 300, fig_id="myfig") + assert "myfig" in html + + def test_auto_uid_when_no_fig_id(self): + html = _iframe_html("t.html", 400, 300) + assert isinstance(html, str) + assert len(html) > 0 + + def test_max_width_respected(self): + html = _iframe_html("t.html", 1000, 500, fig_id="w", max_width=400) + # The wrapper div should have width <= 400px + assert "400px" in html or "width:400px" in html.replace(" ", "") + + def test_default_max_width_is_MAX_DOC_WIDTH(self): + html = _iframe_html("t.html", MAX_DOC_WIDTH + 100, 300, fig_id="w") + assert f"{MAX_DOC_WIDTH}px" in html + + def test_max_height_constrains_scale(self): + html = _iframe_html("t.html", 400, 800, fig_id="h", max_height=200) + assert isinstance(html, str) + + def test_contains_resize_script(self): + html = _iframe_html("t.html", 400, 300, fig_id="rs") + assert "requestAnimationFrame" in html + + def test_no_badge_when_not_interactive(self): + html = _iframe_html("t.html", 400, 300, fig_id="nb", interactive=False) + assert "awi-badge" not in html + + def test_badge_present_when_interactive(self): + html = _iframe_html("t.html", 400, 300, fig_id="bi", interactive=True) + assert "awi-badge" in html + + +# --------------------------------------------------------------------------- +# AnywidgetScraper / ViewerScraper +# --------------------------------------------------------------------------- + +class TestAnywidgetScraper: + def test_repr(self): + s = AnywidgetScraper() + assert repr(s) == "AnywidgetScraper()" + + def test_viewerscraper_is_alias(self): + assert ViewerScraper is AnywidgetScraper + + def test_call_returns_empty_string_when_no_widget(self): + scraper = AnywidgetScraper() + block = ("code", "x = 1") + block_vars = { + "example_globals": {"x": 1}, + "image_path_iterator": iter([]), + "src_file": "test.py", + } + result = scraper(block, block_vars, {}) + assert result == "" + + def test_call_returns_empty_when_globals_empty(self): + scraper = AnywidgetScraper() + block = ("code", "x = 1") + block_vars = { + "example_globals": {}, + "image_path_iterator": iter([]), + "src_file": "test.py", + } + result = scraper(block, block_vars, {}) + assert result == "" + + +# --------------------------------------------------------------------------- +# _make_thumbnail_png — Playwright +# --------------------------------------------------------------------------- + +_has_playwright = importlib.util.find_spec("playwright") is not None + + +@pytest.mark.skipif(not _has_playwright, reason="playwright not installed") +class TestMakeThumbnailPng: + def test_line_fig_returns_valid_png(self, line_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(line_fig) + assert isinstance(png_bytes, bytes) + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n", "Not a valid PNG" + + def test_imshow_fig_returns_valid_png(self, imshow_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(imshow_fig) + arr = decode_png(png_bytes) + assert arr.ndim == 3 + assert arr.shape[2] in (3, 4) + + def test_multi_panel_returns_valid_png(self, multi_panel_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(multi_panel_fig) + assert isinstance(png_bytes, bytes) + assert len(png_bytes) > 0 + + def test_thumbnail_is_dark_theme(self, line_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(line_fig) + arr = decode_png(png_bytes) + # Dark theme (#1e1e2e) — top-left pixel should be dark + top_left = arr[0, 0, :3] + assert top_left.sum() < 200, ( + f"Expected dark background pixel, got {top_left}" + ) diff --git a/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py b/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py new file mode 100644 index 00000000..b91006de --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py @@ -0,0 +1,93 @@ +""" +sphinx_anywidget/tests/test_wheel_builder.py +============================================= + +Tests for ``sphinx_anywidget._wheel_builder.build_wheel``. +""" +from __future__ import annotations + +import pathlib +import tempfile + +import pytest + +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _project_root() -> pathlib.Path: + """Return the anyplotlib project root (contains pyproject.toml).""" + here = pathlib.Path(__file__).parent + for candidate in [here, *here.parents]: + if (candidate / "pyproject.toml").exists(): + return candidate + pytest.skip("Could not find project root with pyproject.toml") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestBuildWheel: + def test_builds_wheel_for_anyplotlib(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None, "build_wheel returned None" + assert result.exists(), f"Wheel file not found: {result}" + assert result.suffix == ".whl" + + def test_wheel_has_stable_name(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert "0.0.0" in result.name, ( + f"Expected 0.0.0 sentinel in wheel name, got {result.name!r}" + ) + + def test_wheel_placed_in_wheels_subdir(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert result.parent.name == "wheels" + + def test_existing_wheel_replaced(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + first = build_wheel(static_dir, "anyplotlib", _project_root()) + assert first is not None + first_mtime = first.stat().st_mtime + + second = build_wheel(static_dir, "anyplotlib", _project_root()) + assert second is not None + assert second.exists() + + def test_invalid_project_returns_none(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + fake_root = pathlib.Path(tmp) / "nonexistent" + result = build_wheel(static_dir, "nonexistent_pkg_xyz", fake_root) + assert result is None + + def test_wheels_dir_created_if_missing(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) / "nested" / "static" + static_dir.mkdir(parents=True) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert (static_dir / "wheels").is_dir() + + def test_wheel_version_is_sentinel(self): + """Wheel uses the 0.0.0 sentinel version regardless of the package's actual version.""" + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + if result is not None: + assert "0.0.0" in result.name, ( + f"Expected 0.0.0 sentinel version in wheel name, got {result.name!r}" + ) From 5384496dc91206c6d64c33d9cefb23f2717af716 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:43:44 -0500 Subject: [PATCH 4/7] Refactor: Reorganize plot classes to inherit from a shared base class and streamline widget management --- anyplotlib/_base_plot.py | 182 ++++++ anyplotlib/markers.py | 38 +- anyplotlib/plot1d/_plot1d.py | 152 +---- anyplotlib/plot1d/_plotbar.py | 122 +--- anyplotlib/plot2d/_plot2d.py | 261 ++++----- anyplotlib/plot3d/_plot3d.py | 49 +- .../test_callbacks_playwright.py | 491 ++++++++++++++++ .../test_interactive/test_callbacks_unit.py | 528 ++++++++++++++++++ 8 files changed, 1357 insertions(+), 466 deletions(-) create mode 100644 anyplotlib/_base_plot.py create mode 100644 anyplotlib/tests/test_interactive/test_callbacks_playwright.py create mode 100644 anyplotlib/tests/test_interactive/test_callbacks_unit.py diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py new file mode 100644 index 00000000..81137741 --- /dev/null +++ b/anyplotlib/_base_plot.py @@ -0,0 +1,182 @@ +""" +_base_plot.py +============= +Shared base classes and mixins for all plot panel types. +""" + +from __future__ import annotations + +from contextlib import contextmanager + +from anyplotlib.callbacks import _EventMixin + + +class _BasePlot(_EventMixin): + """Universal base for Plot1D, Plot2D, PlotBar, and Plot3D. + + Contains methods identical across all four panel types and helper + utilities used by view-setter and widget-adder methods. + + Subclasses must define: + _state : dict — the panel state dict + _push() -> None — serialize state and write to parent Figure + """ + + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + + _configure_pointer_settled = configure_pointer_settled + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + + @contextmanager + def _python_view_push(self): + """Context manager for view setters that must signal _view_from_python. + + Sets the flag on entry, yields for state mutations, then pushes + and clears the flag on exit. + """ + self._state["_view_from_python"] = True + try: + yield + finally: + self._push() + self._state["_view_from_python"] = False + + def _make_widget_push_fn(self, widget): + """Return a targeted-push closure for a widget. + + Replaces the repeated _tp / _targeted_push closures in every + add_*_widget method. + """ + plot_ref, wid_id = self, widget._id + def _push(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() + if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + return _push + + +class _PanelMixin: + """Mixin for panels that support interactive widgets and tick control. + + Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget + serialization), widget management, and tick visibility control. + + Subclasses must define: + _state : dict + _fig : object + _id : str + _widgets : dict[str, Widget] + """ + + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + + def get_widget(self, wid): + """Return the Widget object by ID string or Widget instance.""" + from anyplotlib.widgets import Widget + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + from anyplotlib.widgets import Widget + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + """Return a list of all active widget objects on this panel.""" + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + """Remove all interactive overlay widgets from this panel.""" + self._widgets.clear() + self._push() + + +class _MarkerMixin: + """Mixin for panels that support static marker collections. + + Shared by Plot1D and Plot2D. + + Subclasses must define: + _state : dict + markers : MarkerRegistry + _push() -> None + """ + + def _push_markers(self) -> None: + self._state["markers"] = self.markers.to_wire_list() + self._push() + + def _add_marker(self, mtype: str, name, **kwargs): + return self.markers.add(mtype, name, **kwargs) + + def remove_marker(self, marker_type: str, name: str) -> None: + """Remove a named marker collection by type and name. + + Parameters + ---------- + marker_type : str + Collection type, e.g. ``"points"``, ``"vlines"``. + name : str + The name used when the collection was created. + """ + self.markers.remove(marker_type, name) + + def clear_markers(self) -> None: + """Remove all marker collections from this panel.""" + self.markers.clear() + + def list_markers(self) -> list: + """Return a summary list of all marker collections on this panel. + + Returns + ------- + list of dict + Each dict has keys ``"type"``, ``"name"``, and ``"n"`` + (number of markers in the collection). + """ + out = [] + for mtype, td in self.markers._types.items(): + for name, g in td.items(): + out.append({"type": mtype, "name": name, "n": g._count()}) + return out diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 3d764a09..d7592e00 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -62,6 +62,14 @@ def _offsets_2d(offsets) -> list: _VALID_TRANSFORMS = frozenset({"data", "axes", "display"}) +def _apply_fill_color(wire: dict, d: dict) -> None: + """Apply facecolors/alpha fill fields to a wire dict if facecolors is set.""" + fc = d.get("facecolors") + if fc is not None: + wire["fill_color"] = fc + wire["fill_alpha"] = float(d.get("alpha", 0.3)) + + def _offsets_1d(offsets) -> list: """Accept (N,), (N,1) or (N,2) — return (N,1) or (N,2) list.""" arr = np.asarray(offsets, dtype=float) @@ -177,10 +185,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", "#ff0000"), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "arrows": offsets = _offsets_2d(d["offsets"]) @@ -210,10 +215,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "lines": segs = np.asarray(d["segments"], dtype=float) @@ -244,10 +246,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "squares": offsets = _offsets_2d(d["offsets"]) @@ -262,10 +261,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "polygons": vlist = [] @@ -282,10 +278,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "texts": offsets = _offsets_2d(d["offsets"]) @@ -313,10 +306,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "vlines": offsets = _offsets_1d(d["offsets"]) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 2ef35c99..43075fea 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -11,8 +11,9 @@ import numpy as np from typing import Callable +from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -147,7 +148,7 @@ def remove(self) -> None: # Plot1D # --------------------------------------------------------------------------- -class Plot1D(_EventMixin): +class Plot1D(_BasePlot, _PanelMixin, _MarkerMixin): """1-D line plot panel returned by :meth:`Axes.plot`. All display state is stored in a plain ``_state`` dict. Every mutation @@ -308,24 +309,6 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - self._state["markers"] = self.markers.to_wire_list() - self._push() - def to_state_dict(self) -> dict: d = dict(self._state) # Replace numpy arrays with b64-encoded strings for the wire format. @@ -615,12 +598,7 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: :meth:`on_changed` / :meth:`on_release`. """ widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -642,12 +620,7 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: :meth:`on_changed` / :meth:`on_release`. """ widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -685,12 +658,7 @@ def add_range_widget(self, x0: float, x1: float, """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() @@ -723,44 +691,12 @@ def add_point_widget(self, x: float, y: float, """ widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp_point(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp_point + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - """Return a list of all active widget objects on this panel.""" - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - """Remove all interactive overlay widgets from this panel.""" - self._widgets.clear() - self._push() - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ @@ -783,19 +719,15 @@ def set_view(self, x0: float | None = None, x1: float | None = None) -> None: span = xmax - xmin or 1.0 f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) - self._state["view_x0"] = f0 - self._state["view_x1"] = f1 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = f0 + self._state["view_x1"] = f1 def reset_view(self) -> None: """Reset the view to show the full x range of the primary line.""" - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 # ------------------------------------------------------------------ # Primary-line property setters @@ -882,10 +814,6 @@ def set_ylabel(self, label: str) -> None: self._state["y_units"] = str(label) self._push() - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() - def set_yscale(self, scale: str) -> None: """Set the y-axis scale: ``'linear'`` or ``'log'``.""" if scale not in ("linear", "log"): @@ -920,32 +848,9 @@ def get_xbound(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) return (float(xarr.min()), float(xarr.max())) - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, @@ -1392,37 +1297,6 @@ def add_texts(self, offsets, texts, name=None, *, labels=labels, label=label, transform=transform) - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - def __repr__(self) -> str: n = len(self._state.get("data", [])) color = self._state.get("line_color", "?") diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index f4f87832..921e8729 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -9,7 +9,8 @@ import numpy as np from typing import Callable -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib._base_plot import _BasePlot, _PanelMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -75,7 +76,7 @@ def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): return dmin, dmax -class PlotBar(_EventMixin): +class PlotBar(_BasePlot, _PanelMixin): """Bar-chart plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -138,9 +139,6 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, ) n = values_2d.shape[0] - if orient not in ("v", "h"): - raise ValueError("orient must be 'v' or 'h'") - # ── x (positions or labels) ──────────────────────────────────── _x_labels: list = [] _x_centers: np.ndarray | None = None @@ -218,21 +216,6 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - - # ------------------------------------------------------------------ - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - def to_state_dict(self) -> dict: d = dict(self._state) d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] @@ -326,11 +309,6 @@ def set_log_scale(self, log_scale: bool) -> None: # ------------------------------------------------------------------ # Display control # ------------------------------------------------------------------ - def set_title(self, label: str) -> None: - """Set the panel title.""" - self._state["title"] = str(label) - self._push() - def set_xlabel(self, label: str) -> None: """Set the x-axis label.""" self._state["x_label"] = str(label) @@ -365,29 +343,6 @@ def set_group_labels(self, labels) -> None: self._state["group_labels"] = list(labels) self._push() - def set_axis_off(self) -> None: - """Hide axes, ticks, and labels.""" - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - """Show axes, ticks, and labels.""" - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - """Show or hide x/y tick marks independently.""" - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # View (xlim / ylim) # ------------------------------------------------------------------ @@ -397,11 +352,9 @@ def set_xlim(self, xmin: float, xmax: float) -> None: span = x_axis[1] - x_axis[0] if span == 0: return - self._state["view_x0"] = (xmin - x_axis[0]) / span - self._state["view_x1"] = (xmax - x_axis[0]) / span - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = (xmin - x_axis[0]) / span + self._state["view_x1"] = (xmax - x_axis[0]) / span def set_ylim(self, y_min: float, y_max: float) -> None: """Fix the value-axis range to [y_min, y_max].""" @@ -425,12 +378,10 @@ def get_xlim(self) -> tuple: def reset_view(self) -> None: """Reset pan/zoom to show all bars.""" - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 - self._state["y_range"] = None - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._state["y_range"] = None # ------------------------------------------------------------------ # Overlay Widgets @@ -438,12 +389,7 @@ def reset_view(self) -> None: def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: """Add a draggable vertical line at data position *x*.""" widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -451,12 +397,7 @@ def _tp(): def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: """Add a draggable horizontal line at value-axis position *y*.""" widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -469,12 +410,7 @@ def add_range_widget(self, x0: float, x1: float, """Add a draggable range overlay. See :meth:`Plot1D.add_range_widget` for full docs.""" widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() @@ -487,42 +423,12 @@ def add_point_widget(self, x: float, y: float, """Add a freely-draggable control point to this panel.""" widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - self._widgets.clear() - self._push() - def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 28e6030a..3be47e6a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -9,8 +9,9 @@ import numpy as np from typing import Callable +from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -19,7 +20,7 @@ from anyplotlib._utils import _normalize_image, _build_colormap_lut -class Plot2D(_EventMixin): +class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): """2-D image plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -143,31 +144,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 return base64.b64encode(arr.tobytes()).decode("ascii") - def _push(self) -> None: - """Serialise _state + markers and write to Figure trait.""" - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - """Called by MarkerRegistry whenever markers change.""" - self._state["markers"] = self.markers.to_wire_list() - self._push() - def to_state_dict(self) -> dict: """Return a JSON-serialisable copy of the current state.""" d = dict(self._state) @@ -326,10 +307,6 @@ def set_ylabel(self, label: str) -> None: self._state["y_label"] = str(label) self._push() - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() - def set_xlim(self, xmin: float, xmax: float) -> None: self.set_view(x0=xmin, x1=xmax) @@ -375,107 +352,113 @@ def set_aspect(self, ratio) -> None: self._state["aspect"] = float(ratio) if ratio is not None else None self._push() - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: - kind = kind.lower() - valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") - if kind not in valid: - raise ValueError(f"kind must be one of {valid}") + """Add an overlay widget by kind name. + + Dispatches to the dedicated ``add__widget`` method. + Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``, + ``"polygon"``, ``"crosshair"``, ``"label"``. + """ + dispatch = { + "circle": self.add_circle_widget, + "rectangle": self.add_rectangle_widget, + "annular": self.add_annular_widget, + "polygon": self.add_polygon_widget, + "crosshair": self.add_crosshair_widget, + "label": self.add_label_widget, + } + key = kind.lower() + if key not in dispatch: + raise ValueError(f"kind must be one of {tuple(dispatch)}") + return dispatch[key](color=color, **kwargs) + + def add_circle_widget(self, cx: float | None = None, cy: float | None = None, + r: float | None = None, color: str = "#00e5ff") -> CircleWidget: + """Add a draggable circle overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] + widget = CircleWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + r=float(r) if r is not None else iw * 0.1, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget - def _f(k, default): return float(kwargs.get(k, default)) - def _i(k, default): return int(kwargs.get(k, default)) - - if kind == "circle": - widget = CircleWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r=_f("r", iw * 0.1), color=color) - elif kind == "rectangle": - widget = RectangleWidget(lambda: None, - x=_f("x", iw * 0.25), y=_f("y", ih * 0.25), - w=_f("w", iw * 0.5), h=_f("h", ih * 0.5), - color=color) - elif kind == "annular": - r_outer = _f("r_outer", iw * 0.2) - r_inner = _f("r_inner", iw * 0.1) - widget = AnnularWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r_outer=r_outer, r_inner=r_inner, color=color) - elif kind == "polygon": - raw = kwargs.get("vertices", [[iw * .25, ih * .25], [iw * .75, ih * .25], - [iw * .75, ih * .75], [iw * .25, ih * .75]]) - widget = PolygonWidget(lambda: None, vertices=raw, color=color) - elif kind == "crosshair": - widget = CrosshairWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - color=color) - else: # label - widget = LabelWidget(lambda: None, - x=_f("x", iw * 0.1), y=_f("y", ih * 0.1), - text=str(kwargs.get("text", "Label")), - fontsize=_i("fontsize", 14), color=color) - - # Replace the temporary push_fn with a targeted one now that - # we have both the widget's _id and the plot's _id. - plot_ref = self - wid_id = widget._id - def _targeted_push(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() - if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _targeted_push + def add_rectangle_widget(self, x: float | None = None, y: float | None = None, + w: float | None = None, h: float | None = None, + color: str = "#00e5ff") -> RectangleWidget: + """Add a draggable rectangle overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = RectangleWidget(lambda: None, + x=float(x) if x is not None else iw * 0.25, + y=float(y) if y is not None else ih * 0.25, + w=float(w) if w is not None else iw * 0.5, + h=float(h) if h is not None else ih * 0.5, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + def add_annular_widget(self, cx: float | None = None, cy: float | None = None, + r_outer: float | None = None, r_inner: float | None = None, + color: str = "#00e5ff") -> AnnularWidget: + """Add a draggable annular (ring) overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = AnnularWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + r_outer=float(r_outer) if r_outer is not None else iw * 0.2, + r_inner=float(r_inner) if r_inner is not None else iw * 0.1, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget - self._push() # full panel push once so JS knows about the widget + self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] + def add_polygon_widget(self, vertices=None, color: str = "#00e5ff") -> PolygonWidget: + """Add a draggable polygon overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + if vertices is None: + vertices = [[iw * .25, ih * .25], [iw * .75, ih * .25], + [iw * .75, ih * .75], [iw * .25, ih * .75]] + widget = PolygonWidget(lambda: None, vertices=vertices, color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget self._push() + return widget - def list_widgets(self) -> list: - return list(self._widgets.values()) + def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None, + color: str = "#00e5ff") -> CrosshairWidget: + """Add a draggable crosshair overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = CrosshairWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget - def clear_widgets(self) -> None: - self._widgets.clear() + def add_label_widget(self, x: float | None = None, y: float | None = None, + text: str = "Label", fontsize: int = 14, + color: str = "#00e5ff") -> LabelWidget: + """Add a draggable text label overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = LabelWidget(lambda: None, + x=float(x) if x is not None else iw * 0.1, + y=float(y) if y is not None else ih * 0.1, + text=str(text), fontsize=int(fontsize), color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget self._push() + return widget # ------------------------------------------------------------------ # View control @@ -523,27 +506,20 @@ def set_view(self, self._state["center_y"] = (fy0 + fy1) / 2.0 zoom_candidates.append(1.0 / (fy1 - fy0)) - if zoom_candidates: - self._state["zoom"] = min(zoom_candidates) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + if zoom_candidates: + self._state["zoom"] = min(zoom_candidates) def reset_view(self) -> None: """Reset pan and zoom to show the full image.""" - self._state["zoom"] = 1.0 - self._state["center_x"] = 0.5 - self._state["center_y"] = 0.5 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["zoom"] = 1.0 + self._state["center_x"] = 0.5 + self._state["center_y"] = 0.5 # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, @@ -690,37 +666,6 @@ def add_texts(self, offsets, texts, name=None, *, labels=labels, label=label, transform=transform) - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - def __repr__(self) -> str: w = self._state.get("image_width", "?") h = self._state.get("image_height", "?") diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index dc9d4b53..54bba257 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -10,7 +10,8 @@ import numpy as np -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib._base_plot import _BasePlot +from anyplotlib.callbacks import CallbackRegistry from anyplotlib._utils import _arr_to_b64, _build_colormap_lut @@ -25,7 +26,7 @@ def _triangulate_grid(rows: int, cols: int) -> list: return faces -class Plot3D(_EventMixin): +class Plot3D(_BasePlot): """3-D plot panel. Supports three geometry types matching matplotlib's 3-D Axes API: @@ -136,14 +137,6 @@ def __init__(self, geom_type: str, } self.callbacks = CallbackRegistry() - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -165,38 +158,20 @@ def set_colormap(self, name: str) -> None: def set_view(self, azimuth: float | None = None, elevation: float | None = None) -> None: """Set the camera azimuth (°) and/or elevation (°).""" - if azimuth is not None: self._state["azimuth"] = float(azimuth) - if elevation is not None: self._state["elevation"] = float(elevation) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + if azimuth is not None: self._state["azimuth"] = float(azimuth) + if elevation is not None: self._state["elevation"] = float(elevation) def set_zoom(self, zoom: float) -> None: - self._state["zoom"] = float(zoom) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["zoom"] = float(zoom) def reset_view(self) -> None: """Restore the camera to the angles/zoom set at construction time.""" - self._state["azimuth"] = self._state["_default_azimuth"] - self._state["elevation"] = self._state["_default_elevation"] - self._state["zoom"] = self._state["_default_zoom"] - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False - - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() + with self._python_view_push(): + self._state["azimuth"] = self._state["_default_azimuth"] + self._state["elevation"] = self._state["_default_elevation"] + self._state["zoom"] = self._state["_default_zoom"] def set_xlabel(self, label: str) -> None: self._state["x_label"] = str(label) diff --git a/anyplotlib/tests/test_interactive/test_callbacks_playwright.py b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py new file mode 100644 index 00000000..6920f467 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py @@ -0,0 +1,491 @@ +""" +tests/test_interactive/test_callbacks_playwright.py +==================================================== + +Playwright integration tests for the callback system. + +Each test exercises the full JS → Python dispatch pipeline: + 1. ``interact_page(fig)`` opens the standalone HTML in headless Chromium. + 2. ``_collect_events(page)`` intercepts every ``event_json`` write on the + JS model shim so we can verify the browser emitted the right payload. + 3. ``page.mouse.*`` / ``page.keyboard.*`` fires real browser events. + 4. ``_sim(fig, plot, event_type, ...)`` replays the same payload through + ``fig._dispatch_event`` to verify the Python handler receives it. + +Because the standalone HTML has no live Python kernel, steps 3 and 4 are +independent but complementary: step 3 confirms JS sends the event; step 4 +confirms Python processes it. + +Coordinate system (mirrors figure_esm.js constants) +---------------------------------------------------- + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 + 400×300 figure → plot area page-coords: x≈66, y≈20, w≈330, h≈246 +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, + PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _sim(fig, plot, event_type: str, **fields) -> None: + """Drive the Python dispatch path directly (no browser needed).""" + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + payload.update(fields) + fig._dispatch_event(json.dumps(payload)) + + +def _make_1d(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.sin(np.linspace(0, 6.28, 128))) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _make_2d(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _center(): + return _plot_center_page(FIG_W, FIG_H) + + +def _plot_left_edge(): + """Page x-coordinate of the left edge of the plot area.""" + return GRID_PAD + PAD_L + 5 + + +def _plot_top_edge(): + """Page y-coordinate of the top edge of the plot area.""" + return GRID_PAD + PAD_T + 5 + + +def _outside_plot(): + """Page coords clearly outside the plot area (title bar region).""" + return GRID_PAD + 10, GRID_PAD + 5 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. Event types — JS emission verified with Playwright +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestEventTypesJsEmission: + """Verify each event type is emitted by the JS engine on real interactions.""" + + def test_pointer_down_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.wait_for_timeout(80) + page.mouse.up() + events = _get_events(page, "pointer_down") + assert len(events) >= 1, "pointer_down should be emitted on click" + + def test_pointer_up_emitted(self, interact_page): + # pointer_up fires on significant drag release (not a plain click). + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 50, cy, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "pointer_up should be emitted after a drag release" + + def test_pointer_move_emitted(self, interact_page): + # pointer_move fires on every mousemove over a 3D panel. + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + plot = ax.plot_surface(X, Y, X ** 2 + Y ** 2) + page = interact_page(fig) + _collect_events(page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 30, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + events = _get_events(page, "pointer_move") + assert len(events) > 0, "pointer_move events should fire during 3D drag" + + def test_double_click_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click should be emitted on dblclick" + + def test_wheel_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.wheel(0, -100) + page.wait_for_timeout(80) + events = _get_events(page, "wheel") + assert len(events) >= 1, "wheel event should be emitted on scroll" + + def test_key_down_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + page.keyboard.press("r") + page.wait_for_timeout(80) + events = _get_events(page, "key_down") + assert len(events) >= 1, "key_down should be emitted on key press" + + def test_pointer_enter_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + ox, oy = _outside_plot() + px = _plot_left_edge() + py = _plot_top_edge() + page.mouse.move(ox, oy) + page.wait_for_timeout(30) + page.mouse.move(px, py, steps=5) + page.wait_for_timeout(80) + events = _get_events(page, "pointer_enter") + assert len(events) >= 1, "pointer_enter should fire when mouse enters plot area" + + def test_pointer_leave_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.wait_for_timeout(30) + ox, oy = _outside_plot() + page.mouse.move(ox, oy, steps=5) + page.wait_for_timeout(80) + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "pointer_leave should fire when mouse exits plot area" + + def test_pointer_down_has_required_fields(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "pointer_down") + assert events, "No pointer_down events collected" + e = events[0] + for field in ("event_type", "x", "y", "button", "buttons", "modifiers"): + assert field in e, f"pointer_down missing field {field!r}" + + def test_pointer_down_has_xdata_ydata(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "pointer_down") + assert events + e = events[0] + assert "xdata" in e and "ydata" in e, "2D pointer_down should carry xdata/ydata" + + def test_wheel_has_dx_dy_fields(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.wheel(0, -120) + page.wait_for_timeout(80) + events = _get_events(page, "wheel") + assert events + e = events[0] + assert "dy" in e or "dx" in e, "wheel event should carry dx or dy" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. Python dispatch — via _sim + real Python handlers +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPythonDispatch: + """Verify Python callback machinery processes dispatched events correctly.""" + + def test_pointer_down_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.event_type), "pointer_down") + _sim(fig, plot, "pointer_down", x=200, y=150, xdata=16.0, ydata=16.0) + assert received == ["pointer_down"] + + def test_pointer_move_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.xdata), "pointer_move") + _sim(fig, plot, "pointer_move", x=200, y=150, xdata=8.0, ydata=8.0) + assert received == [8.0] + + def test_double_click_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(True), "double_click") + _sim(fig, plot, "double_click", x=200, y=150) + assert received == [True] + + def test_wheel_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.dy), "wheel") + _sim(fig, plot, "wheel", dx=0.0, dy=-1.0) + assert received == [-1.0] + + def test_key_down_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.key), "key_down") + _sim(fig, plot, "key_down", key="r") + assert received == ["r"] + + def test_wildcard_handler_receives_all_event_types(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.event_type), "*") + for etype in ("pointer_down", "pointer_up", "pointer_move", "wheel"): + _sim(fig, plot, etype, x=100, y=100) + assert received == ["pointer_down", "pointer_up", "pointer_move", "wheel"] + + def test_priority_order_respected(self, interact_page): + fig, plot, page = _make_2d(interact_page) + order = [] + plot.add_event_handler( + lambda e: order.append("low"), "pointer_down", order=1 + ) + plot.add_event_handler( + lambda e: order.append("high"), "pointer_down", order=0 + ) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert order == ["high", "low"] + + def test_stop_propagation_halts_chain(self, interact_page): + fig, plot, page = _make_2d(interact_page) + called = [] + + def first(e): + called.append("first") + e.stop_propagation = True + + plot.add_event_handler(first, "pointer_down", order=0) + plot.add_event_handler(lambda e: called.append("second"), "pointer_down", order=1) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert called == ["first"] + + def test_disconnect_stops_delivery(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + fn = lambda e: received.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert received == [] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. pause_events — JS emission + Python dispatch combined +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPauseEventsPlaywright: + """pause_events drops events in the Python callback layer.""" + + def test_pause_suppresses_pointer_move_handler(self, interact_page): + """JS fires pointer_move; Python handler does not receive it while paused.""" + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.move(cx + 20, cy, steps=5) + page.wait_for_timeout(50) + # JS events are sent to model; Python dispatch is paused + _sim(fig, plot, "pointer_move", x=200, y=150) + _sim(fig, plot, "pointer_move", x=210, y=150) + + assert received == [], ( + "pause_events should prevent handler from firing during the context" + ) + + def test_pause_allows_other_types_through(self, interact_page): + fig, plot, page = _make_2d(interact_page) + move_received = [] + down_received = [] + plot.add_event_handler(lambda e: move_received.append(1), "pointer_move") + plot.add_event_handler(lambda e: down_received.append(1), "pointer_down") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_down", x=100, y=100) + + assert move_received == [] + assert down_received == [1] + + def test_events_resume_after_pause_context(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + + _sim(fig, plot, "pointer_move", x=110, y=100) + assert received == [1], "Handler should fire after pause context exits" + + def test_js_still_emits_events_during_pause(self, interact_page): + """The browser still emits events during Python pause — only dispatch is suppressed. + + Uses a 3D panel because pointer_move fires on every mousemove there. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + plot = ax.plot_surface(X, Y, X ** 2 + Y ** 2) + page = interact_page(fig) + _collect_events(page) + + with plot.pause_events("pointer_move"): + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + + js_events = _get_events(page, "pointer_move") + assert len(js_events) > 0, ( + "JS should still emit pointer_move even while Python pause is active" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. hold_events — buffers and flushes on context exit +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestHoldEventsPlaywright: + """hold_events buffers Python callbacks and flushes them on context exit.""" + + def test_hold_buffers_during_context(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler( + lambda e: received.append(e.dwell_ms), + "pointer_settled", + ms=50, + delta=2, + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_settled", x=200, y=150, dwell_ms=100.0) + _sim(fig, plot, "pointer_settled", x=205, y=150, dwell_ms=110.0) + assert received == [], "Buffered events should not fire inside hold context" + + assert len(received) == 2, "Both buffered events should flush on exit" + + def test_hold_flush_preserves_order(self, interact_page): + fig, plot, page = _make_2d(interact_page) + order = [] + plot.add_event_handler( + lambda e: order.append(e.x), + "pointer_settled", + ms=50, + ) + + with plot.hold_events("pointer_settled"): + for x in (10, 20, 30, 40): + _sim(fig, plot, "pointer_settled", x=x, y=100, dwell_ms=60.0) + + assert order == [10, 20, 30, 40] + + def test_hold_non_held_type_fires_immediately(self, interact_page): + fig, plot, page = _make_2d(interact_page) + move_calls = [] + settled_calls = [] + plot.add_event_handler(lambda e: move_calls.append(1), "pointer_move") + plot.add_event_handler( + lambda e: settled_calls.append(1), "pointer_settled", ms=50 + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_settled", x=100, y=100, dwell_ms=60.0) + assert move_calls == [1], "pointer_move not held — should fire immediately" + assert settled_calls == [], "pointer_settled should still be buffered" + + assert settled_calls == [1] + + def test_pause_inside_hold_drops_not_buffers(self, interact_page): + """An event that matches both hold and pause: pause wins, event is dropped.""" + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.hold_events("pointer_move"): + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + + assert received == [], "pause inside hold should drop the event entirely" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5. pointer_settled — real dwell detection via Playwright +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerSettledPlaywright: + def test_pointer_settled_fires_after_dwell(self, interact_page): + """After the mouse stops moving, pointer_settled is emitted by JS. + + The handler must be registered BEFORE interact_page() so the settled + dwell config (ms/delta) is baked into the serialised state that the + standalone HTML page loads. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=100, delta=2) + page = interact_page(fig) + _collect_events(page) + + cx, cy = _center() + page.mouse.move(cx, cy) + page.wait_for_timeout(400) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled should fire after dwell timeout" + + def test_pointer_settled_not_fired_on_rapid_movement(self, interact_page): + """Continuous rapid movement suppresses pointer_settled.""" + fig, plot, page = _make_2d(interact_page) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=2) + + cx, cy = _center() + for _ in range(8): + page.mouse.move(cx, cy) + page.mouse.move(cx + 60, cy, steps=4) + page.mouse.move(cx, cy, steps=4) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled should not fire during continuous rapid movement" + ) diff --git a/anyplotlib/tests/test_interactive/test_callbacks_unit.py b/anyplotlib/tests/test_interactive/test_callbacks_unit.py new file mode 100644 index 00000000..9d53fdf9 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_callbacks_unit.py @@ -0,0 +1,528 @@ +""" +tests/test_interactive/test_callbacks_unit.py +============================================== + +Pure-Python unit tests for the callback system. No browser required. + +These tests cover: + - ``Event`` dataclass fields and defaults + - ``CallbackRegistry`` connect / disconnect / fire / priority / wildcards + - ``pause_events`` / ``hold_events`` context-manager semantics + - ``_EventMixin`` registration, decoration, and removal API + - Regression: old callback API is gone from all plot types + - ``fig.close()`` fires the ``close`` event on every panel +""" +from __future__ import annotations + +import time + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin + + +# ── Event dataclass ─────────────────────────────────────────────────────────── + +class TestEvent: + def test_required_fields(self): + e = Event(event_type="pointer_down", source=None) + assert e.event_type == "pointer_down" + assert e.source is None + + def test_time_stamp_auto_set(self): + before = time.perf_counter() + e = Event(event_type="pointer_down") + after = time.perf_counter() + assert before <= e.time_stamp <= after + + def test_modifiers_default_empty_list(self): + e = Event(event_type="pointer_move") + assert e.modifiers == [] + assert isinstance(e.modifiers, list) + + def test_pointer_fields_default_none(self): + e = Event(event_type="pointer_move") + assert e.x is None + assert e.y is None + assert e.button is None + assert e.buttons == 0 + assert e.xdata is None + assert e.ydata is None + assert e.ray is None + assert e.line_id is None + assert e.dwell_ms is None + + def test_wheel_fields_default_none(self): + e = Event(event_type="wheel") + assert e.dx is None + assert e.dy is None + + def test_key_field_default_none(self): + e = Event(event_type="key_down") + assert e.key is None + + def test_bar_fields_default_none(self): + e = Event(event_type="pointer_down") + assert e.bar_index is None + assert e.value is None + assert e.x_label is None + assert e.group_index is None + + def test_stop_propagation_default_false(self): + e = Event(event_type="pointer_down") + assert e.stop_propagation is False + + def test_all_fields_settable(self): + e = Event( + event_type="pointer_down", + source="plot", + modifiers=["ctrl", "shift"], + x=100, y=200, + button=0, buttons=1, + xdata=3.14, ydata=2.71, + line_id="abc12345", + bar_index=2, value=99.5, x_label="Jan", group_index=1, + dx=10.0, dy=-5.0, + key="q", + ) + assert e.modifiers == ["ctrl", "shift"] + assert e.x == 100 + assert e.xdata == 3.14 + assert e.line_id == "abc12345" + assert e.bar_index == 2 + assert e.key == "q" + assert e.dx == 10.0 + assert e.dy == -5.0 + + def test_no_data_dict_attribute(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") + + def test_repr_includes_event_type(self): + e = Event(event_type="pointer_down", x=10, y=20) + assert "pointer_down" in repr(e) + + def test_stop_propagation_not_in_repr(self): + e = Event(event_type="pointer_down", stop_propagation=True) + assert "stop_propagation" not in repr(e) + + +# ── CallbackRegistry ────────────────────────────────────────────────────────── + +class TestCallbackRegistry: + def test_connect_returns_int_cid(self): + reg = CallbackRegistry() + cid = reg.connect("pointer_down", lambda e: None) + assert isinstance(cid, int) + + def test_fire_calls_handler(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_fire_only_matching_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("pointer_up", lambda e: calls.append("up")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_disconnect_by_cid(self): + reg = CallbackRegistry() + calls = [] + cid = reg.connect("pointer_down", lambda e: calls.append(1)) + reg.disconnect(cid) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_silent_if_not_found(self): + reg = CallbackRegistry() + reg.disconnect(999) # should not raise + + def test_wildcard_receives_all_types(self): + reg = CallbackRegistry() + calls = [] + reg.connect("*", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + reg.fire(Event("wheel")) + assert calls == ["pointer_down", "key_down", "wheel"] + + def test_priority_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("second"), order=1) + reg.connect("pointer_down", lambda e: order.append("first"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["first", "second"] + + def test_same_priority_fires_in_registration_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("a"), order=0) + reg.connect("pointer_down", lambda e: order.append("b"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["a", "b"] + + def test_stop_propagation(self): + reg = CallbackRegistry() + calls = [] + def handler_a(e): + calls.append("a") + e.stop_propagation = True + reg.connect("pointer_down", handler_a, order=0) + reg.connect("pointer_down", lambda e: calls.append("b"), order=1) + reg.fire(Event("pointer_down")) + assert calls == ["a"] + + def test_disconnect_fn_by_reference(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(1) + reg.connect("pointer_down", fn) + reg.disconnect_fn(fn) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_fn_specific_type(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.disconnect_fn(fn, "pointer_down") + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_bool_true_when_handlers_present(self): + reg = CallbackRegistry() + assert not bool(reg) + reg.connect("pointer_down", lambda e: None) + assert bool(reg) + + def test_invalid_event_type_raises(self): + reg = CallbackRegistry() + with pytest.raises(ValueError, match="Invalid event_type"): + reg.connect("on_click", lambda e: None) + + def test_connect_same_fn_multiple_types(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + +# ── pause_events / hold_events ──────────────────────────────────────────────── + +class TestPauseHold: + def test_pause_drops_events(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + def test_pause_handlers_intact_after_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) + assert calls == [1] + + def test_pause_all_types_when_no_args(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("key_down", lambda e: calls.append("key")) + with reg.pause_events(): + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + assert calls == [] + + def test_pause_only_specified_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append("move")) + reg.connect("pointer_down", lambda e: calls.append("down")) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_pause_nested_same_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) # still paused + reg.fire(Event("pointer_move")) # now fires + assert calls == [1] + + def test_hold_buffers_and_flushes_on_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_settled")) + reg.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1, 1] + + def test_hold_fires_non_held_types_immediately(self): + reg = CallbackRegistry() + move_calls = [] + settled_calls = [] + reg.connect("pointer_move", lambda e: move_calls.append(1)) + reg.connect("pointer_settled", lambda e: settled_calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_settled")) + assert move_calls == [1] + assert settled_calls == [1] + + def test_hold_events_in_order(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(e.x)) + with reg.hold_events(): + reg.fire(Event("pointer_settled", x=1)) + reg.fire(Event("pointer_settled", x=2)) + reg.fire(Event("pointer_settled", x=3)) + assert calls == [1, 2, 3] + + def test_pause_wins_over_hold(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.hold_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + +# ── _EventMixin ─────────────────────────────────────────────────────────────── + +class _FakePlot(_EventMixin): + def __init__(self): + self.callbacks = CallbackRegistry() + self._settled_config = (0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._settled_config = (ms, delta) + + +class TestEventMixin: + def test_functional_form_single_type(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(e.event_type), "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_functional_form_multi_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + def test_decorator_form_single_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_move") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_move")) + assert calls == ["pointer_move"] + + def test_decorator_form_multi_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_down", "key_down") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("key_down")) + assert calls == ["pointer_down", "key_down"] + + def test_wildcard_decorator(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("*") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("wheel")) + assert calls == ["pointer_down", "wheel"] + + def test_remove_handler_by_fn(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_remove_handler_by_fn_specific_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.remove_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_remove_handler_by_cid(self): + plot = _FakePlot() + calls = [] + cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) + plot.remove_handler(cid) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_pointer_settled_configures_on_connect(self): + plot = _FakePlot() + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._settled_config == (400, 5) + + def test_pointer_settled_clears_on_last_disconnect(self): + plot = _FakePlot() + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._settled_config == (0, 0) + + def test_ms_delta_without_settled_raises(self): + plot = _FakePlot() + with pytest.raises(ValueError, match="ms/delta"): + plot.add_event_handler(lambda e: None, "pointer_down", ms=400) + + def test_pause_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_move") + with plot.pause_events("pointer_move"): + plot.callbacks.fire(Event("pointer_move")) + assert calls == [] + + def test_hold_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") + with plot.hold_events("pointer_settled"): + plot.callbacks.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1] + + +# ── Regression: old API is gone ────────────────────────────────────────────── + +class TestRegressionOldAPIGone: + def test_plot1d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_click") + + def test_plot1d_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_changed") + + def test_plot1d_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_release") + + def test_plot2d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert not hasattr(plot, "on_click") + + def test_widget_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_changed") + + def test_event_no_phys_x(self): + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_plot3d_no_on_click(self): + x = np.linspace(-2, 2, 10) + XX, YY = np.meshgrid(x, x) + fig, ax = apl.subplots(1, 1) + plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) + assert not hasattr(plot, "on_click") + + def test_plotbar_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.bar(["A", "B"], [1.0, 2.0]) + assert not hasattr(plot, "on_click") + + def test_line1d_no_on_hover(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_hover") + + +# ── fig.close() ────────────────────────────────────────────────────────────── + +class TestFigureClose: + def test_close_in_valid_event_types(self): + assert "close" in VALID_EVENT_TYPES + + def test_figure_close_sets_closed_flag(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + assert not getattr(fig, "_closed", False) + fig.close() + assert fig._closed is True + + def test_figure_close_fires_event_on_plot(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e.event_type)) + fig.close() + assert received == ["close"] + + def test_figure_close_fires_on_all_panels(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + p1 = ax1.plot(np.zeros(10)) + p2 = ax2.imshow(np.zeros((8, 8))) + counts = [0, 0] + p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) + p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) + fig.close() + assert counts == [1, 1] + + def test_figure_close_is_idempotent(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e)) + fig.close() + fig.close() + assert len(received) == 1 From 2e547c67898f6da3ccc6b1d1e55108f4deec642e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 10:32:30 -0500 Subject: [PATCH 5/7] Refactor: Introduce shared constants and helper functions to streamline view state preservation and event scheduling --- anyplotlib/figure_esm.js | 376 +++++++++++++++------------------------ 1 file changed, 141 insertions(+), 235 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 8c3c1ec2..65307b28 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -115,6 +115,73 @@ function render({ model, el }) { return new Int32Array(buf.buffer); } + // ── shared constants ────────────────────────────────────────────────────── + const STATS_DIV_CSS = + 'position:absolute;top:4px;left:4px;padding:4px 7px;' + + 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + + 'font-family:monospace;border-radius:4px;pointer-events:none;' + + 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + + // ── shared helpers ──────────────────────────────────────────────────────── + + // Preserve JS-side view state when Python pushes data without requesting a + // view change (_view_from_python === false). + function _preserveView(p2, newState) { + if (!p2.state) return; + if (p2.kind === '2d' && !newState._view_from_python) { + newState.zoom = p2.state.zoom; + newState.center_x = p2.state.center_x; + newState.center_y = p2.state.center_y; + } else if ((p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; + } else if (p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; + } + } + + // Factory: returns a debounced commit function. + // onCommit is called once per animation frame after the last request. + function _makeCommitter(onCommit) { + let pending = false; + return function() { + if (pending) return; pending = true; + requestAnimationFrame(() => { pending = false; onCommit(); }); + }; + } + + // Factory: returns { clear(), arm(mx, my, e, extraFields?) } for the + // pointer_settled dwell-timer pattern. extraFields is an optional + // zero-arg callback that returns extra fields to merge into the event. + function _makeSettledScheduler(p) { + let timer = null, startX = 0, startY = 0, startTs = 0; + return { + clear() { clearTimeout(timer); timer = null; }, + arm(mx, my, e, extraFields) { + const ms = p.state?.pointer_settled_ms ?? 0; + if (ms <= 0) return; + const delta = p.state?.pointer_settled_delta ?? 4; + clearTimeout(timer); + startX = mx; startY = my; startTs = performance.now(); + const mods = _modifiers(e); + timer = setTimeout(() => { + if (Math.hypot(p.mouseX - startX, p.mouseY - startY) <= delta) { + const _now = performance.now(); + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, modifiers: mods, + button: null, buttons: 0, + x: Math.round(p.mouseX), y: Math.round(p.mouseY), + dwell_ms: _now - startTs, + ...(extraFields ? extraFields() : {}), + }); + } + }, ms); + } + }; + } + // ── per-panel frame timing ──────────────────────────────────────────────── // Called at the entry of every draw function (draw2d / draw1d / draw3d / // drawBar). Records a high-resolution timestamp in a 60-entry rolling @@ -517,11 +584,7 @@ function render({ model, el }) { const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; const statsDiv = document.createElement('div'); - statsDiv.style.cssText = - 'position:absolute;top:4px;left:4px;padding:4px 7px;' + - 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + - 'font-family:monospace;border-radius:4px;pointer-events:none;' + - 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + statsDiv.style.cssText = STATS_DIV_CSS; if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); const p = { @@ -563,21 +626,7 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python data pushes don't - // reset it — but only when Python has NOT explicitly requested a view - // change (set_view / reset_view set _view_from_python: true). - if (p2.state && p2.kind === '2d' && !newState._view_from_python) { - newState.zoom = p2.state.zoom; - newState.center_x = p2.state.center_x; - newState.center_y = p2.state.center_y; - } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { - newState.view_x0 = p2.state.view_x0; - newState.view_x1 = p2.state.view_x1; - } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { - newState.azimuth = p2.state.azimuth; - newState.elevation = p2.state.elevation; - newState.zoom = p2.state.zoom; - } + _preserveView(p2, newState); p2.state = newState; } catch(_) { return; } @@ -641,11 +690,7 @@ function render({ model, el }) { const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; const statsDiv = document.createElement('div'); - statsDiv.style.cssText = - 'position:absolute;top:4px;left:4px;padding:4px 7px;' + - 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + - 'font-family:monospace;border-radius:4px;pointer-events:none;' + - 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + statsDiv.style.cssText = STATS_DIV_CSS; if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); const p = { @@ -692,21 +737,7 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python data pushes don't - // reset it — but only when Python has NOT explicitly requested a view - // change (set_view / reset_view set _view_from_python: true). - if (p2.state && p2.kind === '2d' && !newState._view_from_python) { - newState.zoom = p2.state.zoom; - newState.center_x = p2.state.center_x; - newState.center_y = p2.state.center_y; - } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { - newState.view_x0 = p2.state.view_x0; - newState.view_x1 = p2.state.view_x1; - } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { - newState.azimuth = p2.state.azimuth; - newState.elevation = p2.state.elevation; - newState.zoom = p2.state.zoom; - } + _preserveView(p2, newState); p2.state = newState; } catch(_) { return; } @@ -1822,16 +1853,8 @@ function render({ model, el }) { function _attachEvents3d(p) { const { overlayCanvas } = p; let dragStart = null; - let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit() { - if (commitPending) return; commitPending = true; - requestAnimationFrame(() => { - commitPending = false; - model.save_changes(); - }); - } + const _scheduleCommit = _makeCommitter(() => model.save_changes()); + const settled = _makeSettledScheduler(p); overlayCanvas.addEventListener('mousedown', (e) => { @@ -1857,7 +1880,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1889,31 +1912,7 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); // Keyboard shortcuts @@ -1943,7 +1942,7 @@ function render({ model, el }) { _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('mouseleave', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('keyup', (e) => { @@ -1964,20 +1963,8 @@ function render({ model, el }) { function _plotRect1d(pw,ph){return{x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-PAD_R),h:Math.max(1,ph-PAD_T-PAD_B)};} - function _xToFrac1d(xArr,val){ - if(xArr.length<2) return 0; - const n=xArr.length, asc=xArr[n-1]>=xArr[0]; - if(asc?val<=xArr[0]:val>=xArr[0]) return 0; - if(asc?val>=xArr[n-1]:val<=xArr[n-1]) return 1; - let lo=0,hi=n-2; - while(lo>1;const ok=asc?(xArr[mid]<=val&&val=val&&val>xArr[mid+1]);if(ok){lo=mid;break;}if(asc?xArr[mid+1]<=val:xArr[mid+1]>=val)lo=mid+1;else hi=mid;} - return(lo+(val-xArr[lo])/(xArr[lo+1]-xArr[lo]))/(n-1); - } - function _fracToX1d(xArr,frac){ - if(xArr.length<2) return xArr.length?xArr[0]:0; - const n=xArr.length,pos=Math.max(0,Math.min(1,frac))*(n-1),lo=Math.min(Math.floor(pos),n-2),t=pos-lo; - return xArr[lo]+t*(xArr[lo+1]-xArr[lo]); - } + // _xToFrac1d / _fracToX1d are identical to _axisValToFrac / _axisFracToVal + // (defined at the top of this file) — callers use those shared functions. function _fracToPx1d(frac,x0,x1,r){return r.x+((frac-x0)/((x1-x0)||1))*r.w;} function _valToPy1d(val,dMin,dMax,r){return r.y+r.h-((val-dMin)/((dMax-dMin)||1))*r.h;} @@ -2021,10 +2008,10 @@ function render({ model, el }) { // Grid ctx.strokeStyle=theme.gridStroke; ctx.lineWidth=1; if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xVMin=_axisFracToVal(xArr,x0), xVMax=_axisFracToVal(xArr,x1); const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,v),x0,x1,r); if(pxr.x+r.w) continue; ctx.beginPath();ctx.moveTo(px,r.y);ctx.lineTo(px,r.y+r.h);ctx.stroke(); } @@ -2050,8 +2037,8 @@ function render({ model, el }) { for(const sp of (st.spans||[])){ ctx.fillStyle=sp.color||(theme.dark?'rgba(255,255,100,0.15)':'rgba(200,160,0,0.15)'); if(sp.axis==='x'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,sp.v0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,sp.v1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,sp.v0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,sp.v1),x0,x1,r); ctx.fillRect(px0,r.y,px1b-px0,r.h); } else { const py0=_toPlotY(sp.v1), py1=_toPlotY(sp.v0); @@ -2226,11 +2213,11 @@ function render({ model, el }) { if(axisVis1d&&xTicksVis1d){ ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xVMin=_axisFracToVal(xArr,x0), xVMax=_axisFracToVal(xArr,x1); const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); ctx.textAlign='center'; ctx.textBaseline='top'; for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,v),x0,x1,r); if(pxr.x+r.w) continue; ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); @@ -2330,7 +2317,7 @@ function render({ model, el }) { const color=w.color||'#00e5ff'; ovCtx.save();ovCtx.strokeStyle=color;ovCtx.lineWidth=2; if(w.type==='vline'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(px,r.y);ovCtx.lineTo(px,r.y+r.h);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,px,r.y+7,color); } else if(w.type==='hline'){ @@ -2338,8 +2325,8 @@ function render({ model, el }) { ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,r.x+r.w-7,py,color); } else if(w.type==='range'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); if(w.style==='fwhm'){ // FWHM style: o-------o two handles joined by a dashed horizontal line const pyHalf=_valToPy1d(w.y||0,dMin,dMax,r); @@ -2359,7 +2346,7 @@ function render({ model, el }) { _ovHandle1d(ovCtx,px0,r.y+7,color);_ovHandle1d(ovCtx,px1b,r.y+7,color); } } else if(w.type==='point'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); const py=_valToPy1d(w.y,dMin,dMax,r); // Dashed crosshair guide lines (skipped when show_crosshair is false) if(w.show_crosshair!==false){ @@ -2402,7 +2389,7 @@ function render({ model, el }) { mkCtx.save();mkCtx.beginPath();mkCtx.rect(r.x,r.y,r.w,r.h);mkCtx.clip(); function _offToCanvas(off){ - const xFrac=xArr.length>=2?_xToFrac1d(xArr,off[0]):(off[0]/((xArr.length-1)||1)); + const xFrac=xArr.length>=2?_axisValToFrac(xArr,off[0]):(off[0]/((xArr.length-1)||1)); const px=_fracToPx1d(xFrac,x0,x1,r); let py; if(off.length>=2&&off[1]!=null){py=_valToPy1d(off[1],dMin,dMax,r);} @@ -2410,7 +2397,7 @@ function render({ model, el }) { else{py=_valToPy1d(0,dMin,dMax,r);} return[px,py]; } - function _xPx(v){return _fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,v):0,x0,x1,r);} + function _xPx(v){return _fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,v):0,x0,x1,r);} function _yPx(v){return _valToPy1d(v,dMin,dMax,r);} for(let si=0;si= 2 ? _fracToX1d(lineXArr, frac) : frac; + const physX = lineXArr.length >= 2 ? _axisFracToVal(lineXArr, frac) : frac; const physY = dMin + (r.y + r.h - by) / (r.h||1) * (dMax - dMin); return { lineId, canvasPx: bx, canvasPy: by, x: physX, y: physY }; } @@ -2667,7 +2654,7 @@ function render({ model, el }) { const perLabels=Array.isArray(ms.labels)?ms.labels:null; if(ms.type==='points'){ for(let i=0;i<(ms.offsets||[]).length;i++){ - const frac=xArr.length>=2?_xToFrac1d(xArr,ms.offsets[i][0]):0; + const frac=xArr.length>=2?_axisValToFrac(xArr,ms.offsets[i][0]):0; const px=_fracToPx1d(frac,x0,x1,r); const sz=Math.max(1,ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5); if(Math.sqrt((mx-px)**2+(my-r.y-r.h/2)**2)<=sz+MARKER_HIT) @@ -2675,7 +2662,7 @@ function render({ model, el }) { } } else if(ms.type==='vlines'){ for(let i=0;i<(ms.offsets||[]).length;i++){ - const px=_fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,ms.offsets[i][0]):0,x0,x1,r); + const px=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,ms.offsets[i][0]):0,x0,x1,r); if(Math.abs(mx-px)<=MARKER_HIT&&my>=r.y&&my<=r.y+r.h) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } @@ -2688,8 +2675,8 @@ function render({ model, el }) { } else if(ms.type==='lines'){ for(let i=0;i<(ms.segments||[]).length;i++){ const seg=ms.segments[i]; - const xf1=xArr.length>=2?_xToFrac1d(xArr,seg[0][0]):0; - const xf2=xArr.length>=2?_xToFrac1d(xArr,seg[1][0]):0; + const xf1=xArr.length>=2?_axisValToFrac(xArr,seg[0][0]):0; + const xf2=xArr.length>=2?_axisValToFrac(xArr,seg[1][0]):0; const x1c=_fracToPx1d(xf1,x0,x1,r),y1c=_valToPy1d(seg[0][1],dMin,dMax,r); const x2c=_fracToPx1d(xf2,x0,x1,r),y2c=_valToPy1d(seg[1][1],dMin,dMax,r); const dx=x2c-x1c,dy=y2c-y1c,len2=dx*dx+dy*dy; @@ -2728,13 +2715,11 @@ function render({ model, el }) { function _attachEvents2d(p) { const { overlayCanvas } = p; - let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit(){ - if(commitPending) return; commitPending=true; - requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); - } + let localOnly = false; + const _scheduleCommit = _makeCommitter(() => { + localOnly = true; model.save_changes(); setTimeout(() => { localOnly = false; }, 200); + }); + const settled = _makeSettledScheduler(p); // Wheel zoom — anchored on the image point under the cursor overlayCanvas.addEventListener('wheel',(e)=>{ @@ -2809,7 +2794,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2902,51 +2887,27 @@ function render({ model, el }) { p._hoverSi=newSi; p._hoverI=mhit?mhit.i:-1; drawMarkers2d(p, mhit?{si:newSi}:null); } - if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);clearTimeout(_settledTimer); _settledTimer = null;return;} + if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);settled.clear();return;} tooltip.style.display='none'; } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - const st2 = p.state; if (!st2) return; - const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); - const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); - const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); - const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; - const _siw = st2.image_width || 1, _sih = st2.image_height || 1; - const sPhysX = sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX; - const sPhysY = sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY; - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - img_x: sImgX, - img_y: sImgY, - xdata: sPhysX, - ydata: sPhysY, - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e, () => { + const st2 = p.state; if (!st2) return {}; + const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); + const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); + const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); + const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; + const _siw = st2.image_width || 1, _sih = st2.image_height || 1; + return { + img_x: sImgX, img_y: sImgY, + xdata: sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX, + ydata: sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY, + }; + }); }); overlayCanvas.addEventListener('mouseleave',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} @@ -3027,13 +2988,11 @@ function render({ model, el }) { function _attachEvents1d(p) { const { overlayCanvas } = p; - let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit(){ - if(commitPending) return; commitPending=true; - requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); - } + let localOnly = false; + const _scheduleCommit = _makeCommitter(() => { + localOnly = true; model.save_changes(); setTimeout(() => { localOnly = false; }, 200); + }); + const settled = _makeSettledScheduler(p); // Wheel zoom overlayCanvas.addEventListener('wheel',(e)=>{ @@ -3090,7 +3049,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -3128,7 +3087,7 @@ function render({ model, el }) { const r=_plotRect1d(p.pw,p.ph); const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); - const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; + const physX=xArr.length>=2?_axisFracToVal(xArr,frac):frac; _emitEvent(p.id,'key_down',null,{ time_stamp:performance.now()/1000, modifiers:_modifiers(e), @@ -3160,12 +3119,12 @@ function render({ model, el }) { if(mxr.x+r.w||myr.y+r.h){ p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); return; } const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); - const phys=xArr.length>=2?_fracToX1d(xArr,frac):frac; + const phys=xArr.length>=2?_axisFracToVal(xArr,frac):frac; p.statusBar.textContent=`x:${fmtVal(phys)}`;p.statusBar.style.display='block'; const mhit=_markerHitTest1d(mx,my,p); const newSi=mhit?mhit.si:-1; @@ -3193,34 +3152,10 @@ function render({ model, el }) { } if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); overlayCanvas.addEventListener('mouseleave',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} @@ -3234,7 +3169,7 @@ function render({ model, el }) { const r=_plotRect1d(p.pw,p.ph); const xArr=p._1dXArr||(st.x_axis_b64?_decodeF64(st.x_axis_b64):(st.x_axis||[])); const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); - xdata=xArr.length>=2?_fracToX1d(xArr,frac):frac; + xdata=xArr.length>=2?_axisFracToVal(xArr,frac):frac; } _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata}); }); @@ -3430,7 +3365,7 @@ function render({ model, el }) { const w=widgets[i]; if(w.visible===false) continue; if(w.type==='point'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.hypot(mx-px,my-py)<=HR+4) return{idx:i,mode:'move',wtype:'point',startMX:mx,startMY:my,snapW:{...w}}; @@ -3441,15 +3376,15 @@ function render({ model, el }) { const w=widgets[i]; if(w.visible===false) continue; if(w.type==='vline'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); if(Math.sqrt((mx-px)**2+(my-(r.y+7))**2)<=HR||Math.abs(mx-px)<=5) return{idx:i,mode:'move',wtype:'vline',startMX:mx,snapW:{...w}}; } else if(w.type==='hline'){ const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.abs(my-py)<=5) return{idx:i,mode:'move',wtype:'hline',startMY:my,snapW:{...w}}; } else if(w.type==='range'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); if(w.style==='fwhm'){ // FWHM style: hit-test the two circular handles const pyHalf=_valToPy1d(w.y||0,st.data_min,st.data_max,r); @@ -3474,7 +3409,7 @@ function render({ model, el }) { const {mx,my:py}=_clientPos(e,p.overlayCanvas,p.pw,p.ph); const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const x0=st.view_x0||0,x1=st.view_x1||1; - const xUnit=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); + const xUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); const widgets=st.overlay_widgets; const d=p.ovDrag, s=d.snapW, w=widgets[d.idx]; if(w.type==='vline'){w.x=xUnit;} @@ -3483,15 +3418,15 @@ function render({ model, el }) { if(d.mode==='edge0') w.x0=xUnit; else if(d.mode==='edge1') w.x1=xUnit; else { - const snapPx=_fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,s.x0):0,x0,x1,r); - const dxUnit=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); + const snapPx=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,s.x0):0,x0,x1,r); + const dxUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); w.x0=s.x0+dxUnit;w.x1=s.x1+dxUnit; } } else if(w.type==='point'){ // Clamp to plot rectangle const clampX=Math.max(r.x,Math.min(r.x+r.w,mx)); const clampY=Math.max(r.y,Math.min(r.y+r.h,py)); - w.x=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); + w.x=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); w.y=st.data_max-((clampY-r.y)/(r.h||1))*(st.data_max-st.data_min); } drawOverlay1d(p); @@ -4218,13 +4153,8 @@ function render({ model, el }) { } // Widget drag support - let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit() { - if (commitPending) return; commitPending = true; - requestAnimationFrame(() => { commitPending = false; model.save_changes(); }); - } + const _scheduleCommit = _makeCommitter(() => model.save_changes()); + const settled = _makeSettledScheduler(p); overlayCanvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; @@ -4246,7 +4176,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -4296,35 +4226,11 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); overlayCanvas.addEventListener('mouseleave', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; From bdf4d2ac4bffcf2f3cf80d71118bb966505439c1 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 19:15:51 -0500 Subject: [PATCH 6/7] Refactor: Add package-level pytest fixture for shared Chromium browser session --- anyplotlib/conftest.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 anyplotlib/conftest.py diff --git a/anyplotlib/conftest.py b/anyplotlib/conftest.py new file mode 100644 index 00000000..6b5ce3b1 --- /dev/null +++ b/anyplotlib/conftest.py @@ -0,0 +1,26 @@ +""" +anyplotlib/conftest.py +====================== + +Package-level pytest fixtures shared by ALL test subdirectories: + - anyplotlib/tests/ + - anyplotlib/sphinx_anywidget/tests/ + +Putting ``_pw_browser`` here (rather than in either subdirectory's conftest) +means both test trees share the same Chromium session — only one +``sync_playwright()`` context is ever active per pytest run. +""" +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session") +def _pw_browser(): + """Yield a headless Chromium browser for the whole test session.""" + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() From a7cc17d57a369216f372d8e17a3c03bd0d4e925d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 19:22:32 -0500 Subject: [PATCH 7/7] Fix: Switch wheel builder from pip to uv build for CI compatibility Co-Authored-By: Carter Francis --- anyplotlib/sphinx_anywidget/_wheel_builder.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py index c854383a..c275c6af 100644 --- a/anyplotlib/sphinx_anywidget/_wheel_builder.py +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -10,7 +10,6 @@ import re import subprocess -import sys from pathlib import Path @@ -53,9 +52,8 @@ def build_wheel( tmp_dir = Path(tmp_str) result = subprocess.run( [ - sys.executable, "-m", "pip", "wheel", - "--no-deps", "--quiet", - "--wheel-dir", str(tmp_dir), + "uv", "build", "--wheel", + "--out-dir", str(tmp_dir), str(project_root), ], capture_output=True,