From 09aca046344b47ab8673bd0d0a7657a1bdc0351c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 10:29:22 -0500 Subject: [PATCH 01/43] =?UTF-8?q?feat:=20add=20pointer=5Fsettled=20dwell?= =?UTF-8?q?=20timer=20to=20JS=20=E2=80=94=20zero=20cost=20when=20unused,?= =?UTF-8?q?=20per-panel=20ms/delta=20from=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- anyplotlib/figure_esm.js | 113 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0e77375d..a54441c6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1699,6 +1699,8 @@ function render({ model, el }) { 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(() => { @@ -1730,6 +1732,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', () => { + clearTimeout(_settledTimer); _settledTimer = null; if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1753,6 +1756,29 @@ 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); // Keyboard shortcuts @@ -2418,6 +2444,8 @@ 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);}); @@ -2493,6 +2521,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2589,8 +2618,33 @@ function render({ model, el }) { } 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + overlayCanvas.addEventListener('mouseleave',()=>{ + clearTimeout(_settledTimer); _settledTimer = null; + p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); @@ -2643,6 +2697,8 @@ 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);}); @@ -2702,6 +2758,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; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2795,8 +2852,33 @@ function render({ model, el }) { } if(lhit) _emitEvent(p.id,'on_line_hover',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); } + // 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + overlayCanvas.addEventListener('mouseleave',()=>{ + clearTimeout(_settledTimer); _settledTimer = null; + p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';draw1d(p);drawOverlay1d(p);overlayCanvas.style.cursor='crosshair';} }); @@ -3730,6 +3812,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(); }); @@ -3755,6 +3839,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3804,9 +3889,33 @@ 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave', () => { + clearTimeout(_settledTimer); _settledTimer = null; if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; }); From 2dc0724db3366c48a844eaf2315dd21602f14fac Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:12:05 -0500 Subject: [PATCH 02/43] docs: add event system redesign spec Audited the existing event system against pygfx/rendercanvas conventions, identified naming inconsistencies and gaps, and designed a complete replacement aligned with pygfx naming (pointer_down/up/move/settled, key_down/key_up, etc.) with anyplotlib-specific extensions (pointer_settled with ms/delta params, pause_events/hold_events context managers). --- .../specs/2026-05-14-event-system-design.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-event-system-design.md diff --git a/docs/superpowers/specs/2026-05-14-event-system-design.md b/docs/superpowers/specs/2026-05-14-event-system-design.md new file mode 100644 index 00000000..c4c45117 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-event-system-design.md @@ -0,0 +1,381 @@ +# Event System Redesign + +**Date:** 2026-05-14 +**Status:** Approved — ready for implementation planning + +## Motivation + +The existing event system has several inconsistencies identified during a pre-0.1.0 audit: + +- `on_click` fires on mouse press (not full click cycle) — misleading name +- `on_release` means "debounced/settled" not "mouse button released" — misleading name +- `on_changed` conflates viewport pan/zoom with widget drag frames +- `phys_x`/`phys_y` are non-standard field names; matplotlib users expect `xdata`/`ydata` +- Modifier keys (ctrl, shift, alt) are not exposed on any event +- No `pointer_up`, `pointer_enter`, `pointer_leave`, `double_click`, `wheel`, or `key_up` events +- `on_key` decorator has asymmetric optional-argument syntax inconsistent with all other decorators +- `on_click` payload differs completely across plot types (coords on Plot1D/2D, bar metadata on PlotBar, no data coords on Plot3D) +- No way to pause or buffer events during batch operations + +The redesign aligns with the [pygfx/rendercanvas event system](https://github.com/pygfx/rendercanvas) naming and adds anyplotlib-specific extensions (`pointer_settled`, pause/hold). + +--- + +## Section 1: Event Types + +### Pointer events (all plot types) + +| Event | Trigger | +|-------|---------| +| `pointer_down` | Mouse/touch pressed — replaces `on_click` | +| `pointer_up` | Mouse/touch physically released — new | +| `pointer_move` | Pointer moved (drag or hover) — replaces `on_changed` | +| `pointer_settled` | Pointer held still for ≥ N ms within ± delta px — replaces `on_release`, gains explicit params | +| `pointer_enter` | Cursor enters the panel — new | +| `pointer_leave` | Cursor leaves the panel — new | +| `double_click` | Double-click / long-tap — new | +| `wheel` | Scroll wheel or pinch — new | + +### Key events (all plot types) + +| Event | Trigger | +|-------|---------| +| `key_down` | Key pressed while panel focused — replaces `on_key` | +| `key_up` | Key released — new | + +### Plot-specific behaviour + +`pointer_move` and `pointer_down` on **Plot1D** carry a `line_id` field when the pointer is over a line (`None` otherwise). These are not separate event types — the same event carries extra data. Users check `if event.line_id` to distinguish. This replaces the separate `on_line_hover` and `on_line_click` event types. + +--- + +## Section 2: Event Object Fields + +The `Event` dataclass is flattened — all fields are top-level attributes with `None` as the default when a field does not apply. No more `data` dict with attribute proxy. + +### Universal fields (every event) + +| Field | Type | Description | +|-------|------|-------------| +| `event_type` | `str` | e.g. `"pointer_down"` | +| `source` | `object` | the plot or widget that fired it | +| `time_stamp` | `float` | `perf_counter()` at fire time | +| `modifiers` | `list[str]` | `["ctrl"]`, `["shift"]`, `["alt"]`, `["meta"]` — empty list if none | + +### Pointer fields (pointer_down, pointer_up, pointer_move, pointer_settled, pointer_enter, pointer_leave, double_click) + +| Field | Type | Present on | +|-------|------|-----------| +| `x` | `int` | all pointer events — pixel x within panel | +| `y` | `int` | all pointer events — pixel y within panel | +| `button` | `int \| None` | `pointer_down`, `pointer_up`, `double_click` only — 0=left, 1=middle, 2=right; `None` on enter/leave/move/settled | +| `buttons` | `int` | all pointer events — bitmask of currently held buttons (useful on `pointer_enter` to detect dragging into panel) | +| `xdata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space x coordinate | +| `ydata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space y coordinate | +| `ray` | `dict \| None` | Plot3D only — `{"origin": [x,y,z], "direction": [dx,dy,dz]}` | +| `line_id` | `str \| None` | Plot1D only — set when pointer is over a line, `None` otherwise | +| `dwell_ms` | `float \| None` | `pointer_settled` only — actual time the pointer held still | + +### PlotBar additional fields on `pointer_down` + +| Field | Type | Description | +|-------|------|-------------| +| `bar_index` | `int \| None` | which bar was clicked; `None` if click missed all bars | +| `value` | `float \| None` | bar value | +| `x_label` | `str \| None` | category label | +| `group_index` | `int \| None` | group index for grouped bars; `None` for ungrouped | + +PlotBar `pointer_down` also carries `x`, `y`, `xdata`, `ydata` like other plot types, so all fields are available. + +### Wheel fields + +| Field | Type | Description | +|-------|------|-------------| +| `x`, `y` | `int` | pointer position at time of scroll | +| `dx`, `dy` | `float` | scroll deltas; accumulated across merged frames (matching pygfx) | + +### Key fields (key_down, key_up) + +| Field | Type | Description | +|-------|------|-------------| +| `key` | `str` | key name e.g. `"q"`, `"Enter"`, `"ArrowLeft"` | +| `x`, `y` | `int` | pointer position at time of keypress | + +--- + +## Section 3: Connection API + +The user-facing API on every plot and widget becomes `add_event_handler` / `remove_handler`. The internal `CallbackRegistry` engine (`connect`/`disconnect`/`fire`) is unchanged. + +### Functional form + +```python +# Single type +cid = plot.add_event_handler(fn, "pointer_down") + +# Multiple types in one call +cid = plot.add_event_handler(fn, "pointer_down", "pointer_up") + +# Wildcard — receives every event type +cid = plot.add_event_handler(fn, "*") + +# pointer_settled with explicit thresholds (defaults: ms=300, delta=4) +# ms/delta are only valid when "pointer_settled" is in the types list — ValueError otherwise +cid = plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + +# Priority — lower order fires first, default 0 +cid = plot.add_event_handler(fn, "pointer_move", order=-1) +``` + +### Decorator form + +```python +@plot.add_event_handler("pointer_down") +def on_press(event): + print(event.xdata, event.ydata) + +@plot.add_event_handler("pointer_down", "pointer_up") +def on_press_release(event): + print(event.event_type, event.button) + +@plot.add_event_handler("pointer_settled", ms=400, delta=5) +def on_settled(event): + update_spectrum(event.xdata, event.ydata) +``` + +### Removal + +```python +# By CID (returned from add_event_handler) +plot.remove_handler(cid) + +# By callback reference + specific types +plot.remove_handler(fn, "pointer_down") + +# By callback reference alone — removes from all types it was registered under +plot.remove_handler(fn) +``` + +### Per-line filtering on Plot1D + +Line handles returned by `ax.plot()` and `line.add_line()` expose their own `add_event_handler`. Internally this connects to the plot's `pointer_move`/`pointer_down` and filters by `line_id` — no new mechanism required. + +```python +line = ax.plot(data) +overlay = line.add_line(data2) + +@line.add_event_handler("pointer_move") +def on_hover(event): + print(event.xdata, event.line_id) + +@overlay.add_event_handler("pointer_down") +def on_pick(event): + print("picked overlay line") +``` + +### What disappears + +| Old | New | +|-----|-----| +| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | +| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | +| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | +| `@plot.on_key` / `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` | +| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | +| `@line.on_click` | `@line.add_event_handler("pointer_down")` | +| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | +| `plot.callbacks.connect("on_click", fn)` | `plot.callbacks.connect("pointer_down", fn)` | + +--- + +## Section 4: Architecture & Data Flow + +### JS changes (`figure_esm.js`) + +**New events JS must emit:** + +| JS DOM event | anyplotlib event | Notes | +|-------------|-----------------|-------| +| `mouseenter` | `pointer_enter` | per panel canvas element | +| `mouseleave` | `pointer_leave` | per panel canvas element | +| `mouseup` | `pointer_up` | previously swallowed after debounce | +| `dblclick` | `double_click` | | +| `wheel` | `wheel` | `dx`/`dy` accumulated across merged frames | +| `keyup` | `key_up` | complement to existing keydown | + +**Fields added to all emitted events:** +- `modifiers`: extracted from `ctrlKey`, `shiftKey`, `altKey`, `metaKey` +- `buttons`: from `event.buttons` bitmask (available on all MouseEvents) +- `button`: from `event.button` on press/release events +- `time_stamp`: set in JS before sending + +**`pointer_settled` timer logic (per panel):** + +``` +On pointer_move: + if panel_state.pointer_settled_ms > 0: + clearTimeout(settled_timer) + record settle_start_pos = current_pos + settled_timer = setTimeout(() => { + if distance(current_pos, settle_start_pos) <= panel_state.pointer_settled_delta: + emit pointer_settled { ...pointer fields, dwell_ms: actual_elapsed } + }, panel_state.pointer_settled_ms) +``` + +Timer is never created when `pointer_settled_ms == 0`. Cost is zero when no handler is connected. + +**Key registration removed:** `registered_keys` state field is eliminated. `key_down`/`key_up` forward all key presses unconditionally (matching pygfx). Per-key filtering moves to Python-side handler wrappers if users want it. + +### Python changes + +**`_dispatch_event()` field mapping:** + +| Old field | New field | Change | +|-----------|-----------|--------| +| `phys_x` | `xdata` | rename | +| `phys_y` | `ydata` | rename | +| `mouse_x` | `x` | rename | +| `mouse_y` | `y` | rename | +| *(absent)* | `button` | new | +| *(absent)* | `buttons` | new | +| *(absent)* | `modifiers` | new | +| *(absent)* | `time_stamp` | new | +| *(absent)* | `ray` | new (Plot3D) | +| *(absent)* | `dx`, `dy` | new (wheel) | +| *(absent)* | `dwell_ms` | new (pointer_settled) | + +**`pointer_settled` configuration flow:** + +When the first `pointer_settled` handler connects: +```python +plot._state["pointer_settled_ms"] = ms # configured threshold +plot._state["pointer_settled_delta"] = delta # configured threshold +plot._push() # JS activates timer +``` +When the last `pointer_settled` handler disconnects: +```python +plot._state["pointer_settled_ms"] = 0 # JS deactivates timer +plot._push() +``` + +**`CallbackRegistry` additions:** +1. Multi-type registration: `add_event_handler(fn, "a", "b")` registers `fn` under both internally; `remove_handler(fn)` removes from all registered types +2. Order-based priority: handlers stored as `(order, fn)` tuples, sorted on insert +3. Wildcard `"*"`: fires for every event type dispatched +4. `stop_propagation`: existing — `event.stop_propagation = True` in a handler halts remaining handlers + +### Pause and Hold + +Both are context managers implemented on `CallbackRegistry` and exposed on every plot and widget. + +**Pause (suppress):** +```python +with plot.pause_events(): # suppress all types + update_all_panels() + +with plot.pause_events("pointer_move"): # suppress specific types + do_something() +``` + +**Hold (buffer + flush):** +```python +with plot.hold_events(): # buffer all types, flush on exit + do_something() + +with plot.hold_events("pointer_settled"): # buffer specific types only + do_something() +``` + +**Nesting:** both use a depth counter — pause/hold only fully lifts when the outermost context exits. + +**Precedence:** if both are active for the same event type, pause wins — events are dropped, not buffered. + +**`CallbackRegistry` internal state:** +- `_pause_types: set[str]` — event types currently suppressed +- `_pause_depth: int` — nesting depth counter +- `_hold_types: set[str]` — event types currently buffered +- `_hold_depth: int` — nesting depth counter +- `_held_events: deque[Event]` — ordered buffer of held events + +`fire()` checks pause first (drop), then hold (queue), then dispatch. + +--- + +## Section 5: Testing Plan + +### Tier 1 — Pure Python, no browser + +**`CallbackRegistry` unit tests:** +- Multi-type registration fires handler for both types +- Wildcard `"*"` receives every event type dispatched +- Lower `order` fires before higher; same order fires in registration order +- `remove_handler` by CID +- `remove_handler` by callback reference + types +- `remove_handler` by callback reference alone removes from all types +- `stop_propagation` halts dispatch mid-handler-list +- `pause_events()`: events dropped, handlers intact after context exit +- `hold_events()`: events queued, fire in order on exit +- Pause inside hold: paused types are dropped (not buffered) +- Nested hold: depth counter lifts only on outermost exit +- `pointer_settled` params set in panel state on first connect, cleared on last disconnect + +**`Event` dataclass tests:** +- Universal fields present on every event +- `modifiers` is always a `list`, never `None` +- `time_stamp` is always set +- Plot3D events carry `ray`, not `xdata`/`ydata` +- PlotBar `pointer_down` carries bar metadata and coordinates +- `pointer_settled` carries `dwell_ms ≥` configured threshold +- `pointer_enter`/`pointer_leave` carry `buttons` (bitmask) but `button` is `None` + +### Tier 2 — Playwright browser tests + +One matrix per plot type (Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar): + +| Test | Verified | +|------|---------| +| `pointer_down` | fires on mousedown; correct `x/y`, `button=0`, `buttons=1`, `xdata/ydata` | +| `pointer_up` | fires on mouseup; `button=0`, `buttons=0` | +| `pointer_move` | fires during drag; `xdata/ydata` update correctly | +| `pointer_enter/leave` | fire when mouse crosses panel boundary | +| `double_click` | fires on dblclick; same fields as `pointer_down` | +| `wheel` | fires on scroll; `dx/dy` non-zero | +| `key_down/key_up` | fire on keypress/release; `key` field correct | +| `modifiers` | ctrl+click produces `modifiers=["ctrl"]` | +| `pointer_settled` | fires after configured ms; does NOT fire if pointer moves beyond delta | + +**Plot1D-specific:** +- `pointer_move` over a line sets `line_id`; off a line sets `line_id=None` +- `pointer_down` on a line sets `line_id` +- Line handle's `add_event_handler` filters correctly — handler on `line2` does not fire when pointer is over `line1` + +**`pointer_settled`-specific:** +- Does not fire when no handler connected (JS timer flag absent from panel state) +- `dwell_ms` on the event is ≥ configured `ms` +- Fires again after pointer moves and re-settles (resets correctly) +- Two panels with different `ms`/`delta` thresholds behave independently + +**Pause/Hold integration:** +- `pause_events()` during drag: `pointer_move` does not reach handler +- `hold_events()` during drag: events fire in order on context exit +- Type-specific hold: `hold_events("pointer_settled")` buffers settled but fires `pointer_move` immediately + +### Tier 3 — Regression + +- `on_click`, `on_changed`, `on_release`, `on_key` raise `AttributeError` (old names removed) +- `event.phys_x`, `event.phys_y` raise `AttributeError` (renamed to `xdata`/`ydata`) +- All `Examples/` files run without error after event handler updates + +--- + +## Summary of Changes + +| Area | Change | +|------|--------| +| Event names | 5 renamed, 8 new added | +| Event fields | `phys_x/y` → `xdata/ydata`, `mouse_x/y` → `x/y`; add `modifiers`, `button`, `buttons`, `time_stamp`, `ray`, `dx/dy`, `dwell_ms` | +| Connection API | `add_event_handler` / `remove_handler`; multi-type, wildcard, priority | +| `pointer_settled` | Configurable `ms`/`delta` per panel; zero cost when unused | +| Pause/Hold | Context managers on every plot and widget | +| JS layer | 6 new event types forwarded; `registered_keys` removed; timer for `pointer_settled` | +| Removed | `on_click`, `on_changed`, `on_release`, `on_key`, `on_line_hover`, `on_line_click`, `disconnect()`, `registered_keys` | From 3938d95b98640ce6a70605655af8dbabddc1f3d3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:30:17 -0500 Subject: [PATCH 03/43] docs: add event system implementation plan --- .../plans/2026-05-14-event-system.md | 2352 +++++++++++++++++ 1 file changed, 2352 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-event-system.md diff --git a/docs/superpowers/plans/2026-05-14-event-system.md b/docs/superpowers/plans/2026-05-14-event-system.md new file mode 100644 index 00000000..d8b1774d --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-event-system.md @@ -0,0 +1,2352 @@ +# Event System Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the existing `on_click`/`on_changed`/`on_release`/`on_key` event system with pygfx-aligned `pointer_*`/`key_*` events, a flat `Event` dataclass, multi-type/wildcard/priority registration, `pause_events`/`hold_events` context managers, and `pointer_settled` with per-panel JS timer. + +**Architecture:** Python-first — rewrite `CallbackRegistry` and `Event` in `callbacks.py`, add `_EventMixin` for the user-facing API, then update all plot/widget classes to inherit it. JS changes forward new event types and add the `pointer_settled` dwell timer. All old decorator methods (`on_click`, `on_changed`, etc.) are removed. + +**Tech Stack:** Python 3.10+, dataclasses, contextlib, anywidget traitlets, Playwright for browser tests, pytest. + +**Spec:** `docs/superpowers/specs/2026-05-14-event-system-design.md` + +--- + +## File Map + +**Modified:** +- `anyplotlib/callbacks.py` — rewrite `Event`, `CallbackRegistry`; add `_EventMixin` +- `anyplotlib/figure/_figure.py` — update `_dispatch_event` field mapping; add `import time` +- `anyplotlib/plot1d/_plot1d.py` — inherit `_EventMixin`, remove old decorators, update `Line1D` +- `anyplotlib/plot2d/_plot2d.py` — same pattern +- `anyplotlib/plot2d/_plotmesh.py` — same pattern (inherits Plot2D, may need minimal changes) +- `anyplotlib/plot3d/_plot3d.py` — same pattern + `ray` field in state +- `anyplotlib/plot1d/_plotbar.py` — same pattern + updated pointer_down payload +- `anyplotlib/widgets/_base.py` — inherit `_EventMixin`, remove old decorators, update `_update_from_js` +- `anyplotlib/figure_esm.js` — forward new event types, add fields, pointer_settled timer, remove registered_keys + +**Replaced:** +- `anyplotlib/tests/test_interactive/test_callbacks.py` — full rewrite for new API + +**Created:** +- `anyplotlib/tests/test_interactive/test_event_plots.py` — Playwright per-plot-type matrix +- `anyplotlib/tests/test_interactive/test_event_settled.py` — pointer_settled Playwright tests +- `anyplotlib/tests/test_interactive/test_event_pause_hold.py` — pause/hold Playwright tests + +--- + +## Task 1: Rewrite `Event` dataclass + +Flatten `Event` — all payload fields become top-level typed attributes instead of a `data` dict with `__getattr__` proxy. + +**Files:** +- Modify: `anyplotlib/callbacks.py` +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write the failing tests** + +Replace the top of `anyplotlib/tests/test_interactive/test_callbacks.py` with: + +```python +"""Tests for the redesigned Event dataclass and CallbackRegistry.""" +from __future__ import annotations +import time +import pytest +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES + + +# ── 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" + + 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) +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v +``` +Expected: FAIL — `Event` still has `data` field, `time_stamp` not auto-set, etc. + +- [ ] **Step 3: Rewrite `Event` in `callbacks.py`** + +Replace the entire `callbacks.py` with: + +```python +""" +callbacks.py +============ + +Event system used by all plot objects and widgets. + +:class:`Event` + Flat dataclass carrying all event fields as typed top-level attributes. + +:class:`CallbackRegistry` + Per-object handler store with multi-type, wildcard, priority, pause, and hold support. + +:class:`_EventMixin` + Mixin added to every plot class and widget exposing ``add_event_handler`` / + ``remove_handler`` / ``pause_events`` / ``hold_events``. +""" +from __future__ import annotations + +import time +from collections import defaultdict, deque +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, Callable + +VALID_EVENT_TYPES = frozenset({ + "pointer_down", "pointer_up", "pointer_move", "pointer_settled", + "pointer_enter", "pointer_leave", "double_click", "wheel", + "key_down", "key_up", "*", +}) + + +@dataclass +class Event: + """A single interactive event with all payload fields as typed attributes. + + Universal fields (every event): + event_type, source, time_stamp, modifiers + + Pointer fields (pointer_* and double_click events): + x, y — pixel coordinates within the panel + button — 0=left 1=middle 2=right; None on move/enter/leave/settled + buttons — bitmask of currently held buttons + xdata, ydata — data-space coordinates (None for Plot3D) + ray — Plot3D only: {"origin": [...], "direction": [...]} + line_id — Plot1D only: set when pointer is over a line + dwell_ms — pointer_settled only: actual dwell time + + PlotBar extra fields (pointer_down only): + bar_index, value, x_label, group_index + + Wheel fields: + dx, dy — scroll deltas + + Key fields: + key — key name e.g. "q", "Enter", "ArrowLeft" + + Propagation: + stop_propagation — set True inside a handler to halt remaining handlers + """ + event_type: str + source: Any = None + time_stamp: float = field(default_factory=time.perf_counter) + modifiers: list[str] = field(default_factory=list) + # Pointer + x: int | None = None + y: int | None = None + button: int | None = None + buttons: int = 0 + xdata: float | None = None + ydata: float | None = None + ray: dict | None = None + line_id: str | None = None + dwell_ms: float | None = None + # PlotBar + bar_index: int | None = None + value: float | None = None + x_label: str | None = None + group_index: int | None = None + # Wheel + dx: float | None = None + dy: float | None = None + # Key + key: str | None = None + # Propagation (not repr'd) + stop_propagation: bool = field(default=False, repr=False) + + def __repr__(self) -> str: + src = type(self.source).__name__ if self.source is not None else "None" + parts = [f"event_type={self.event_type!r}", f"source={src}"] + for fname in ("x", "y", "xdata", "ydata", "button", "key", + "line_id", "bar_index", "dwell_ms"): + v = getattr(self, fname) + if v is not None: + parts.append(f"{fname}={v!r}") + if self.modifiers: + parts.append(f"modifiers={self.modifiers!r}") + return "Event(" + ", ".join(parts) + ")" +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v +``` +Expected: All 11 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: flatten Event dataclass — all payload fields are typed top-level attrs" +``` + +--- + +## Task 2: Rewrite `CallbackRegistry` + +Replace the simple `_entries` dict with a per-type handler list supporting priority ordering, wildcard `"*"`, multi-type registration, and `stop_propagation`. + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append to Task 1 file) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +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"] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v +``` +Expected: Most FAIL — old `CallbackRegistry` doesn't support priority, wildcard, `disconnect_fn`, or new event type names. + +- [ ] **Step 3: Append new `CallbackRegistry` to `callbacks.py`** + +Remove the old `CallbackRegistry` class and replace with: + +```python +class CallbackRegistry: + """Per-object handler store. + + Supports: + - Priority ordering (``order`` kwarg — lower fires first) + - Wildcard ``"*"`` type receives every dispatched event + - ``stop_propagation`` on the event halts remaining handlers + - ``disconnect_fn(fn, *types)`` removes by callback reference + - ``pause_events`` / ``hold_events`` context managers (added in Task 3) + """ + + def __init__(self) -> None: + # {event_type: [(order, cid, fn), ...]} — sorted by order + self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) + self._next_cid: int = 1 + # {cid: set[str]} — which types this cid is registered under + self._cid_map: dict[int, set[str]] = {} + # {id(fn): set[int]} — which cids this fn owns + self._fn_map: dict[int, set[int]] = defaultdict(set) + # pause/hold (populated in Task 3) + self._pause_counts: dict[str, int] = {} + self._hold_counts: dict[str, int] = {} + self._held: deque[Event] = deque() + + # ── registration ───────────────────────────────────────────────────── + + def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: + """Register fn for event_type. Returns integer CID.""" + if event_type not in VALID_EVENT_TYPES: + raise ValueError( + f"Invalid event_type {event_type!r}. " + f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" + ) + cid = self._next_cid + self._next_cid += 1 + self._handlers[event_type].append((order, cid, fn)) + self._handlers[event_type].sort(key=lambda t: t[0]) + self._cid_map.setdefault(cid, set()).add(event_type) + self._fn_map[id(fn)].add(cid) + return cid + + def disconnect(self, cid: int) -> None: + """Remove handler by CID. Silent if not found.""" + types = self._cid_map.pop(cid, set()) + for et in types: + self._handlers[et] = [ + (o, c, f) for o, c, f in self._handlers[et] if c != cid + ] + for fn_cids in self._fn_map.values(): + fn_cids.discard(cid) + + def disconnect_fn(self, fn: Callable, *types: str) -> None: + """Remove fn from the given types (all types if none given).""" + for cid in list(self._fn_map.get(id(fn), set())): + cid_types = self._cid_map.get(cid, set()) + if not types or cid_types & set(types): + self.disconnect(cid) + + # ── dispatch ───────────────────────────────────────────────────────── + + def fire(self, event: Event) -> None: + """Dispatch event to matching handlers (respects pause/hold).""" + et = event.event_type + if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: + return + if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: + self._held.append(event) + return + self._dispatch(event) + + def _dispatch(self, event: Event) -> None: + et = event.event_type + specific = list(self._handlers.get(et, [])) + wildcard = list(self._handlers.get("*", [])) + merged = sorted(specific + wildcard, key=lambda t: t[0]) + for _order, _cid, fn in merged: + if event.stop_propagation: + break + fn(event) + + def _flush(self) -> None: + while self._held: + self._dispatch(self._held.popleft()) + + def __bool__(self) -> bool: + return any(bool(v) for v in self._handlers.values()) +``` + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v +``` +Expected: All 14 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation" +``` + +--- + +## Task 3: Add `pause_events` / `hold_events` to `CallbackRegistry` + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append context managers) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +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 +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v +``` +Expected: FAIL — `pause_events`/`hold_events` not yet implemented. + +- [ ] **Step 3: Append context managers to `CallbackRegistry` in `callbacks.py`** + +Add these methods inside the `CallbackRegistry` class (after `_flush`): + +```python + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types while inside this context. + All types are paused when called with no arguments. + Pause wins over hold for the same type.""" + target = types if types else ("*",) + for t in target: + self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._pause_counts[t] -= 1 + if self._pause_counts[t] == 0: + del self._pause_counts[t] + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when the outermost hold exits. + All types are held when called with no arguments.""" + target = types if types else ("*",) + for t in target: + self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._hold_counts[t] -= 1 + if self._hold_counts[t] == 0: + del self._hold_counts[t] + if not self._hold_counts: + self._flush() +``` + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v +``` +Expected: All 9 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "feat: add pause_events and hold_events context managers to CallbackRegistry" +``` + +--- + +## Task 4: Add `_EventMixin` to `callbacks.py` + +The mixin provides `add_event_handler`, `remove_handler`, `pause_events`, `hold_events` for every plot and widget. + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append class) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +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] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEventMixin -v +``` +Expected: FAIL — `_EventMixin` not yet defined. + +- [ ] **Step 3: Append `_EventMixin` to `callbacks.py`** + +```python +class _EventMixin: + """Mixin for plot classes and widgets. + + Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / + ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` + in its ``__init__``. + """ + + callbacks: CallbackRegistry + + def add_event_handler( + self, + fn_or_type, + *args, + order: float = 0, + ms: int = 300, + delta: float = 4, + ): + """Register an event handler. Works as a direct call or decorator. + + Direct call:: + + plot.add_event_handler(fn, "pointer_down") + plot.add_event_handler(fn, "pointer_down", "pointer_up") + + Decorator:: + + @plot.add_event_handler("pointer_down") + def handler(event): ... + + @plot.add_event_handler("pointer_settled", ms=400, delta=5) + def on_settle(event): ... + + Parameters + ---------- + fn_or_type : callable or str + Handler function (direct call) or first event type string (decorator). + *args : str + Remaining event type strings. + order : float + Priority. Lower fires first. Default 0. + ms : int + ``pointer_settled`` dwell threshold in milliseconds. Default 300. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + delta : float + ``pointer_settled`` pixel radius. Default 4. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + """ + if callable(fn_or_type): + return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) + else: + all_types = (fn_or_type,) + args + def _decorator(fn: Callable) -> Callable: + self._register(fn, all_types, order=order, ms=ms, delta=delta) + return fn + return _decorator + + def _register( + self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float + ) -> Callable: + has_settled = "pointer_settled" in types + _ms_changed = ms != 300 + _delta_changed = delta != 4 + if (_ms_changed or _delta_changed) and not has_settled: + raise ValueError( + "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" + ) + for event_type in types: + self.callbacks.connect(event_type, fn, order=order) + if has_settled: + self._configure_pointer_settled(ms, delta) + fn._event_types = getattr(fn, "_event_types", set()) | set(types) + return fn + + def remove_handler(self, cid_or_fn, *types: str) -> None: + """Remove a registered handler. + + Parameters + ---------- + cid_or_fn : int or callable + CID returned by ``callbacks.connect()`` or the handler function. + *types : str + If given, only remove from these types. If omitted, remove from all. + """ + if isinstance(cid_or_fn, int): + self.callbacks.disconnect(cid_or_fn) + else: + self.callbacks.disconnect_fn(cid_or_fn, *types) + if not self.callbacks._handlers.get("pointer_settled"): + self._configure_pointer_settled(0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + """Override in plot subclasses to push thresholds to JS.""" + pass + + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types (all types if none given).""" + with self.callbacks.pause_events(*types): + yield + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when context exits.""" + with self.callbacks.hold_events(*types): + yield +``` + +Also add `_EventMixin` to the module's `__all__` export and update the top docstring. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py -v +``` +Expected: All tests in all three test classes PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events" +``` + +--- + +## Task 5: Update `_dispatch_event` in Figure and `Widget._update_from_js` + +Map renamed JS fields (`phys_x`→`xdata`, `mouse_x`→`x`) to the flat `Event` constructor. Update widget sync. + +**Files:** +- Modify: `anyplotlib/figure/_figure.py` +- Modify: `anyplotlib/widgets/_base.py` + +- [ ] **Step 1: Add `import time` to `figure/_figure.py`** + +Find the existing imports block (around line 1-10) and add: +```python +import time +``` + +- [ ] **Step 2: Replace `_dispatch_event` in `figure/_figure.py`** + +Find the `_dispatch_event` method (currently lines ~343-397) and replace the body entirely: + +```python +def _dispatch_event(self, raw: str) -> None: + if not raw or raw == "{}": + return + try: + msg = json.loads(raw) + except Exception: + return + if msg.get("source") == "python": + return + + panel_id = msg.get("panel_id", "") + event_type = msg.get("event_type", "pointer_move") + widget_id = msg.get("widget_id") + + # Inset state changes + if event_type == "inset_state_change": + inset_ax = self._insets_map.get(panel_id) + if inset_ax is not None: + new_state = msg.get("new_state", "normal") + if new_state in ("normal", "minimized", "maximized"): + inset_ax._inset_state = new_state + self._push_layout() + return + + plot = self._plots_map.get(panel_id) + if plot is None: + return + + source = None + if widget_id and hasattr(plot, "_widgets"): + widget = plot._widgets.get(widget_id) + if widget is not None: + widget._update_from_js(msg, event_type) + source = widget + + if hasattr(plot, "callbacks"): + event = Event( + event_type=event_type, + source=source, + time_stamp=msg.get("time_stamp", time.perf_counter()), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ray=msg.get("ray"), + line_id=msg.get("line_id"), + dwell_ms=msg.get("dwell_ms"), + bar_index=msg.get("bar_index"), + value=msg.get("value"), + x_label=msg.get("x_label"), + group_index=msg.get("group_index"), + dx=msg.get("dx"), + dy=msg.get("dy"), + key=msg.get("key"), + ) + plot.callbacks.fire(event) +``` + +Also update the import at the top of `_figure.py` — find the `from anyplotlib.callbacks import ...` line and make sure `Event` is imported: +```python +from anyplotlib.callbacks import CallbackRegistry, Event +``` + +- [ ] **Step 3: Update `Widget._update_from_js` in `widgets/_base.py`** + +Find `_update_from_js` (currently lines ~223-253) and replace: + +```python +def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: + """Apply incoming JS state without pushing back (avoids echo). + + Updates widget ``_data`` with widget-specific state fields from JS, + then fires widget callbacks with a flat Event. + + Parameters + ---------- + msg : dict + Full raw event message from JS. + event_type : str + One of the new pointer event types (``pointer_move``, ``pointer_up``, + ``pointer_down``). + + Returns + ------- + bool + True if any widget state changed. + """ + # Fields that belong to the event envelope, not widget state + _envelope = { + "source", "panel_id", "event_type", "widget_id", + "time_stamp", "modifiers", "button", "buttons", + "x", "y", "xdata", "ydata", + } + changed = False + for k, v in msg.items(): + if k in ("id", "type") or k in _envelope: + continue + if self._data.get(k) != v: + self._data[k] = v + changed = True + + # Always fire on press/release; only fire pointer_move when state changed + if changed or event_type in ("pointer_up", "pointer_down"): + event = Event( + event_type=event_type, + source=self, + time_stamp=msg.get("time_stamp", 0.0), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ) + self.callbacks.fire(event) + return changed +``` + +Also update the `set` method (line ~97) which currently fires `Event("on_changed", ...)` directly: + +```python +def set(self, _push: bool = True, **kwargs) -> None: + self._data.update(kwargs) + if _push: + self._push_fn() + # Fire pointer_move for programmatic updates + self.callbacks.fire(Event("pointer_move", source=self)) +``` + +- [ ] **Step 4: Run existing Python tests to check nothing broke** + +```bash +uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive -x +``` +Expected: All non-interactive tests PASS (they don't touch event dispatch). + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/figure/_figure.py anyplotlib/widgets/_base.py +git commit -m "refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields" +``` + +--- + +## Task 6: Update `Plot1D` and `Line1D` + +Remove `on_changed`/`on_release`/`on_click`/`on_key`/`on_line_hover`/`on_line_click`/`disconnect`/`_connect_on_key`. Inherit `_EventMixin`. Update `Line1D` to expose `add_event_handler` with `line_id` filtering. Remove `registered_keys` from state. + +**Files:** +- Modify: `anyplotlib/plot1d/_plot1d.py` + +- [ ] **Step 1: Update imports in `plot1d/_plot1d.py`** + +Find the imports block and update the callbacks import: +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` + +- [ ] **Step 2: Make `Plot1D` inherit `_EventMixin`** + +Find the class definition line: +```python +class Plot1D: +``` +Change to: +```python +class Plot1D(_EventMixin): +``` + +- [ ] **Step 3: Remove `registered_keys` from `_state` in `Plot1D.__init__`** + +Find `"registered_keys": [],` in the `_state` dict initialisation and delete that line. + +- [ ] **Step 4: Add `_configure_pointer_settled` to `Plot1D`** + +After `self.callbacks = CallbackRegistry()` in `__init__`, add to the `_state` dict: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +Add this method to the `Plot1D` class: +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 5: Remove old event decorator methods from `Plot1D`** + +Delete these methods entirely (find by name): +- `on_changed` +- `on_release` +- `on_click` +- `on_key` +- `_connect_on_key` +- `on_line_hover` +- `on_line_click` +- `disconnect` + +- [ ] **Step 6: Update `Line1D` event methods** + +Replace `Line1D.on_hover` and `Line1D.on_click` with a single `add_event_handler` that filters by `line_id`: + +```python +def add_event_handler(self, fn_or_type, *args, **kwargs): + """Register a handler scoped to this line only. + + Wraps the plot-level ``pointer_move`` / ``pointer_down`` handler + with a ``line_id`` filter. Only ``pointer_move`` and ``pointer_down`` + are meaningful on a line handle. + + Usage:: + + @line.add_event_handler("pointer_move") + def on_hover(event): + print(event.xdata, event.line_id) + + @line.add_event_handler("pointer_down") + def on_pick(event): + print("picked", event.line_id) + """ + target_lid = self._lid + + if callable(fn_or_type): + fn = fn_or_type + types = args + return self._wrap_and_register(fn, types, target_lid, **kwargs) + else: + all_types = (fn_or_type,) + args + def _decorator(fn): + return self._wrap_and_register(fn, all_types, target_lid, **kwargs) + return _decorator + +def _wrap_and_register(self, fn, types, target_lid, **kwargs): + from functools import wraps + @wraps(fn) + def _filtered(event): + if event.line_id == target_lid: + fn(event) + _filtered.__wrapped__ = fn + return self._plot.add_event_handler(_filtered, *types, **kwargs) + +def remove_handler(self, cid_or_fn, *types): + """Remove a handler registered via this line handle.""" + self._plot.remove_handler(cid_or_fn, *types) +``` + +- [ ] **Step 7: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py anyplotlib/tests/test_plot1d/ -v +``` +Expected: All PASS. If `test_callbacks.py` had tests that used old `on_click` decorator on plots, update those to use `add_event_handler`. + +- [ ] **Step 8: Commit** + +```bash +git add anyplotlib/plot1d/_plot1d.py +git commit -m "refactor: Plot1D and Line1D adopt _EventMixin, remove old on_* decorators and registered_keys" +``` + +--- + +## Task 7: Update `Plot2D` and `PlotMesh` + +Same pattern as Task 6 — inherit `_EventMixin`, remove old decorators, add `_configure_pointer_settled`. + +**Files:** +- Modify: `anyplotlib/plot2d/_plot2d.py` +- Modify: `anyplotlib/plot2d/_plotmesh.py` + +- [ ] **Step 1: In `plot2d/_plot2d.py` — update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class Plot2D(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys` from `_state`, add settled config keys** + +Remove `"registered_keys": [],` from the `_state` dict. + +Add to `_state`: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled` to `Plot2D`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods from `Plot2D`** + +Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Check `PlotMesh` — it inherits `Plot2D`** + +Open `anyplotlib/plot2d/_plotmesh.py`. If `PlotMesh` also defines any of the removed methods directly, delete them. If it only inherits, no change is needed beyond checking the import line references nothing removed. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot2d/ -v +``` +Expected: All PASS. + +- [ ] **Step 7: Commit** + +```bash +git add anyplotlib/plot2d/_plot2d.py anyplotlib/plot2d/_plotmesh.py +git commit -m "refactor: Plot2D and PlotMesh adopt _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 8: Update `Plot3D` + +Same pattern. Additionally, add `"ray": None` to the `_state` template since Plot3D pointer events carry a `ray` field instead of `xdata`/`ydata`. + +**Files:** +- Modify: `anyplotlib/plot3d/_plot3d.py` + +- [ ] **Step 1: Update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class Plot3D(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys`, add settled config** + +Remove `"registered_keys": [],` from `_state`. + +Add: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods** + +Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot3d/ -v +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/plot3d/_plot3d.py +git commit -m "refactor: Plot3D adopts _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 9: Update `PlotBar` + +Same pattern. The `pointer_down` event for PlotBar carries `bar_index`, `value`, `x_label`, `group_index` from the JS side — these are already handled by the flat `Event` constructor in `_dispatch_event`, so no extra Python work is needed beyond inheriting the mixin. + +**Files:** +- Modify: `anyplotlib/plot1d/_plotbar.py` + +- [ ] **Step 1: Update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class PlotBar(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys`, add settled config** + +Remove `"registered_keys": [],` from `_state`. + +Add: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods** + +Delete: `on_click`, `on_changed`, `on_release`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot1d/test_plotbar.py -v +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/plot1d/_plotbar.py +git commit -m "refactor: PlotBar adopts _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 10: Update `Widget` base class + +Replace `on_changed`/`on_release`/`on_click`/`disconnect` with `_EventMixin`. The `_update_from_js` was already updated in Task 5. + +**Files:** +- Modify: `anyplotlib/widgets/_base.py` + +- [ ] **Step 1: Update import** + +```python +from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin +``` + +- [ ] **Step 2: Inherit `_EventMixin`** + +```python +class Widget(_EventMixin): +``` + +- [ ] **Step 3: Remove old decorator methods** + +Delete: `on_changed`, `on_release`, `on_click`, `disconnect`. + +The `callbacks` attribute is already set in `__init__` — `_EventMixin` will find it. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/ -v -k "widget" +``` +Expected: All widget tests PASS. + +- [ ] **Step 5: Run full Python test suite** + +```bash +uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive/test_event_plots.py \ + --ignore=anyplotlib/tests/test_interactive/test_event_settled.py \ + --ignore=anyplotlib/tests/test_interactive/test_event_pause_hold.py +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/widgets/_base.py +git commit -m "refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect" +``` + +--- + +## Task 11: JS — Forward new event types and fields + +Add the six missing event types to `figure_esm.js` and add `modifiers`, `buttons`, `button`, `time_stamp` to all emitted events. + +**Files:** +- Modify: `anyplotlib/figure_esm.js` + +This file is ~4000 lines. Search for existing mouse/key event listeners to find the right locations. + +- [ ] **Step 1: Find existing event emission sites** + +```bash +grep -n "mousedown\|mouseup\|mousemove\|keydown\|keyup\|wheel\|dblclick\|mouseenter\|mouseleave\|event_json\|event_type" anyplotlib/figure_esm.js | head -40 +``` +Note the line numbers for: mouse event listeners, the function that sends events to Python, key event handling. + +- [ ] **Step 2: Add a helper to extract common fields** + +Find where JS sends events to Python (the function that writes to `event_json`). Add a helper function near the top of the event-handling section: + +```javascript +function _pointerFields(e, panelId) { + return { + time_stamp: performance.now() / 1000, // seconds, matching perf_counter() + modifiers: _modifiers(e), + button: e.button ?? null, + buttons: e.buttons ?? 0, + }; +} + +function _modifiers(e) { + const mods = []; + if (e.ctrlKey) mods.push("ctrl"); + if (e.shiftKey) mods.push("shift"); + if (e.altKey) mods.push("alt"); + if (e.metaKey) mods.push("meta"); + return mods; +} +``` + +- [ ] **Step 3: Rename outgoing `event_type` values** + +Find all places the JS emits `event_type: "on_click"`, `"on_changed"`, `"on_release"`, `"on_key"`, `"on_line_hover"`, `"on_line_click"` and replace: + +| Old JS `event_type` | New JS `event_type` | +|---------------------|---------------------| +| `"on_click"` | `"pointer_down"` | +| `"on_changed"` | `"pointer_move"` | +| `"on_release"` | `"pointer_settled"` | +| `"on_key"` | `"key_down"` | +| `"on_line_hover"` | `"pointer_move"` (with `line_id` field already set) | +| `"on_line_click"` | `"pointer_down"` (with `line_id` field already set) | +| `"on_inset_state_change"` | `"inset_state_change"` | + +- [ ] **Step 4: Rename outgoing payload field names** + +In all JS event payloads, rename: +- `phys_x` → `xdata` +- `phys_y` → `ydata` +- `mouse_x` → `x` +- `mouse_y` → `y` + +```bash +grep -n "phys_x\|phys_y\|mouse_x\|mouse_y" anyplotlib/figure_esm.js +``` +Replace every occurrence. + +- [ ] **Step 5: Add `_pointerFields` to every emitted pointer event** + +For every place the JS calls the send-to-Python function with a pointer event, spread `_pointerFields(e, panelId)` into the payload: + +```javascript +// Before (example): +sendEvent({ event_type: "pointer_down", panel_id: panelId, x: px, y: py }); + +// After: +sendEvent({ event_type: "pointer_down", panel_id: panelId, + ..._pointerFields(e, panelId), x: px, y: py }); +``` + +- [ ] **Step 6: Add listener for `pointer_up` (mouseup)** + +Find the `mousedown` listener and add a `mouseup` listener alongside it: + +```javascript +canvas.addEventListener("mouseup", (e) => { + sendEvent({ + event_type: "pointer_up", + panel_id: panelId, + ..._pointerFields(e, panelId), + x: /* pixel x relative to canvas */, + y: /* pixel y relative to canvas */, + xdata: /* data coord x or null */, + ydata: /* data coord y or null */, + }); +}); +``` + +- [ ] **Step 7: Add `pointer_enter` / `pointer_leave` listeners** + +```javascript +canvas.addEventListener("mouseenter", (e) => { + sendEvent({ event_type: "pointer_enter", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); +}); +canvas.addEventListener("mouseleave", (e) => { + sendEvent({ event_type: "pointer_leave", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); +}); +``` + +Note: `button` is `null` on enter/leave events (no button triggered the event). `buttons` reflects currently-held buttons. + +- [ ] **Step 8: Add `double_click` listener** + +```javascript +canvas.addEventListener("dblclick", (e) => { + sendEvent({ event_type: "double_click", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/, + xdata: /*or null*/, ydata: /*or null*/ }); +}); +``` + +- [ ] **Step 9: Add `wheel` listener** + +```javascript +canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + sendEvent({ event_type: "wheel", panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: /*px*/, y: /*py*/, + dx: e.deltaX, dy: e.deltaY }); +}, { passive: false }); +``` + +- [ ] **Step 10: Add `key_up` listener** + +Find the existing `keydown` listener and add `keyup` alongside: + +```javascript +document.addEventListener("keyup", (e) => { + if (!panelFocused) return; + sendEvent({ event_type: "key_up", panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, x: lastPointerX, y: lastPointerY }); +}); +``` + +- [ ] **Step 11: Remove `registered_keys` filtering from JS** + +Find the section that checks `registered_keys` before forwarding key events (something like `if (state.registered_keys.includes(e.key) || ...)`). Remove this guard — forward all key events unconditionally. + +- [ ] **Step 12: Run the full pure-Python test suite to confirm no regressions** + +```bash +uv run pytest anyplotlib/tests/ -v -k "not test_event_plots and not test_event_settled and not test_event_pause_hold" +``` +Expected: All PASS. + +- [ ] **Step 13: Commit** + +```bash +git add anyplotlib/figure_esm.js +git commit -m "feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event fields to xdata/ydata/x/y; add modifiers/button/buttons/time_stamp" +``` + +--- + +## Task 12: JS — `pointer_settled` dwell timer + +Add a per-panel dwell timer that fires `pointer_settled` after the pointer holds still for the configured ms/delta thresholds. + +**Files:** +- Modify: `anyplotlib/figure_esm.js` + +- [ ] **Step 1: Add timer state per panel** + +Near the per-panel state initialisation, add: + +```javascript +let _settledTimer = null; +let _settledStartX = 0; +let _settledStartY = 0; +let _settledStartTs = 0; +``` + +- [ ] **Step 2: Add `pointer_settled` trigger inside the `pointer_move` handler** + +Inside the `mousemove` / `pointer_move` emission block, after emitting `pointer_move`, add: + +```javascript +// pointer_settled dwell timer +const settledMs = panelState.pointer_settled_ms ?? 0; +const settledDelta = panelState.pointer_settled_delta ?? 4; +if (settledMs > 0) { + clearTimeout(_settledTimer); + const nowX = currentPixelX; + const nowY = currentPixelY; + const nowTs = performance.now(); + _settledStartX = nowX; + _settledStartY = nowY; + _settledStartTs = nowTs; + _settledTimer = setTimeout(() => { + const dist = Math.hypot(currentPixelX - _settledStartX, + currentPixelY - _settledStartY); + if (dist <= settledDelta) { + const dwellMs = performance.now() - _settledStartTs; + sendEvent({ + event_type: "pointer_settled", + panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: lastModifiers, + buttons: lastButtons, + button: null, + x: currentPixelX, + y: currentPixelY, + xdata: currentDataX ?? null, + ydata: currentDataY ?? null, + dwell_ms: dwellMs, + }); + } + }, settledMs); +} +``` + +Where `currentPixelX`, `currentPixelY`, `currentDataX`, `currentDataY`, `lastModifiers`, `lastButtons` are variables already tracked by the mousemove handler. + +- [ ] **Step 3: Cancel timer on `mouseup` and `mouseleave`** + +Inside the `mouseup` and `mouseleave` handlers, add: +```javascript +clearTimeout(_settledTimer); +_settledTimer = null; +``` + +- [ ] **Step 4: Commit** + +```bash +git add anyplotlib/figure_esm.js +git commit -m "feat: add pointer_settled dwell timer to JS with zero cost when unused" +``` + +--- + +## Task 13: Playwright tests — pointer events per plot type + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_plots.py` + +- [ ] **Step 1: Create the test file** + +```python +""" +Playwright tests for pointer/key events across all plot types. +Each plot type gets: pointer_down, pointer_up, pointer_move, pointer_enter, +pointer_leave, double_click, wheel, key_down, key_up, modifiers. +""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _collect(page, fig, event_type): + """Return a list of event dicts received for event_type.""" + page.evaluate(f""" + window._evts_{event_type} = []; + window._aplModel.on("{event_type}", (e) => {{ + window._evts_{event_type}.push(e); + }}); + """) + return page.evaluate(f"window._evts_{event_type}") + + +def _plot1d_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(100)) + return fig + + +def _plot2d_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + ax.imshow(np.zeros((64, 64))) + return fig + + +def _plot3d_fig(): + x = np.linspace(-2, 2, 20) + y = np.linspace(-2, 2, 20) + XX, YY = np.meshgrid(x, y) + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + ax.plot_surface(XX, YY, np.zeros_like(XX)) + return fig + + +def _plotbar_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.bar(["A", "B", "C"], [1.0, 2.0, 3.0]) + return fig + + +# ── pointer_down ───────────────────────────────────────────────────────────── + +class TestPointerDown: + def test_plot1d_pointer_down_fields(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd", lambda e: received.append(json.loads(e))) + page.evaluate(""" + window._aplModel && window._aplModel.on && + window._aplModel.on("pointer_down", e => window._on_pd(JSON.stringify(e))) + """) + page.mouse.click(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "pointer_down" + assert isinstance(e["x"], (int, float)) + assert isinstance(e["y"], (int, float)) + assert e["button"] == 0 + assert e["buttons"] == 0 # buttons=0 after release + assert isinstance(e["modifiers"], list) + assert isinstance(e["time_stamp"], (int, float)) + + def test_plot2d_pointer_down_has_xdata_ydata(self, interact_page): + fig = _plot2d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd2", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_pd2(JSON.stringify(e)))" + ) + page.mouse.click(200, 200) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e.get("xdata") is not None + assert e.get("ydata") is not None + + def test_plot3d_pointer_down_no_xdata(self, interact_page): + fig = _plot3d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd3", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_pd3(JSON.stringify(e)))" + ) + page.mouse.click(200, 200) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e.get("xdata") is None + assert e.get("ydata") is None + + def test_ctrl_click_modifiers(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_ctrl", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_ctrl(JSON.stringify(e)))" + ) + page.keyboard.down("Control") + page.mouse.click(200, 150) + page.keyboard.up("Control") + page.wait_for_timeout(200) + assert any("ctrl" in e.get("modifiers", []) for e in received) + + +# ── pointer_up ──────────────────────────────────────────────────────────────── + +class TestPointerUp: + def test_fires_after_drag(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pu", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_up', e => window._on_pu(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(150, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[-1] + assert e["event_type"] == "pointer_up" + assert e["button"] == 0 + + +# ── pointer_move ────────────────────────────────────────────────────────────── + +class TestPointerMove: + def test_fires_during_drag(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pm", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_move', e => window._on_pm(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=10) + page.mouse.up() + page.wait_for_timeout(300) + assert len(received) >= 5 # multiple frames during drag + + +# ── pointer_enter / pointer_leave ───────────────────────────────────────────── + +class TestPointerEnterLeave: + def test_enter_fires_on_mouse_enter(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pe", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_enter', e => window._on_pe(JSON.stringify(e)))" + ) + # Move from outside the widget to inside + page.mouse.move(0, 0) + page.mouse.move(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "pointer_enter" + assert received[0].get("button") is None # button is None on enter + assert isinstance(received[0]["buttons"], int) + + def test_leave_fires_on_mouse_leave(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pl", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_leave', e => window._on_pl(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.move(0, 0) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "pointer_leave" + + +# ── double_click ────────────────────────────────────────────────────────────── + +class TestDoubleClick: + def test_fires_on_dblclick(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_dc", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('double_click', e => window._on_dc(JSON.stringify(e)))" + ) + page.mouse.dblclick(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "double_click" + assert received[0]["button"] == 0 + + +# ── wheel ───────────────────────────────────────────────────────────────────── + +class TestWheel: + def test_fires_on_scroll(self, interact_page): + fig = _plot2d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_wh", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('wheel', e => window._on_wh(JSON.stringify(e)))" + ) + page.mouse.move(200, 200) + page.mouse.wheel(0, 100) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "wheel" + assert e.get("dy") is not None + + +# ── key_down / key_up ───────────────────────────────────────────────────────── + +class TestKeyEvents: + def test_key_down_fires_any_key(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_kd", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('key_down', e => window._on_kd(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) # focus the panel + page.keyboard.press("r") + page.wait_for_timeout(200) + assert any(e["key"] == "r" for e in received) + + def test_key_up_fires(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_ku", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('key_up', e => window._on_ku(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.keyboard.down("q") + page.keyboard.up("q") + page.wait_for_timeout(200) + assert any(e["key"] == "q" for e in received) +``` + +- [ ] **Step 2: Run the new tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_plots.py -v +``` +Expected: All PASS. Fix any failures by adjusting pixel coordinates or widget locators to match your actual panel layout. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_plots.py +git commit -m "test: add Playwright tests for pointer_down/up/move, enter/leave, double_click, wheel, key_down/up" +``` + +--- + +## Task 14: Playwright tests — `pointer_settled` + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_settled.py` + +- [ ] **Step 1: Create the test file** + +```python +"""Tests for pointer_settled dwell timer — JS computes, Python receives.""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl +from anyplotlib.callbacks import Event + + +# ── Python-side: _configure_pointer_settled ─────────────────────────────────── + +class TestSettledConfig: + def test_state_set_on_first_connect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 4 + + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._state["pointer_settled_ms"] == 400 + assert plot._state["pointer_settled_delta"] == 5 + + def test_state_cleared_on_last_disconnect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._state["pointer_settled_ms"] == 0 + + def test_two_handlers_keep_last_config(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=200, delta=3) + plot.add_event_handler(fn2, "pointer_settled", ms=800, delta=6) + # Last connect wins — ms=800, delta=6 + assert plot._state["pointer_settled_ms"] == 800 + assert plot._state["pointer_settled_delta"] == 6 + # Remove fn2 — config clears only when NO handlers remain + plot.remove_handler(fn2) + # fn1 still connected → ms stays at 800 (fn1's config is remembered by registry) + assert plot._state["pointer_settled_ms"] > 0 + + +# ── Playwright: dwell timer ─────────────────────────────────────────────────── + +class TestSettledPlaywright: + def test_fires_after_hold(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + # Configure a short dwell (200ms) for fast tests + plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st(JSON.stringify(e)))" + ) + + # Move into panel and hold still + page.mouse.move(200, 150) + page.wait_for_timeout(400) # well past the 200ms threshold + + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "pointer_settled" + assert e["dwell_ms"] >= 200 + + def test_does_not_fire_if_moving(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st2", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st2(JSON.stringify(e)))" + ) + + # Keep moving — should never settle + page.mouse.move(100, 150) + page.mouse.move(150, 150, steps=5) + page.mouse.move(200, 150, steps=5) + page.mouse.move(250, 150, steps=5) + page.wait_for_timeout(100) + + assert received == [] + + def test_no_timer_when_no_handler_connected(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + # No pointer_settled handler connected — pointer_settled_ms stays 0 + + page = interact_page(fig) + # Confirm JS state has no timer configured + settled_ms = page.evaluate( + f"JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert settled_ms == 0 + + def test_fires_again_after_re_settle(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st3", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st3(JSON.stringify(e)))" + ) + + # First settle + page.mouse.move(200, 150) + page.wait_for_timeout(350) + + # Move and settle again + page.mouse.move(100, 150, steps=3) + page.wait_for_timeout(350) + + assert len(received) >= 2 # fired twice +``` + +- [ ] **Step 2: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_settled.py -v +``` +Expected: All PASS. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_settled.py +git commit -m "test: add pointer_settled Playwright tests including zero-cost guard" +``` + +--- + +## Task 15: Playwright tests — pause/hold integration + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_pause_hold.py` + +- [ ] **Step 1: Create the test file** + +```python +"""Integration tests for pause_events / hold_events during live interactions.""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl + + +class TestPauseIntegration: + def test_pause_drops_pointer_move_during_drag(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + page = interact_page(fig) + + # Pause then trigger drag — moves should not reach handler + page.evaluate("window._aplPaused = true") # hook into test infra below + with plot.pause_events("pointer_move"): + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(200) + + assert received == [] + + # After context exits, moves should fire again + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(150, 150, steps=3) + page.mouse.up() + page.wait_for_timeout(200) + assert len(received) > 0 + + +class TestHoldIntegration: + def test_hold_buffers_settled_fires_on_exit(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_settled") + + page = interact_page(fig) + + with plot.hold_events("pointer_settled"): + page.mouse.move(200, 150) + page.wait_for_timeout(300) # settled fires → buffered + assert received == [] + + # hold context exited → flushed + assert received == [1] + + def test_hold_fires_pointer_move_immediately(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + moves = [] + settles = [] + plot.add_event_handler(lambda e: moves.append(1), "pointer_move") + plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) + plot.add_event_handler(lambda e: settles.append(1), "pointer_settled") + + page = interact_page(fig) + + with plot.hold_events("pointer_settled"): + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(300) + + assert len(moves) > 0 # pointer_move not held → fired immediately + assert len(settles) == 1 # flushed on exit +``` + +- [ ] **Step 2: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_pause_hold.py -v +``` +Expected: All PASS. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_pause_hold.py +git commit -m "test: add pause_events and hold_events Playwright integration tests" +``` + +--- + +## Task 16: Update Examples and regression tests + +**Files:** +- Modify: All `Examples/**/*.py` files that use old event API +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` (add regression block) + +- [ ] **Step 1: Find all example files using old event API** + +```bash +grep -rn "on_click\|on_changed\|on_release\|on_key\|on_hover\|\.disconnect(" Examples/ --include="*.py" +``` + +- [ ] **Step 2: Update each file** + +For each file found, replace old API calls: + +| Old | New | +|-----|-----| +| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | +| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | +| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | +| `@plot.on_key` | `@plot.add_event_handler("key_down")` | +| `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` + `if event.key == "q": return` | +| `@widget.on_changed` | `@widget.add_event_handler("pointer_move")` | +| `@widget.on_release` | `@widget.add_event_handler("pointer_up")` | +| `@widget.on_click` | `@widget.add_event_handler("pointer_down")` | +| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | +| `@line.on_click` | `@line.add_event_handler("pointer_down")` | +| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | +| `event.phys_x` | `event.xdata` | +| `event.phys_y` | `event.ydata` | +| `event.mouse_x` | `event.x` | +| `event.mouse_y` | `event.y` | + +- [ ] **Step 3: Add regression tests to `test_callbacks.py`** + +Append to `anyplotlib/tests/test_interactive/test_callbacks.py`: + +```python +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): + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_event_no_data_dict(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +uv run pytest anyplotlib/tests/ -v +``` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Examples/ anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: update Examples to new event API; add regression tests confirming old API removed" +``` + +--- + +## Verification + +After all tasks complete, run the full suite once more: + +```bash +uv run pytest anyplotlib/tests/ -v --tb=short 2>&1 | tail -20 +``` + +Expected output ends with something like: +``` +========== NNN passed in XX.Xs ========== +``` + +with zero failures or errors. From 7123d6dddd9d39c9e829a3b6dcab7b6302cc9ec9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:46:20 -0500 Subject: [PATCH 04/43] =?UTF-8?q?refactor:=20flatten=20Event=20dataclass?= =?UTF-8?q?=20=E2=80=94=20all=20payload=20fields=20are=20typed=20top-level?= =?UTF-8?q?=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old Event(event_type, source, data: dict) + __getattr__ proxy with a flat dataclass where every payload field (x, y, xdata, ydata, key, modifiers, etc.) is a typed top-level attribute with sensible defaults. Exports VALID_EVENT_TYPES frozenset and keeps CallbackRegistry as a minimal placeholder ahead of the full Task 2 rewrite. --- anyplotlib/callbacks.py | 142 ++-- .../tests/test_interactive/test_callbacks.py | 755 ++---------------- 2 files changed, 152 insertions(+), 745 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 66ec37af..7628b75a 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -2,106 +2,112 @@ callbacks.py ============ -Lightweight two-class event system used by every plot object and widget. - -:class:`CallbackRegistry` - Per-object store of named callbacks. Every plot object and widget - exposes ``on_changed``, ``on_release``, ``on_click``, ``on_key``, - ``on_line_hover``, and ``on_line_click`` decorator methods that - connect handlers through this registry. +Event system used by all plot objects and widgets. :class:`Event` - Immutable data-carrier passed to every callback. All keys in the - raw JS payload are accessible as attributes (``event.zoom``, - ``event.cx``, etc.) in addition to the typed ``event_type``, - ``source``, and ``data`` fields. - -Example -------- -.. code-block:: python + Flat dataclass carrying all event fields as typed top-level attributes. - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(data) +:class:`CallbackRegistry` + Per-object handler store. (Full implementation added in Tasks 2-3.) - @plot.on_release - def on_settle(event): - print(f"zoom={event.zoom:.2f} center=({event.center_x:.3f}, {event.center_y:.3f})") +:class:`_EventMixin` + Mixin added to every plot class and widget. (Added in Task 4.) """ - from __future__ import annotations + +import time +from collections import defaultdict, deque +from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, Callable -_VALID_EVENT_TYPES = ( - "on_click", - "on_changed", - "on_release", - "on_key", - "on_line_hover", - "on_line_click", -) +VALID_EVENT_TYPES = frozenset({ + "pointer_down", "pointer_up", "pointer_move", "pointer_settled", + "pointer_enter", "pointer_leave", "double_click", "wheel", + "key_down", "key_up", "*", +}) @dataclass class Event: - """A single interactive event. + """A single interactive event with all payload fields as typed attributes. + + Universal fields (every event): + event_type, source, time_stamp, modifiers - :event_type: one of ``on_click`` / ``on_changed`` / ``on_release`` / - ``on_key`` / ``on_line_hover`` / ``on_line_click`` - :source: the originating Python object (Widget, Plot, or None) - :data: full state dict; all keys also accessible as ``event.x`` + Pointer fields (pointer_* and double_click events): + x, y — pixel coordinates within the panel + button — 0=left 1=middle 2=right; None on move/enter/leave/settled + buttons — bitmask of currently held buttons + xdata, ydata — data-space coordinates (None for Plot3D) + ray — Plot3D only: {"origin": [...], "direction": [...]} + line_id — Plot1D only: set when pointer is over a line + dwell_ms — pointer_settled only: actual dwell time + PlotBar extra fields (pointer_down only): + bar_index, value, x_label, group_index - For ``on_line_hover`` and ``on_line_click`` events the data dict - contains: + Wheel fields: + dx, dy — scroll deltas - * ``line_id`` – ``None`` for the primary line, or the 8-char ID - string assigned by :meth:`Plot1D.add_line`. - * ``x`` – data-space x coordinate of the nearest point on the line. - * ``y`` – data-space y coordinate of the nearest point on the line. + Key fields: + key — key name e.g. "q", "Enter", "ArrowLeft" + + Propagation: + stop_propagation — set True inside a handler to halt remaining handlers """ event_type: str - source: Any - data: dict = field(default_factory=dict) - - def __getattr__(self, key: str) -> Any: - try: - return self.data[key] - except KeyError: - raise AttributeError( - f"Event has no attribute {key!r}. " - f"Available data keys: {list(self.data)}" - ) from None + source: Any = None + time_stamp: float = field(default_factory=time.perf_counter) + modifiers: list[str] = field(default_factory=list) + # Pointer + x: int | None = None + y: int | None = None + button: int | None = None + buttons: int = 0 + xdata: float | None = None + ydata: float | None = None + ray: dict | None = None + line_id: str | None = None + dwell_ms: float | None = None + # PlotBar + bar_index: int | None = None + value: float | None = None + x_label: str | None = None + group_index: int | None = None + # Wheel + dx: float | None = None + dy: float | None = None + # Key + key: str | None = None + # Propagation (not repr'd) + stop_propagation: bool = field(default=False, repr=False) def __repr__(self) -> str: src = type(self.source).__name__ if self.source is not None else "None" parts = [f"event_type={self.event_type!r}", f"source={src}"] - _skip = {"id", "type", "color", "colormap_data", - "image_b64", "histogram_data", "colormap_name"} - shown = 0 - for k, v in self.data.items(): - if k in _skip or shown >= 6: - continue - parts.append( - f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" - ) - shown += 1 + for fname in ("x", "y", "xdata", "ydata", "button", "key", + "line_id", "bar_index", "dwell_ms"): + v = getattr(self, fname) + if v is not None: + parts.append(f"{fname}={v!r}") + if self.modifiers: + parts.append(f"modifiers={self.modifiers!r}") return "Event(" + ", ".join(parts) + ")" class CallbackRegistry: - """Per-object registry for on_click / on_changed / on_release / on_key / - on_line_hover / on_line_click callbacks.""" + """Minimal placeholder — full implementation in Task 2.""" def __init__(self) -> None: self._next_cid: int = 1 self._entries: dict[int, tuple[str, Callable]] = {} def connect(self, event_type: str, fn: Callable) -> int: - """Register fn for event_type. Returns integer CID.""" - if event_type not in _VALID_EVENT_TYPES: + if event_type not in VALID_EVENT_TYPES: raise ValueError( - f"event_type must be one of {_VALID_EVENT_TYPES}, got {event_type!r}" + f"Invalid event_type {event_type!r}. " + f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" ) cid = self._next_cid self._next_cid += 1 @@ -109,11 +115,9 @@ def connect(self, event_type: str, fn: Callable) -> int: return cid def disconnect(self, cid: int) -> None: - """Remove handler for cid. Silent if not found.""" self._entries.pop(cid, None) - def fire(self, event) -> None: - """Dispatch event to all handlers matching event.event_type.""" + def fire(self, event: Event) -> None: for _cid, (et, fn) in list(self._entries.items()): if et == event.event_type: fn(event) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index b38fae34..549655fc 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -1,682 +1,85 @@ -""" -tests/test_interactive/test_callbacks.py -======================================== - -Tests for the unified object-level callback system. - -Covers: - * Event dataclass – event_type / source / data / attribute forwarding - * CallbackRegistry – connect / disconnect / fire (event_type dispatch only) - * Plot2D / Plot1D / PlotMesh / Plot3D – on_changed / on_release / on_click - * Figure._on_event – JSON routing to widget + plot callbacks - * Practical patterns - -Widget-level callback and event-dispatch integration tests live in -``test_widgets.py``. -""" - +"""Tests for the redesigned Event dataclass and CallbackRegistry.""" from __future__ import annotations - -import json -import numpy as np +import time import pytest - -import anyplotlib as apl -from anyplotlib.callbacks import CallbackRegistry, Event -from anyplotlib.plot1d import Plot1D -from anyplotlib.plot2d import Plot2D, PlotMesh -from anyplotlib.plot3d import Plot3D - - -# ───────────────────────────────────────────────────────────────────────────── -# Helpers -# ───────────────────────────────────────────────────────────────────────────── - -def _simulate_js_event(fig, plot, event_type: str, *, widget_id=None, **fields): - """Simulate JS sending an interaction event via event_json.""" - payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} - if widget_id is not None: - payload["widget_id"] = widget_id if isinstance(widget_id, str) else widget_id._id - payload.update(fields) - fig._on_event({"new": json.dumps(payload)}) - - -def _plot2d(): - fig, ax = apl.subplots(1, 1) - return ax.imshow(np.zeros((32, 32))) +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES -def _plot1d(): - fig, ax = apl.subplots(1, 1) - return ax.plot(np.zeros(64)) - - -def _plotmesh(): - fig, ax = apl.subplots(1, 1) - return ax.pcolormesh(np.zeros((8, 8))) - - -def _plot3d(): - fig, ax = apl.subplots(1, 1) - x = np.linspace(-1, 1, 10) - y = np.linspace(-1, 1, 10) - X, Y = np.meshgrid(x, y) - Z = X ** 2 + Y ** 2 - return ax.plot_surface(X, Y, Z) - - -# ───────────────────────────────────────────────────────────────────────────── -# 1. Event dataclass -# ───────────────────────────────────────────────────────────────────────────── +# ── Event dataclass ─────────────────────────────────────────────────────────── class TestEvent: - def test_event_type_field(self): - ev = Event(event_type="on_release", source=None, data={"x": 1.0}) - assert ev.event_type == "on_release" - - def test_source_field(self): - obj = object() - ev = Event(event_type="on_changed", source=obj, data={}) - assert ev.source is obj - - def test_data_attribute_forwarding(self): - ev = Event(event_type="on_changed", source=None, data={"cx": 12.5, "cy": 8.0}) - assert ev.cx == pytest.approx(12.5) - assert ev.cy == pytest.approx(8.0) - - def test_unknown_attribute_raises(self): - ev = Event(event_type="on_changed", source=None, data={"x": 1.0}) - with pytest.raises(AttributeError, match="Event has no attribute 'nonexistent'"): - _ = ev.nonexistent - - def test_data_key_various_types(self): - ev = Event(event_type="on_click", source=None, - data={"x": 1.1, "text": "hello", "flag": True, "n": 7}) - assert ev.x == pytest.approx(1.1) - assert ev.text == "hello" - assert ev.flag is True - assert ev.n == 7 - - def test_empty_data_raises_on_access(self): - ev = Event(event_type="on_release", source=None, data={}) - with pytest.raises(AttributeError): - _ = ev.anything - - def test_repr_contains_event_type(self): - ev = Event(event_type="on_release", source=None, data={"zoom": 2.5}) - assert "on_release" in repr(ev) - - def test_repr_shows_source_type(self): - from anyplotlib.widgets import CircleWidget - w = CircleWidget(lambda: None, cx=0, cy=0, r=5) - ev = Event(event_type="on_changed", source=w, data={}) - assert "CircleWidget" in repr(ev) - - -# ───────────────────────────────────────────────────────────────────────────── -# 2. CallbackRegistry -# ───────────────────────────────────────────────────────────────────────────── - -class TestCallbackRegistry: - - def test_connect_returns_int_cid(self): - reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) - assert isinstance(cid, int) - - def test_connect_cids_increment(self): - reg = CallbackRegistry() - c1 = reg.connect("on_changed", lambda e: None) - c2 = reg.connect("on_release", lambda e: None) - assert c2 > c1 - - def test_invalid_event_type_raises(self): - reg = CallbackRegistry() - with pytest.raises(ValueError, match="event_type must be one of"): - reg.connect("change", lambda e: None) # old name - - def test_fire_on_changed(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_changed", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert len(fired) == 1 - - def test_fire_does_not_cross_types(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert fired == [] - - def test_fire_on_release(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_fire_on_click(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_click", lambda e: fired.append(e)) - reg.fire(Event("on_click", None, {})) - assert len(fired) == 1 - - def test_three_types_independent(self): - reg = CallbackRegistry() - c_log, r_log, k_log = [], [], [] - reg.connect("on_changed", lambda e: c_log.append(1)) - reg.connect("on_release", lambda e: r_log.append(1)) - reg.connect("on_click", lambda e: k_log.append(1)) - reg.fire(Event("on_changed", None, {})) - reg.fire(Event("on_release", None, {})) - reg.fire(Event("on_click", None, {})) - assert len(c_log) == 1 and len(r_log) == 1 and len(k_log) == 1 - - def test_disconnect_removes_handler(self): - reg = CallbackRegistry() - fired = [] - cid = reg.connect("on_release", lambda e: fired.append(e)) - reg.disconnect(cid) - reg.fire(Event("on_release", None, {})) - assert fired == [] - - def test_disconnect_unknown_cid_is_silent(self): - reg = CallbackRegistry() - reg.disconnect(9999) - - def test_disconnect_twice_is_silent(self): - reg = CallbackRegistry() - cid = reg.connect("on_release", lambda e: None) - reg.disconnect(cid) - reg.disconnect(cid) - - def test_bool_false_when_empty(self): - assert not CallbackRegistry() - - def test_bool_true_when_connected(self): - reg = CallbackRegistry() - reg.connect("on_changed", lambda e: None) - assert reg - - def test_bool_false_after_all_disconnected(self): - reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) - reg.disconnect(cid) - assert not reg - - def test_multiple_handlers_all_called(self): - reg = CallbackRegistry() - log = [] - reg.connect("on_release", lambda e: log.append("a")) - reg.connect("on_release", lambda e: log.append("b")) - reg.connect("on_release", lambda e: log.append("c")) - reg.fire(Event("on_release", None, {})) - assert sorted(log) == ["a", "b", "c"] - - def test_disconnect_inside_callback_is_safe(self): - reg = CallbackRegistry() - fired = [] - - def self_disconnect(event): - fired.append(event) - reg.disconnect(self_disconnect._cid) - - self_disconnect._cid = reg.connect("on_release", self_disconnect) - reg.fire(Event("on_release", None, {})) - reg.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_no_handlers_fire_is_noop(self): - CallbackRegistry().fire(Event("on_release", None, {})) - - -# ───────────────────────────────────────────────────────────────────────────── -# 3. Plot2D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot2DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot2d().callbacks, CallbackRegistry) - - def test_on_changed_decorator(self): - v = _plot2d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - assert len(fired) == 1 - - def test_on_changed_not_fired_for_release(self): - v = _plot2d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - def test_on_release_decorator(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_on_click_decorator(self): - v = _plot2d() - fired = [] - - @v.on_click - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_click", None, {"x": 5.0, "y": 10.0})) - assert len(fired) == 1 - assert fired[0].x == pytest.approx(5.0) - - def test_decorator_stamps_cid(self): - v = _plot2d() - - @v.on_release - def cb(event): pass - - assert hasattr(cb, "_cid") and isinstance(cb._cid, int) - - def test_disconnect(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - def test_single_fire_pattern(self): - v = _plot2d() - fired = [] - - @v.on_release - def once(event): - fired.append(event) - v.disconnect(once._cid) - - v.callbacks.fire(Event("on_release", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_zoom_event_data(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, - {"center_x": 0.6, "center_y": 0.4, "zoom": 3.0})) - assert fired[0].zoom == pytest.approx(3.0) - - -# ───────────────────────────────────────────────────────────────────────────── -# 4. Plot1D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot1DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot1d().callbacks, CallbackRegistry) - - def test_on_changed_and_on_release(self): - v = _plot1d() - change_fired, release_fired = [], [] - - @v.on_changed - def lv(event): change_fired.append(event) - - @v.on_release - def done(event): release_fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(change_fired) == 1 and len(release_fired) == 1 - - def test_view_change_event_data(self): - v = _plot1d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {"view_x0": 0.2, "view_x1": 0.8})) - assert fired[0].view_x0 == pytest.approx(0.2) - assert fired[0].view_x1 == pytest.approx(0.8) - - def test_disconnect(self): - v = _plot1d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_changed", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 5. PlotMesh callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlotMeshCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plotmesh().callbacks, CallbackRegistry) - - def test_on_changed_and_on_release(self): - v = _plotmesh() - change_fired, release_fired = [], [] - - @v.on_changed - def lv(event): change_fired.append(event) - - @v.on_release - def done(event): release_fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(change_fired) == 1 and len(release_fired) == 1 - - def test_disconnect(self): - v = _plotmesh() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 6. Plot3D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot3DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot3d().callbacks, CallbackRegistry) - - def test_on_changed_rotation(self): - v = _plot3d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_changed", None, - {"azimuth": 45.0, "elevation": 30.0, "zoom": 1.0})) - assert fired[0].azimuth == pytest.approx(45.0) - - def test_on_release_data(self): - v = _plot3d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, - {"azimuth": -60.0, "elevation": 20.0, "zoom": 2.5})) - assert fired[0].zoom == pytest.approx(2.5) - - def test_on_click(self): - v = _plot3d() - fired = [] - - @v.on_click - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_click", None, {"x": 1.0})) - assert len(fired) == 1 - - def test_disconnect(self): - v = _plot3d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 7. Figure._on_event routing -# ───────────────────────────────────────────────────────────────────────────── - -class TestFigureEventRouting: - - def test_dispatch_reaches_plot_callbacks(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - _simulate_js_event(fig, v, "on_release", cx=10.0, cy=20.0) - assert len(fired) == 1 - assert fired[0].cx == pytest.approx(10.0) - - def test_dispatch_with_widget_id_updates_widget(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("circle", cx=0.0, cy=0.0) - - _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=5.0) - assert wid.cx == pytest.approx(5.0) - - def test_widget_and_plot_callbacks_both_fire(self): - """A single JS event bearing a widget_id fires both the widget-level - and the plot-level on_release callbacks, with the widget as source.""" - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("circle") - w_fired, p_fired = [], [] - - @wid.on_release - def wc(event): w_fired.append(event) - - @v.on_release - def pc(event): p_fired.append(event) - - _simulate_js_event(fig, v, "on_release", widget_id=wid, cx=5.0, cy=5.0) - assert len(w_fired) == 1 and len(p_fired) == 1 - assert w_fired[0].source is wid - assert p_fired[0].source is wid - - def test_dispatch_wrong_panel_id_ignored(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps({"source": "js", "panel_id": "nonexistent", - "event_type": "on_release"})}) - assert fired == [] - - def test_dispatch_empty_json_ignored(self): - fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "{}"}) - - def test_dispatch_invalid_json_ignored(self): - fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "not-json"}) - - def test_source_python_not_dispatched(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps( - {"source": "python", "panel_id": v._id, - "event_type": "on_changed", "cx": 5.0})}) - assert fired == [] - - def test_multi_panel_correct_routing(self): - fig, (ax1, ax2) = apl.subplots(1, 2) - v1 = ax1.imshow(np.zeros((16, 16))) - v2 = ax2.plot(np.zeros(32)) - fired1, fired2 = [], [] - - @v1.on_release - def cb1(event): fired1.append(event) - - @v2.on_release - def cb2(event): fired2.append(event) - - _simulate_js_event(fig, v1, "on_release", zoom=1.5) - assert len(fired1) == 1 and fired2 == [] - - _simulate_js_event(fig, v2, "on_release", view_x0=0.1, view_x1=0.9) - assert len(fired2) == 1 and len(fired1) == 1 - - def test_protocol_keys_stripped_from_event_data(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((16, 16))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - _simulate_js_event(fig, v, "on_release", zoom=2.0) - ev = fired[0] - assert "panel_id" not in ev.data - assert "event_type" not in ev.data - assert "source" not in ev.data - assert ev.zoom == pytest.approx(2.0) - - def test_default_event_type_is_on_changed(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((16, 16))) - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps({"source": "js", - "panel_id": v._id, "cx": 1.0})}) - assert len(fired) == 1 - - -# ───────────────────────────────────────────────────────────────────────────── -# 8. Practical patterns -# ───────────────────────────────────────────────────────────────────────────── - -class TestPracticalPatterns: - - def test_readout_update_on_drag(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((64, 64))) - wid = v.add_widget("crosshair") - readout = {"value": ""} - - @wid.on_changed - def live(event): - readout["value"] = f"({event.cx:.1f}, {event.cy:.1f})" - - _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=12.5, cy=7.3) - assert readout["value"] == "(12.5, 7.3)" - - def test_expensive_work_gated_on_release(self): - fig, ax = apl.subplots(1, 1) - v = ax.plot(np.zeros(64)) - wid = v.add_vline_widget(x=284.0) - calls = {"cheap": 0, "expensive": 0} - - @wid.on_changed - def live(event): calls["cheap"] += 1 - - @wid.on_release - def done(event): calls["expensive"] += 1 - - for i in range(10): - _simulate_js_event(fig, v, "on_changed", widget_id=wid, x=285.0 + i) - _simulate_js_event(fig, v, "on_release", widget_id=wid, x=285.0) - - assert calls["cheap"] == 10 - assert calls["expensive"] == 1 - - def test_multiple_widgets_separate_callbacks(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - w1 = v.add_widget("circle") - w2 = v.add_widget("crosshair") - log = {w1._id: [], w2._id: []} - - @w1.on_release - def cb1(event): log[w1._id].append(event) - - @w2.on_release - def cb2(event): log[w2._id].append(event) - - _simulate_js_event(fig, v, "on_release", widget_id=w1, cx=5.0, cy=5.0) - assert len(log[w1._id]) == 1 and len(log[w2._id]) == 0 - - _simulate_js_event(fig, v, "on_release", widget_id=w2, cx=8.0, cy=8.0) - assert len(log[w1._id]) == 1 and len(log[w2._id]) == 1 - - def test_widget_attribute_assignment(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) - wid.x = 40.0 - assert wid.x == pytest.approx(40.0) - assert v.to_state_dict()["overlay_widgets"][0]["x"] == pytest.approx(40.0) - - def test_widget_x_readback_after_js_event(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) - _simulate_js_event(fig, v, "on_changed", widget_id=wid, - x=77.0, y=88.0, w=33.0, h=44.0) - assert wid.x == pytest.approx(77.0) - assert wid.y == pytest.approx(88.0) - - def test_3d_rotate_many_frames_one_release(self): - x = y = np.linspace(-1, 1, 5) - X, Y = np.meshgrid(x, y) - fig, ax = apl.subplots(1, 1) - v = ax.plot_surface(X, Y, np.zeros((5, 5))) - frames, final = [], {} - - @v.on_changed - def live(event): frames.append(event.azimuth) - - @v.on_release - def done(event): final["az"] = event.azimuth - - for az in range(0, 50, 5): - _simulate_js_event(fig, v, "on_changed", - azimuth=float(az), elevation=30.0, zoom=1.0) - _simulate_js_event(fig, v, "on_release", - azimuth=45.0, elevation=30.0, zoom=1.0) - - assert len(frames) == 10 - assert final["az"] == pytest.approx(45.0) - + 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" + + 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) From 4263db6fda753456024f10d0eea31573eddab1e5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 09:36:14 -0500 Subject: [PATCH 05/43] test: add stop_propagation repr test and dx/dy assertions to TestEvent --- anyplotlib/tests/test_interactive/test_callbacks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 549655fc..65539b1b 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -75,6 +75,8 @@ def test_all_fields_settable(self): 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") @@ -83,3 +85,7 @@ def test_no_data_dict_attribute(self): 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) From 3db9043af3b20dc0224b56ea2c7f82c9dcc4135f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 09:38:27 -0500 Subject: [PATCH 06/43] refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation --- anyplotlib/callbacks.py | 78 ++++++++++-- .../tests/test_interactive/test_callbacks.py | 111 ++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 7628b75a..fdd00f3a 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -97,13 +97,33 @@ def __repr__(self) -> str: class CallbackRegistry: - """Minimal placeholder — full implementation in Task 2.""" + """Per-object handler store. + + Supports: + - Priority ordering (``order`` kwarg — lower fires first) + - Wildcard ``"*"`` type receives every dispatched event + - ``stop_propagation`` on the event halts remaining handlers + - ``disconnect_fn(fn, *types)`` removes by callback reference + - ``pause_events`` / ``hold_events`` context managers (added in Task 3) + """ def __init__(self) -> None: + # {event_type: [(order, cid, fn), ...]} — sorted by order + self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) self._next_cid: int = 1 - self._entries: dict[int, tuple[str, Callable]] = {} - - def connect(self, event_type: str, fn: Callable) -> int: + # {cid: set[str]} — which types this cid is registered under + self._cid_map: dict[int, set[str]] = {} + # {id(fn): set[int]} — which cids this fn owns + self._fn_map: dict[int, set[int]] = defaultdict(set) + # pause/hold state (populated in Task 3) + self._pause_counts: dict[str, int] = {} + self._hold_counts: dict[str, int] = {} + self._held: deque[Event] = deque() + + # ── registration ───────────────────────────────────────────────────── + + def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: + """Register fn for event_type. Returns integer CID.""" if event_type not in VALID_EVENT_TYPES: raise ValueError( f"Invalid event_type {event_type!r}. " @@ -111,16 +131,54 @@ def connect(self, event_type: str, fn: Callable) -> int: ) cid = self._next_cid self._next_cid += 1 - self._entries[cid] = (event_type, fn) + self._handlers[event_type].append((order, cid, fn)) + self._handlers[event_type].sort(key=lambda t: t[0]) + self._cid_map.setdefault(cid, set()).add(event_type) + self._fn_map[id(fn)].add(cid) return cid def disconnect(self, cid: int) -> None: - self._entries.pop(cid, None) + """Remove handler by CID. Silent if not found.""" + types = self._cid_map.pop(cid, set()) + for et in types: + self._handlers[et] = [ + (o, c, f) for o, c, f in self._handlers[et] if c != cid + ] + for fn_cids in self._fn_map.values(): + fn_cids.discard(cid) + + def disconnect_fn(self, fn: Callable, *types: str) -> None: + """Remove fn from the given types (all types if none given).""" + for cid in list(self._fn_map.get(id(fn), set())): + cid_types = self._cid_map.get(cid, set()) + if not types or cid_types & set(types): + self.disconnect(cid) + + # ── dispatch ───────────────────────────────────────────────────────── def fire(self, event: Event) -> None: - for _cid, (et, fn) in list(self._entries.items()): - if et == event.event_type: - fn(event) + """Dispatch event to matching handlers (respects pause/hold).""" + et = event.event_type + if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: + return + if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: + self._held.append(event) + return + self._dispatch(event) + + def _dispatch(self, event: Event) -> None: + et = event.event_type + specific = list(self._handlers.get(et, [])) + wildcard = list(self._handlers.get("*", [])) + merged = sorted(specific + wildcard, key=lambda t: t[0]) + for _order, _cid, fn in merged: + if event.stop_propagation: + break + fn(event) + + def _flush(self) -> None: + while self._held: + self._dispatch(self._held.popleft()) def __bool__(self) -> bool: - return bool(self._entries) + return any(bool(v) for v in self._handlers.values()) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 65539b1b..339205cf 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -89,3 +89,114 @@ def test_repr_includes_event_type(self): 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"] From 0513bb10afdc5e66ec0ed1c448dc95946bb994fe Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:31:46 -0500 Subject: [PATCH 07/43] feat: add pause_events and hold_events context managers to CallbackRegistry Implement two context manager methods in CallbackRegistry to control event dispatching: pause_events() suppresses events while active, hold_events() buffers events and flushes them on exit. Pause takes precedence over hold for the same event type. --- anyplotlib/callbacks.py | 33 +++++++ .../tests/test_interactive/test_callbacks.py | 91 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index fdd00f3a..0c31ceb9 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -180,5 +180,38 @@ def _flush(self) -> None: while self._held: self._dispatch(self._held.popleft()) + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types while inside this context. + All types are paused when called with no arguments. + Pause wins over hold for the same type.""" + target = types if types else ("*",) + for t in target: + self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._pause_counts[t] -= 1 + if self._pause_counts[t] == 0: + del self._pause_counts[t] + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when the outermost hold exits. + All types are held when called with no arguments.""" + target = types if types else ("*",) + for t in target: + self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._hold_counts[t] -= 1 + if self._hold_counts[t] == 0: + del self._hold_counts[t] + if not self._hold_counts: + self._flush() + def __bool__(self) -> bool: return any(bool(v) for v in self._handlers.values()) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 339205cf..e7287d55 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -200,3 +200,94 @@ def test_connect_same_fn_multiple_types(self): 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 From b03ac758af128e836b220d84789ebf71853a635b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:40:53 -0500 Subject: [PATCH 08/43] feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events --- anyplotlib/callbacks.py | 108 ++++++++++++++++ .../tests/test_interactive/test_callbacks.py | 121 +++++++++++++++++- 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 0c31ceb9..28eceaa2 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -215,3 +215,111 @@ def hold_events(self, *types: str): def __bool__(self) -> bool: return any(bool(v) for v in self._handlers.values()) + + +class _EventMixin: + """Mixin for plot classes and widgets. + + Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / + ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` + in its ``__init__``. + """ + + callbacks: CallbackRegistry + + def add_event_handler( + self, + fn_or_type, + *args, + order: float = 0, + ms: int = 300, + delta: float = 4, + ): + """Register an event handler. Works as a direct call or decorator. + + Direct call:: + + plot.add_event_handler(fn, "pointer_down") + plot.add_event_handler(fn, "pointer_down", "pointer_up") + + Decorator:: + + @plot.add_event_handler("pointer_down") + def handler(event): ... + + @plot.add_event_handler("pointer_settled", ms=400, delta=5) + def on_settle(event): ... + + Parameters + ---------- + fn_or_type : callable or str + Handler function (direct call) or first event type string (decorator). + *args : str + Remaining event type strings. + order : float + Priority. Lower fires first. Default 0. + ms : int + ``pointer_settled`` dwell threshold in milliseconds. Default 300. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + delta : float + ``pointer_settled`` pixel radius. Default 4. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + """ + if callable(fn_or_type): + return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) + else: + all_types = (fn_or_type,) + args + def _decorator(fn: Callable) -> Callable: + self._register(fn, all_types, order=order, ms=ms, delta=delta) + return fn + return _decorator + + def _register( + self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float + ) -> Callable: + has_settled = "pointer_settled" in types + _ms_changed = ms != 300 + _delta_changed = delta != 4 + if (_ms_changed or _delta_changed) and not has_settled: + raise ValueError( + "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" + ) + for event_type in types: + self.callbacks.connect(event_type, fn, order=order) + if has_settled: + self._configure_pointer_settled(ms, delta) + fn._event_types = getattr(fn, "_event_types", set()) | set(types) + return fn + + def remove_handler(self, cid_or_fn, *types: str) -> None: + """Remove a registered handler. + + Parameters + ---------- + cid_or_fn : int or callable + CID returned by ``callbacks.connect()`` or the handler function. + *types : str + If given, only remove from these types. If omitted, remove from all. + """ + if isinstance(cid_or_fn, int): + self.callbacks.disconnect(cid_or_fn) + else: + self.callbacks.disconnect_fn(cid_or_fn, *types) + if not self.callbacks._handlers.get("pointer_settled"): + self._configure_pointer_settled(0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + """Override in plot subclasses to push thresholds to JS.""" + pass + + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types (all types if none given).""" + with self.callbacks.pause_events(*types): + yield + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when context exits.""" + with self.callbacks.hold_events(*types): + yield diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index e7287d55..5dd4a4f4 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -2,7 +2,7 @@ from __future__ import annotations import time import pytest -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin # ── Event dataclass ─────────────────────────────────────────────────────────── @@ -291,3 +291,122 @@ def test_pause_wins_over_hold(self): 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] From a39a6044069a907a854955066c21c330052d4d3d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:48:40 -0500 Subject: [PATCH 09/43] refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields --- anyplotlib/figure/_figure.py | 39 ++++++++++++++++++++-------- anyplotlib/widgets/_base.py | 50 ++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 167ec4c3..e8caff1d 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -8,6 +8,7 @@ import json import pathlib +import time import anywidget import traitlets @@ -15,7 +16,7 @@ from anyplotlib.axes import Axes, InsetAxes from anyplotlib.axes._inset_axes import _plot_kind from anyplotlib.figure._gridspec import SubplotSpec -from anyplotlib.callbacks import Event +from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib._repr_utils import repr_html_iframe _HERE = pathlib.Path(__file__).parent.parent @@ -361,21 +362,18 @@ def _dispatch_event(self, raw: str) -> None: except Exception: return - # Echo guard — Python-originated pushes must not loop back if msg.get("source") == "python": return panel_id = msg.get("panel_id", "") - event_type = msg.get("event_type", "on_changed") + event_type = msg.get("event_type", "pointer_move") widget_id = msg.get("widget_id") - data = {k: v for k, v in msg.items() - if k not in ("source", "panel_id", "event_type", "widget_id")} - # Inset state changes are handled before regular plot dispatch - if event_type == "on_inset_state_change": + # Inset state changes handled before regular plot dispatch + if event_type == "inset_state_change": inset_ax = self._insets_map.get(panel_id) if inset_ax is not None: - new_state = data.get("new_state", "normal") + new_state = msg.get("new_state", "normal") if new_state in ("normal", "minimized", "maximized"): inset_ax._inset_state = new_state self._push_layout() @@ -389,11 +387,32 @@ def _dispatch_event(self, raw: str) -> None: if widget_id and hasattr(plot, "_widgets"): widget = plot._widgets.get(widget_id) if widget is not None: - widget._update_from_js(data, event_type) + widget._update_from_js(msg, event_type) source = widget if hasattr(plot, "callbacks"): - event = Event(event_type=event_type, source=source, data=data) + event = Event( + event_type=event_type, + source=source, + time_stamp=msg.get("time_stamp", time.perf_counter()), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ray=msg.get("ray"), + line_id=msg.get("line_id"), + dwell_ms=msg.get("dwell_ms"), + bar_index=msg.get("bar_index"), + value=msg.get("value"), + x_label=msg.get("x_label"), + group_index=msg.get("group_index"), + dx=msg.get("dx"), + dy=msg.get("dy"), + key=msg.get("key"), + ) plot.callbacks.fire(event) def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 700b64fa..5ae68eff 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -94,7 +94,7 @@ def set(self, _push: bool = True, **kwargs) -> None: self._data.update(kwargs) if _push: self._push_fn() - self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) + self.callbacks.fire(Event("pointer_move", source=self)) def get(self, key: str, default=None): """Get a widget property by name. @@ -220,36 +220,52 @@ def hide(self) -> None: # ── JS → Python sync ────────────────────────────────────────────── - def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: + def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: """Apply incoming JS state without pushing back (avoids echo). + Updates widget ``_data`` with widget-specific state fields from msg, + then fires widget callbacks with a flat Event. + Parameters ---------- - new_data : dict - Updated widget properties from JavaScript. - event_type : str, optional - Type of event that triggered the update. + msg : dict + Full raw event message from JS. + event_type : str + One of the pointer event types (``pointer_move``, ``pointer_up``, + ``pointer_down``). Returns ------- bool - True if any state changed. - - Notes - ----- - Always fires on_release / on_click callbacks even if nothing changed. - Only fires on_changed if state actually changed. + True if any widget state changed. """ + _envelope = { + "source", "panel_id", "event_type", "widget_id", + "time_stamp", "modifiers", "button", "buttons", + "x", "y", "xdata", "ydata", + } changed = False - for k, v in new_data.items(): - if k in ("id", "type"): + for k, v in msg.items(): + if k in ("id", "type") or k in _envelope: continue if self._data.get(k) != v: self._data[k] = v changed = True - # Always fire for settle / click; only fire on_changed when something moved - if changed or event_type in ("on_release", "on_click"): - self.callbacks.fire(Event(event_type, source=self, data=dict(self._data))) + + if changed or event_type in ("pointer_up", "pointer_down"): + event = Event( + event_type=event_type, + source=self, + time_stamp=msg.get("time_stamp", 0.0), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ) + self.callbacks.fire(event) return changed # ── repr ────────────────────────────────────────────────────────── From 18972545c23b5054ea0defe52b5c6ad51a078732 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:57:01 -0500 Subject: [PATCH 10/43] refactor: Plot1D/2D/3D/Bar and PlotMesh adopt _EventMixin, remove old on_* decorators and registered_keys --- anyplotlib/plot1d/_plot1d.py | 136 +++++++++------------------------- anyplotlib/plot1d/_plotbar.py | 79 +++----------------- anyplotlib/plot2d/_plot2d.py | 80 +++----------------- anyplotlib/plot3d/_plot3d.py | 80 +++----------------- 4 files changed, 64 insertions(+), 311 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index a16cb0fa..27a19817 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -12,7 +12,7 @@ from typing import Callable from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -77,16 +77,37 @@ def _filtered(event): fn._cid = cid return fn - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks on *this* line only.""" + def add_event_handler(self, fn_or_type, *args, **kwargs): + """Register a handler scoped to this line only. + + Wraps the plot-level pointer_move / pointer_down handler + with a line_id filter. Only pointer_move and pointer_down + are meaningful on a line handle. + """ target_lid = self._lid + + if callable(fn_or_type): + fn = fn_or_type + types = args + return self._wrap_and_register(fn, types, target_lid, **kwargs) + else: + all_types = (fn_or_type,) + args + def _decorator(fn): + return self._wrap_and_register(fn, all_types, target_lid, **kwargs) + return _decorator + + def _wrap_and_register(self, fn, types, target_lid, **kwargs): + from functools import wraps + @wraps(fn) def _filtered(event): - if event.data.get("line_id") == target_lid: + if event.line_id == target_lid: fn(event) - cid = self._plot.callbacks.connect("on_line_click", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn + _filtered.__wrapped__ = fn + return self._plot.add_event_handler(_filtered, *types, **kwargs) + + def remove_handler(self, cid_or_fn, *types): + """Remove a handler registered via this line handle.""" + self._plot.remove_handler(cid_or_fn, *types) def set_data(self, y: "np.ndarray", x_axis=None) -> None: """Update the y-data (and optionally x-axis) of this overlay line. @@ -138,7 +159,7 @@ def remove(self) -> None: # Plot1D # --------------------------------------------------------------------------- -class Plot1D: +class Plot1D(_EventMixin): """1-D line plot panel returned by :meth:`Axes.plot`. All display state is stored in a plain ``_state`` dict. Every mutation @@ -276,7 +297,8 @@ def __init__(self, data: np.ndarray, "spans": [], "overlay_widgets": [], "markers": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.markers = MarkerRegistry(self._push_markers, @@ -284,6 +306,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + def _push(self) -> None: if self._fig is None: return @@ -730,95 +757,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callback API (Plot1D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag/zoom frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when drag/zoom settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - def on_line_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *any* line on this panel. - - The event carries ``event.line_id`` (``None`` = primary line, - str = overlay), ``event.x``, and ``event.y`` in data coordinates. - For per-line filtering use :meth:`Line1D.on_hover` instead. - """ - cid = self.callbacks.connect("on_line_hover", fn) - fn._cid = cid - return fn - - def on_line_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks *any* line on this panel. - - The event carries the same fields as :meth:`on_line_hover`. - For per-line filtering use :meth:`Line1D.on_click` instead. - """ - cid = self.callbacks.connect("on_line_click", fn) - fn._cid = cid - return fn - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 679a96d3..fefeafc4 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -9,7 +9,7 @@ import numpy as np from typing import Callable -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -75,7 +75,7 @@ def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): return dmin, dmax -class PlotBar: +class PlotBar(_EventMixin): """Bar-chart plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -198,11 +198,17 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "view_x0": 0.0, "view_x1": 1.0, "overlay_widgets": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -391,73 +397,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callbacks - # ------------------------------------------------------------------ - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks a bar. - - The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, - ``value``, ``x_center``, and ``x_label``. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag frame (widget drag or hover).""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when a widget drag settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - self.callbacks.disconnect(cid) - 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 91310ee0..280af9f8 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -10,7 +10,7 @@ from typing import Callable from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -19,7 +19,7 @@ from anyplotlib._utils import _normalize_image, _build_colormap_lut -class Plot2D: +class Plot2D(_EventMixin): """2-D image plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -117,7 +117,8 @@ def __init__(self, data: np.ndarray, "center_y": 0.5, "overlay_widgets": [], "markers": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, # Transparent mask overlay (set via set_overlay_mask) "overlay_mask_b64": "", "overlay_mask_color": "#ff4444", @@ -132,6 +133,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 @@ -381,74 +387,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callback API (Plot2D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every pan/zoom/drag frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when pan/zoom/drag settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 4033defb..48638c7b 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -10,7 +10,7 @@ import numpy as np -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib._utils import _arr_to_b64, _build_colormap_lut @@ -25,7 +25,7 @@ def _triangulate_grid(rows: int, cols: int) -> list: return faces -class Plot3D: +class Plot3D(_EventMixin): """3-D plot panel. Supports three geometry types matching matplotlib's 3-D Axes API: @@ -125,10 +125,16 @@ def __init__(self, geom_type: str, "elevation": float(elevation), "zoom": float(zoom), "data_bounds": data_bounds, - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.callbacks = CallbackRegistry() + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -138,74 +144,6 @@ def _push(self) -> None: def to_state_dict(self) -> dict: return dict(self._state) - # ------------------------------------------------------------------ - # Callback API (Plot3D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every rotation/zoom frame.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when rotation/zoom settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ From c6dbaa68fed305848169c8ceb8dbec94384a373c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:26:31 -0500 Subject: [PATCH 11/43] refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect; update tests - Widget base class now inherits _EventMixin for add_event_handler/remove_handler API - Removed on_changed, on_release, on_click decorator methods and disconnect from Widget - Fixed _update_from_js envelope: removed x, y, xdata, ydata so widget position fields named x/y are properly updated from JS events - Added on_line_click, on_line_hover to VALID_EVENT_TYPES in callbacks.py to support Line1D event routing from the JS dispatcher - Updated test_widgets.py: all tests now use add_event_handler/remove_handler, new pointer_* event types, and read widget state from widget._data not event.data - Updated test_plotbar.py: callback tests use add_event_handler/remove_handler and pointer_down/pointer_move event types with flat Event fields --- anyplotlib/callbacks.py | 5 +- .../tests/test_interactive/test_widgets.py | 219 +++++++++--------- anyplotlib/tests/test_plot1d/test_plotbar.py | 59 +++-- anyplotlib/widgets/_base.py | 92 ++------ 4 files changed, 156 insertions(+), 219 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 28eceaa2..a9ddd25c 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,7 +24,10 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", + "key_down", "key_up", + # Plot1D line-specific events (forwarded verbatim from JS) + "on_line_click", "on_line_hover", + "*", }) diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index 218727ca..f2a12bd7 100644 --- a/anyplotlib/tests/test_interactive/test_widgets.py +++ b/anyplotlib/tests/test_interactive/test_widgets.py @@ -6,8 +6,8 @@ Covers: * Widget creation, attribute access, set(), to_dict(), __setattr__ - * on_changed / on_release / on_click decorator + disconnect - * _update_from_js — always fires for on_release/on_click + * add_event_handler / remove_handler (new _EventMixin API) + * _update_from_js — always fires for pointer_up/pointer_down * Widget visibility — hide() / show() * Plot2D / Plot1D widget integration (add / remove / list / clear) * Figure event_json dispatch (JS→Python path via _simulate_js_event) @@ -139,60 +139,62 @@ class TestWidgetCallbacks: def test_on_changed_fires(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(w.x), "pointer_move") w.set(x=42) assert results == [42.0] def test_on_changed_event_source_is_widget(self): w = CircleWidget(lambda: None, cx=0, cy=0, r=5) received = [] - w.on_changed(lambda event: received.append(event.source)) + w.add_event_handler(lambda event: received.append(event.source), "pointer_move") w.set(cx=10) assert received[0] is w def test_multiple_callbacks(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) a, b = [], [] - w.on_changed(lambda event: a.append(1)) - w.on_changed(lambda event: b.append(1)) + w.add_event_handler(lambda event: a.append(1), "pointer_move") + w.add_event_handler(lambda event: b.append(1), "pointer_move") w.set(x=1) assert len(a) == 1 and len(b) == 1 def test_disconnect_by_fn(self): - """Disconnecting using the function object (which has ._cid) should work.""" + """Disconnecting using the function object should work.""" w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") w.set(x=1); assert len(results) == 1 - w.disconnect(fn) # fn._cid is used + w.remove_handler(fn) w.set(x=2); assert len(results) == 1 def test_disconnect_by_cid(self): - """Disconnecting using the integer CID should also work.""" + """Disconnecting using remove_handler with a callable should work.""" w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) - w.disconnect(fn._cid) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") + w.remove_handler(fn) w.set(x=2) assert results == [] def test_disconnect_nonexistent_silent(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) - w.disconnect(9999) + w.remove_handler(9999) def test_on_release_decorator(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_release(lambda event: results.append(event.event_type)) - w.callbacks.fire(Event("on_release", w, {"x": 5.0})) - assert results == ["on_release"] + w.add_event_handler(lambda event: results.append(event.event_type), "pointer_up") + w.callbacks.fire(Event("pointer_up", w)) + assert results == ["pointer_up"] def test_on_click_decorator(self): w = CircleWidget(lambda: None, cx=0, cy=0, r=5) results = [] - w.on_click(lambda event: results.append(event.event_type)) - w.callbacks.fire(Event("on_click", w, {})) - assert results == ["on_click"] + w.add_event_handler(lambda event: results.append(event.event_type), "pointer_down") + w.callbacks.fire(Event("pointer_down", w)) + assert results == ["pointer_down"] class TestWidgetUpdateFromJs: @@ -209,32 +211,32 @@ def test_update_returns_false_on_no_change(self): def test_update_fires_on_changed_when_changed(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(event.x), "pointer_move") w._update_from_js({"x": 99.0}) assert results == [99.0] def test_update_does_not_fire_on_changed_if_unchanged(self): w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10, color="#abc") results = [] - w.on_changed(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_move") w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0, "color": "#abc"}) assert results == [] def test_update_always_fires_on_release(self): - """on_release fires even when nothing changed (drag ended in place).""" + """pointer_up fires even when nothing changed (drag ended in place).""" w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10) results = [] - w.on_release(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_up") w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0}, - event_type="on_release") + event_type="pointer_up") assert results == [1] def test_update_always_fires_on_click(self): - """on_click fires even when nothing changed.""" + """pointer_down fires even when nothing changed.""" w = CrosshairWidget(lambda: None, cx=16.0, cy=16.0) results = [] - w.on_click(lambda event: results.append(1)) - w._update_from_js({"cx": 16.0, "cy": 16.0}, event_type="on_click") + w.add_event_handler(lambda event: results.append(1), "pointer_down") + w._update_from_js({"cx": 16.0, "cy": 16.0}, event_type="pointer_down") assert results == [1] def test_id_and_type_ignored(self): @@ -392,35 +394,35 @@ def test_rectangle_drag_fires_on_changed(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append((event.x, event.y))) + w.add_event_handler(lambda event: results.append((event.x, event.y)), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0, y=60.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=50.0, y=60.0) assert len(results) == 1 assert results[0] == (50.0, 60.0) assert w.x == 50.0 and w.y == 60.0 def test_no_change_no_on_changed_callback(self): - """on_changed must NOT fire when nothing actually changed.""" + """pointer_move must NOT fire when nothing actually changed.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=10.0, y=10.0, w=20.0, h=20.0) assert results == [] def test_on_release_always_fires(self): - """on_release fires even when position didn't change.""" + """pointer_up fires even when position didn't change.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_release(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_up") - _simulate_js_event(fig, v, "on_release", widget_id=w, + _simulate_js_event(fig, v, "pointer_up", widget_id=w, x=10.0, y=10.0, w=20.0, h=20.0) assert len(results) == 1 @@ -429,42 +431,42 @@ def test_on_click_fires(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("crosshair", cx=16.0, cy=16.0) results = [] - w.on_click(lambda event: results.append(event.cx)) + w.add_event_handler(lambda event: results.append(w.cx), "pointer_down") - _simulate_js_event(fig, v, "on_click", widget_id=w, cx=16.0, cy=16.0) + _simulate_js_event(fig, v, "pointer_down", widget_id=w, cx=16.0, cy=16.0) assert len(results) == 1 assert results[0] == pytest.approx(16.0) def test_on_click_line1d_overlay_fires(self): - """Line1D.on_click fires when JS sends on_line_click with the matching line_id.""" + """Line1D.add_event_handler fires when JS sends on_line_click with the matching line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#ff0000") results = [] - line.on_click(lambda event: results.append(event.line_id)) + line.add_event_handler(lambda event: results.append(event.line_id), "on_line_click") _simulate_js_event(fig, v, "on_line_click", line_id=line.id) assert len(results) == 1 assert results[0] == line.id def test_on_click_line1d_primary_fires(self): - """Line1D.on_click on the primary line fires when JS sends on_line_click with no line_id.""" + """Line1D.add_event_handler on the primary line fires when JS sends on_line_click with no line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) results = [] - v.line.on_click(lambda event: results.append(1)) + v.line.add_event_handler(lambda event: results.append(1), "on_line_click") - # No line_id in payload → event.data.get("line_id") is None → matches primary + # No line_id in payload → event.line_id is None → matches primary _simulate_js_event(fig, v, "on_line_click") assert len(results) == 1 def test_on_click_line1d_wrong_id_no_fire(self): - """Line1D.on_click does NOT fire when the JS event carries a different line_id.""" + """Line1D.add_event_handler does NOT fire when the JS event carries a different line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#00ff00") results = [] - line.on_click(lambda event: results.append(1)) + line.add_event_handler(lambda event: results.append(1), "on_line_click") _simulate_js_event(fig, v, "on_line_click", line_id="completely-wrong-id") assert results == [] @@ -474,19 +476,19 @@ def test_circle_drag(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=16, cy=16, r=5) results = [] - w.on_changed(lambda event: results.append(event.cx)) + w.add_event_handler(lambda event: results.append(w.cx), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=25.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, cx=25.0) assert results == [25.0] def test_python_set_does_not_echo(self): - """Python widget.set() triggers on_changed once (from set itself), + """Python widget.set() triggers pointer_move once (from set itself), but the subsequent event_json push must NOT re-fire callbacks.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append("cb")) + w.add_event_handler(lambda event: results.append("cb"), "pointer_move") w.set(x=99) assert results == ["cb"] # one fire from set() @@ -501,10 +503,10 @@ def test_multi_widget_only_changed_fires(self): w1 = v.add_widget("circle", cx=10, cy=10, r=5) w2 = v.add_widget("rectangle", x=0, y=0, w=10, h=10) r1, r2 = [], [] - w1.on_changed(lambda e: r1.append(1)) - w2.on_changed(lambda e: r2.append(1)) + w1.add_event_handler(lambda e: r1.append(1), "pointer_move") + w2.add_event_handler(lambda e: r2.append(1), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w2, x=50.0, y=50.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w2, x=50.0, y=50.0) assert r1 == [] assert len(r2) == 1 @@ -515,10 +517,10 @@ def test_multi_panel_routing(self): w1 = v1.add_widget("circle", cx=8, cy=8, r=3) w2 = v2.add_widget("circle", cx=8, cy=8, r=3) r1, r2 = [], [] - w1.on_changed(lambda e: r1.append(1)) - w2.on_changed(lambda e: r2.append(1)) + w1.add_event_handler(lambda e: r1.append(1), "pointer_move") + w2.add_event_handler(lambda e: r2.append(1), "pointer_move") - _simulate_js_event(fig, v1, "on_changed", widget_id=w1, cx=12.0) + _simulate_js_event(fig, v1, "pointer_move", widget_id=w1, cx=12.0) assert len(r1) == 1 and r2 == [] def test_1d_vline_drag(self): @@ -526,9 +528,9 @@ def test_1d_vline_drag(self): v = ax.plot(np.zeros(64)) w = v.add_vline_widget(x=10.0) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(w.x), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=30.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=30.0) assert results == [30.0] def test_1d_range_drag(self): @@ -536,9 +538,9 @@ def test_1d_range_drag(self): v = ax.plot(np.zeros(64)) w = v.add_range_widget(x0=10, x1=20) results = [] - w.on_changed(lambda event: results.append((event.x0, event.x1))) + w.add_event_handler(lambda event: results.append((w.x0, w.x1)), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x0=15.0, x1=25.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x0=15.0, x1=25.0) assert results == [(15.0, 25.0)] def test_disconnect_prevents_callback(self): @@ -546,10 +548,11 @@ def test_disconnect_prevents_callback(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) - w.disconnect(fn) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") + w.remove_handler(fn) - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=50.0) assert results == [] def test_widget_state_synced_after_js_event(self): @@ -557,7 +560,7 @@ def test_widget_state_synced_after_js_event(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) - _simulate_js_event(fig, v, "on_changed", widget_id=w, + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=77.0, y=88.0, w=33.0, h=44.0) assert w.x == 77.0 and w.y == 88.0 and w.w == 33.0 and w.h == 44.0 @@ -567,7 +570,7 @@ def test_widget_x_readback_after_js_event(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=0.0, cy=0.0, r=5.0) - _simulate_js_event(fig, v, "on_release", widget_id=w, cx=20.0, cy=30.0) + _simulate_js_event(fig, v, "pointer_up", widget_id=w, cx=20.0, cy=30.0) assert w.cx == pytest.approx(20.0) assert w.cy == pytest.approx(30.0) @@ -621,15 +624,15 @@ def test_drag_rectangle_updates_fft(self): initial_b64 = v_fft._state["image_b64"] updates = [] - @rect.on_changed + @rect.add_event_handler("pointer_move") def on_rect_changed(event): log_mag, freq_x, freq_y = self._compute_fft( - img, event.x, event.y, event.w, event.h) + img, rect.x, rect.y, rect.w, rect.h) v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") - updates.append({"x": event.x, "y": event.y, - "w": event.w, "h": event.h}) + updates.append({"x": rect.x, "y": rect.y, + "w": rect.w, "h": rect.h}) - _simulate_js_event(fig, v_real, "on_changed", widget_id=rect, + _simulate_js_event(fig, v_real, "pointer_move", widget_id=rect, x=0.0, y=0.0, w=48.0, h=48.0) assert len(updates) == 1 @@ -643,10 +646,10 @@ def test_multiple_drags_fire_multiple_callbacks(self): v = ax.imshow(img) rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) count = [0] - rect.on_changed(lambda e: count.__setitem__(0, count[0] + 1)) + rect.add_event_handler(lambda e: count.__setitem__(0, count[0] + 1), "pointer_move") for i in range(5): - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=float(i)) # Only fires when something actually changed — first fire is from x=0 # (which equals the initial value, no change), then 1,2,3,4 = 4 fires @@ -657,13 +660,14 @@ def test_drag_then_disconnect(self): v = ax.imshow(np.zeros((32, 32))) rect = v.add_widget("rectangle", x=0, y=0, w=10, h=10) results = [] - fn = rect.on_changed(lambda e: results.append(1)) + fn = lambda e: results.append(1) + rect.add_event_handler(fn, "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=5.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=5.0) assert len(results) == 1 - rect.disconnect(fn) - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=10.0) + rect.remove_handler(fn) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=10.0) assert len(results) == 1 def test_on_release_after_drags(self): @@ -674,12 +678,12 @@ def test_on_release_after_drags(self): rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) drag_count = [0]; release_count = [0] - rect.on_changed(lambda e: drag_count.__setitem__(0, drag_count[0] + 1)) - rect.on_release(lambda e: release_count.__setitem__(0, release_count[0] + 1)) + rect.add_event_handler(lambda e: drag_count.__setitem__(0, drag_count[0] + 1), "pointer_move") + rect.add_event_handler(lambda e: release_count.__setitem__(0, release_count[0] + 1), "pointer_up") for i in range(1, 6): - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) - _simulate_js_event(fig, v, "on_release", widget_id=rect, x=5.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=float(i)) + _simulate_js_event(fig, v, "pointer_up", widget_id=rect, x=5.0) assert drag_count[0] == 5 assert release_count[0] == 1 @@ -727,18 +731,18 @@ def test_show_calls_push(self): assert len(pushed) == 1 def test_hide_does_not_fire_on_changed(self): - """hide() must NOT fire on_changed callbacks.""" + """hide() must NOT fire pointer_move callbacks.""" w = CircleWidget(lambda: None, cx=0, cy=0, r=5) fired = [] - w.on_changed(lambda e: fired.append(1)) + w.add_event_handler(lambda e: fired.append(1), "pointer_move") w.hide() assert fired == [] def test_show_does_not_fire_on_changed(self): - """show() must NOT fire on_changed callbacks.""" + """show() must NOT fire pointer_move callbacks.""" w = CircleWidget(lambda: None, cx=0, cy=0, r=5) fired = [] - w.on_changed(lambda e: fired.append(1)) + w.add_event_handler(lambda e: fired.append(1), "pointer_move") w.hide() w.show() assert fired == [] @@ -783,10 +787,10 @@ def test_hide_then_show_widget_still_draggable(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=10, cy=10, r=5) fired = [] - w.on_changed(lambda e: fired.append(e.cx)) + w.add_event_handler(lambda e: fired.append(w.cx), "pointer_move") w.hide() w.show() - _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=20.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, cx=20.0) assert fired == [20.0] def test_hide_show_1d_range_widget(self): @@ -875,14 +879,14 @@ def toggle(self): self._active = True def _wire(self): - @self._pt.on_changed + @self._pt.add_event_handler("pointer_move") def _peak_moved(event): if self._syncing: return self._syncing = True try: - self.amp = event.data["y"] - self.mu = event.data["x"] + self.amp = self._pt.y + self.mu = self._pt.x self._rng_w.set(x0=self.mu - self.sigma, x1=self.mu + self.sigma) self.line.set_data(self.component_y()) @@ -890,13 +894,13 @@ def _peak_moved(event): finally: self._syncing = False - @self._rng_w.on_changed + @self._rng_w.add_event_handler("pointer_move") def _range_moved(event): if self._syncing: return self._syncing = True try: - x0, x1 = event.data["x0"], event.data["x1"] + x0, x1 = self._rng_w.x0, self._rng_w.x1 self.mu = (x0 + x1) / 2.0 self.sigma = abs(x1 - x0) / 2.0 self._pt.set(x=self.mu) @@ -1042,7 +1046,7 @@ def test_point_drag_updates_component_amp_and_mu(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=3.5, y=0.9) assert ctrl.mu == pytest.approx(3.5) @@ -1055,7 +1059,7 @@ def test_point_drag_updates_range_widget_position(self): ctrl.toggle() original_sigma = ctrl.sigma - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.0, y=1.0) expected_x0 = 4.0 - original_sigma @@ -1070,7 +1074,7 @@ def test_point_drag_updates_component_line_data(self): ctrl.toggle() old_data = _gaussian(x, ctrl.amp, ctrl.mu, ctrl.sigma).copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.0, y=0.8) # Find the extra_line entry for comp_lines[0] @@ -1086,7 +1090,7 @@ def test_point_drag_triggers_refit(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=3.5, y=0.9) assert refit_calls[0] >= 1 @@ -1101,7 +1105,7 @@ def test_point_drag_updates_fit_line(self): entry_before = next(e for e in plot._state["extra_lines"] if e["id"] == lid) old_fit = entry_before["data"].copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.5, y=0.5) entry_after = next(e for e in plot._state["extra_lines"] if e["id"] == lid) @@ -1115,7 +1119,7 @@ def test_range_drag_updates_mu_and_sigma(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) assert ctrl.mu == pytest.approx(3.5) @@ -1127,7 +1131,7 @@ def test_range_drag_recentres_point_widget(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.0, x1=5.0) assert ctrl._pt.x == pytest.approx(3.5) @@ -1138,7 +1142,7 @@ def test_range_drag_updates_component_line_data(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) lid = ctrl.line.id @@ -1152,7 +1156,7 @@ def test_range_drag_triggers_refit(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) assert refit_calls[0] >= 1 @@ -1167,7 +1171,7 @@ def test_two_controllers_independent(self): old_mu1 = ctrls[1].mu - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrls[0]._pt, x=3.8, y=1.1) assert ctrls[1].mu == pytest.approx(old_mu1) @@ -1196,8 +1200,8 @@ def test_line_click_activates_controller(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - # Wire up the line.on_click handler (same as the example) - @ctrl.line.on_click + # Wire up the line click handler (same as the example) + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1217,7 +1221,7 @@ def test_line_click_twice_hides_widgets(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1242,7 +1246,7 @@ def test_line_click_wrong_line_id_no_toggle(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1259,12 +1263,12 @@ def _clicked(event, c=ctrl): # ── example-mirroring tests ─────────────────────────────────────────────── def _build_with_click_handlers(self): - """Same as _build() but wires line.on_click → ctrl.toggle() for both + """Same as _build() but wires line click → ctrl.toggle() for both components, exactly as the for-loop in plot_interactive_fitting.py.""" result = self._build() _, _, controllers, *_ = result for ctrl in controllers: - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() return result @@ -1357,7 +1361,7 @@ def test_example_click_then_drag_updates_fit(self): e for e in plot._state["extra_lines"] if e["id"] == lid )["data"].copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrls[0]._pt, x=4.0, y=0.8) fit_after = next( @@ -1374,6 +1378,3 @@ def test_example_wrong_line_id_not_clickable(self): _simulate_js_event(fig, plot, "on_line_click", line_id="no-such-line") assert ctrls[0]._active is False assert ctrls[1]._active is False - - - diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 3f5dbe86..11fd9246 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -469,44 +469,43 @@ def test_has_callback_registry(self): def test_on_click_decorator_returns_fn(self): p = _make_bar() fn = lambda e: None - assert p.on_click(fn) is fn + result = p.add_event_handler(fn, "pointer_down") + assert result is fn - def test_on_click_stamps_cid(self): + def test_on_click_stamps_event_types(self): p = _make_bar() - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): pass - assert hasattr(cb, "_cid") and isinstance(cb._cid, int) + assert hasattr(cb, "_event_types") and "pointer_down" in cb._event_types def test_on_click_fires(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, {"bar_index": 2, "value": 3.0, - "group_index": 0, "group_value": 3.0})) + p.callbacks.fire(Event("pointer_down", p, bar_index=2, value=3.0, + group_index=0)) assert len(fired) == 1 def test_on_click_event_data_with_group(self): p = _make_bar([10, 20, 30]) fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, - {"bar_index": 1, "value": 20.0, - "group_index": 0, "group_value": 20.0, - "x_center": 1.0, "x_label": "B"})) + p.callbacks.fire(Event("pointer_down", p, + bar_index=1, value=20.0, + group_index=0, + x_label="B")) ev = fired[0] assert ev.bar_index == 1 assert ev.value == pytest.approx(20.0) assert ev.group_index == 0 - assert ev.group_value == pytest.approx(20.0) - assert ev.x_center == pytest.approx(1.0) assert ev.x_label == "B" def test_on_click_grouped_event(self): @@ -514,58 +513,58 @@ def test_on_click_grouped_event(self): p = ax.bar(["A", "B"], [[1, 10], [2, 20]]) fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, - {"bar_index": 1, "group_index": 1, - "value": 20.0, "group_value": 20.0, - "x_center": 1.0, "x_label": "B"})) + p.callbacks.fire(Event("pointer_down", p, + bar_index=1, group_index=1, + value=20.0, + x_label="B")) assert fired[0].group_index == 1 - assert fired[0].group_value == pytest.approx(20.0) + assert fired[0].value == pytest.approx(20.0) def test_on_changed_fires(self): p = _make_bar() fired = [] - @p.on_changed + @p.add_event_handler("pointer_move") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_changed", p, {})) + p.callbacks.fire(Event("pointer_move", p)) assert len(fired) == 1 def test_on_click_not_fired_by_on_changed(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_changed", p, {})) + p.callbacks.fire(Event("pointer_move", p)) assert fired == [] def test_disconnect(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.disconnect(cb._cid) - p.callbacks.fire(Event("on_click", p, {})) + p.remove_handler(cb) + p.callbacks.fire(Event("pointer_down", p)) assert fired == [] def test_multiple_on_click_handlers(self): p = _make_bar() log = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb1(event): log.append("a") - @p.on_click + @p.add_event_handler("pointer_down") def cb2(event): log.append("b") - p.callbacks.fire(Event("on_click", p, {})) + p.callbacks.fire(Event("pointer_down", p)) assert sorted(log) == ["a", "b"] diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 5ae68eff..e73f2301 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -7,10 +7,10 @@ from __future__ import annotations import uuid as _uuid from typing import Any, Callable -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin -class Widget: +class Widget(_EventMixin): """Base class for all overlay widgets. Provides attribute-based state access, callbacks for interaction events, @@ -28,10 +28,15 @@ class Widget: Attributes ---------- callbacks : CallbackRegistry - Event callback registry. Register handlers via: - - ``@widget.on_changed`` — fires on every drag frame - - ``@widget.on_release`` — fires once when drag settles - - ``@widget.on_click`` — fires on click event + Event callback registry. Register handlers via + ``widget.add_event_handler(fn, "pointer_move")`` or as a decorator: + ``@widget.add_event_handler("pointer_move")``. + + Common event types: + + - ``"pointer_move"`` — fires on every drag frame + - ``"pointer_up"`` — fires once when drag settles + - ``"pointer_down"`` — fires on click/press event """ def __init__(self, wtype: str, push_fn: Callable, **kwargs): @@ -123,76 +128,6 @@ def to_dict(self) -> dict: """ return dict(self._data) - # ── callback decorator methods ──────────────────────────────────── - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on every drag frame. - - Use this for high-frequency updates (keep handler fast). - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: register fn to fire once when drag settles. - - Use this for expensive operations triggered after user stops dragging. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on widget click. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def disconnect(self, cid) -> None: - """Remove the callback registered under *cid*. - - Parameters - ---------- - cid : int or Callable - Either the integer CID returned by ``callbacks.connect()``, - or the decorated function itself (carries a ``._cid`` attribute). - """ - if callable(cid) and hasattr(cid, "_cid"): - cid = cid._cid - self.callbacks.disconnect(cid) - # ── visibility ──────────────────────────────────────────────────────── @property @@ -205,7 +140,7 @@ def visible(self, value: bool) -> None: self.show() if value else self.hide() def show(self) -> None: - """Show the widget. Does not fire ``on_changed`` callbacks.""" + """Show the widget. Does not fire ``pointer_move`` callbacks.""" self._data["visible"] = True self._push_fn() @@ -213,7 +148,7 @@ def hide(self) -> None: """Hide the widget without removing it or its callbacks. Call :meth:`show` to make it visible again. - Does not fire ``on_changed`` callbacks. + Does not fire ``pointer_move`` callbacks. """ self._data["visible"] = False self._push_fn() @@ -242,7 +177,6 @@ def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: _envelope = { "source", "panel_id", "event_type", "widget_id", "time_stamp", "modifiers", "button", "buttons", - "x", "y", "xdata", "ydata", } changed = False for k, v in msg.items(): From 5c98bbd32216e878e90d5d04652a2e763502ea49 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:31:26 -0500 Subject: [PATCH 12/43] fix: remove on_line_click/on_line_hover from VALID_EVENT_TYPES; use pointer_down in line tests --- anyplotlib/callbacks.py | 5 +- .../tests/test_interactive/test_widgets.py | 48 +++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index a9ddd25c..28eceaa2 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,10 +24,7 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", - # Plot1D line-specific events (forwarded verbatim from JS) - "on_line_click", "on_line_hover", - "*", + "key_down", "key_up", "*", }) diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index f2a12bd7..17c6dd23 100644 --- a/anyplotlib/tests/test_interactive/test_widgets.py +++ b/anyplotlib/tests/test_interactive/test_widgets.py @@ -438,26 +438,26 @@ def test_on_click_fires(self): assert results[0] == pytest.approx(16.0) def test_on_click_line1d_overlay_fires(self): - """Line1D.add_event_handler fires when JS sends on_line_click with the matching line_id.""" + """Line1D.add_event_handler fires when JS sends pointer_down with the matching line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#ff0000") results = [] - line.add_event_handler(lambda event: results.append(event.line_id), "on_line_click") + line.add_event_handler(lambda event: results.append(event.line_id), "pointer_down") - _simulate_js_event(fig, v, "on_line_click", line_id=line.id) + _simulate_js_event(fig, v, "pointer_down", line_id=line.id) assert len(results) == 1 assert results[0] == line.id def test_on_click_line1d_primary_fires(self): - """Line1D.add_event_handler on the primary line fires when JS sends on_line_click with no line_id.""" + """Line1D.add_event_handler on the primary line fires when JS sends pointer_down with no line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) results = [] - v.line.add_event_handler(lambda event: results.append(1), "on_line_click") + v.line.add_event_handler(lambda event: results.append(1), "pointer_down") # No line_id in payload → event.line_id is None → matches primary - _simulate_js_event(fig, v, "on_line_click") + _simulate_js_event(fig, v, "pointer_down") assert len(results) == 1 def test_on_click_line1d_wrong_id_no_fire(self): @@ -466,9 +466,9 @@ def test_on_click_line1d_wrong_id_no_fire(self): v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#00ff00") results = [] - line.add_event_handler(lambda event: results.append(1), "on_line_click") + line.add_event_handler(lambda event: results.append(1), "pointer_down") - _simulate_js_event(fig, v, "on_line_click", line_id="completely-wrong-id") + _simulate_js_event(fig, v, "pointer_down", line_id="completely-wrong-id") assert results == [] def test_circle_drag(self): @@ -1201,15 +1201,15 @@ def test_line_click_activates_controller(self): ctrl = ctrls[0] # Wire up the line click handler (same as the example) - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() - # Simulate JS sending an on_line_click event for comp_lines[0] + # Simulate JS sending a pointer_down event for comp_lines[0] fig._on_event({"new": __import__("json").dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": ctrl.line.id, })}) @@ -1221,7 +1221,7 @@ def test_line_click_twice_hides_widgets(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1231,7 +1231,7 @@ def _click(): fig._on_event({"new": _json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": ctrl.line.id, })}) @@ -1246,7 +1246,7 @@ def test_line_click_wrong_line_id_no_toggle(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1254,7 +1254,7 @@ def _clicked(event, c=ctrl): fig._on_event({"new": _json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": "completely-wrong-id", })}) @@ -1268,7 +1268,7 @@ def _build_with_click_handlers(self): result = self._build() _, _, controllers, *_ = result for ctrl in controllers: - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() return result @@ -1280,7 +1280,7 @@ def test_example_both_lines_clickable(self): self._build_with_click_handlers() # Click component 0 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert ctrls[0]._active is True assert ctrls[0]._pt is not None assert ctrls[0]._rng_w is not None @@ -1289,7 +1289,7 @@ def test_example_both_lines_clickable(self): assert ctrls[1]._active is False # other controller untouched # Click component 1 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[1].line.id) assert ctrls[1]._active is True assert ctrls[1]._pt.visible is True assert ctrls[1]._rng_w.visible is True @@ -1301,10 +1301,10 @@ def test_example_click_shows_widgets_registered_in_plot(self): assert len(plot.list_widgets()) == 0 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert len(plot.list_widgets()) == 2 # PointWidget + RangeWidget - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[1].line.id) assert len(plot.list_widgets()) == 4 # +2 for ctrl[1] def test_example_second_click_hides_widgets(self): @@ -1313,7 +1313,7 @@ def test_example_second_click_hides_widgets(self): self._build_with_click_handlers() def _click(ctrl): - _simulate_js_event(fig, plot, "on_line_click", + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrl.line.id) _click(ctrls[0]) # show @@ -1331,7 +1331,7 @@ def test_example_third_click_reshows_same_widgets(self): self._build_with_click_handlers() def _click(ctrl): - _simulate_js_event(fig, plot, "on_line_click", + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrl.line.id) _click(ctrls[0]) @@ -1353,7 +1353,7 @@ def test_example_click_then_drag_updates_fit(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = \ self._build_with_click_handlers() - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert ctrls[0]._active is True lid = fit_line.id @@ -1375,6 +1375,6 @@ def test_example_wrong_line_id_not_clickable(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = \ self._build_with_click_handlers() - _simulate_js_event(fig, plot, "on_line_click", line_id="no-such-line") + _simulate_js_event(fig, plot, "pointer_down", line_id="no-such-line") assert ctrls[0]._active is False assert ctrls[1]._active is False From 2586ee377db9ec7f85d26f61aeac4270fcd30817 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:52:12 -0500 Subject: [PATCH 13/43] fix: update inset tests to use renamed inset_state_change event type --- anyplotlib/tests/test_layouts/test_inset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anyplotlib/tests/test_layouts/test_inset.py b/anyplotlib/tests/test_layouts/test_inset.py index 1f4d1e0c..7c67bd33 100644 --- a/anyplotlib/tests/test_layouts/test_inset.py +++ b/anyplotlib/tests/test_layouts/test_inset.py @@ -210,7 +210,7 @@ def test_on_event_inset_state_change(): fig.event_json = json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_inset_state_change", + "event_type": "inset_state_change", "new_state": "minimized", }) @@ -227,7 +227,7 @@ def test_on_event_inset_state_restore_via_event(): fig.event_json = json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_inset_state_change", + "event_type": "inset_state_change", "new_state": "normal", }) assert inset.inset_state == "normal" From bcfc06ac2791970e515f9452c396f54a015df8b7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 13:34:41 -0500 Subject: [PATCH 14/43] feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event types and fields; remove registered_keys filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename event type strings: on_changed→pointer_move, on_release→pointer_up, on_click/on_line_click→pointer_down, on_line_hover→pointer_move, on_key→key_down, on_inset_state_change→inset_state_change - Rename payload fields: phys_x→xdata, phys_y→ydata, mouse_x→x, mouse_y→y - Add _modifiers() and _pointerFields() helpers; spread into all pointer events - Add new listeners: pointer_enter/leave (mouseenter/mouseleave), double_click (dblclick), wheel, key_up for all plot types (1d, 2d, 3d, bar) - Remove registered_keys guard from all keydown handlers; all keys now forwarded unconditionally to Python while built-in shortcuts still run normally - Update test_interaction.py assertions to use new event type names --- anyplotlib/figure_esm.js | 396 +++++++++--------- .../tests/test_layouts/test_interaction.py | 70 ++-- 2 files changed, 237 insertions(+), 229 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index a54441c6..1d06cec6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -702,7 +702,7 @@ function render({ model, el }) { _applyAllInsetStates(layout); } } catch(_) {} - _emitEvent(p.id, 'on_inset_state_change', null, { new_state: newState }); + _emitEvent(p.id, 'inset_state_change', null, { new_state: newState }); } // ── _applyAllInsetStates ────────────────────────────────────────────────── @@ -1684,7 +1684,7 @@ function render({ model, el }) { } // ── event emission helper (module-scope: accessible to all attach fns) ── - // eventType: 'on_changed' | 'on_release' | 'on_click' + // eventType: any pointer_* or key_* event type string function _emitEvent(panelId, eventType, widgetId, extraData) { const payload = Object.assign( { source: 'js', panel_id: panelId, event_type: eventType, @@ -1695,12 +1695,28 @@ function render({ model, el }) { model.save_changes(); } + function _modifiers(e) { + const mods = []; + if (e.ctrlKey) mods.push("ctrl"); + if (e.shiftKey) mods.push("shift"); + if (e.altKey) mods.push("alt"); + if (e.metaKey) mods.push("meta"); + return mods; + } + + function _pointerFields(e) { + return { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + button: e.button != null ? e.button : null, + buttons: e.buttons ?? 0, + }; + } + 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(() => { @@ -1727,18 +1743,19 @@ function render({ model, el }) { p.state.elevation = Math.max(-89, Math.min(89, dragStart.el - dy * 0.5)); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_changed', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'pointer_move', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); e.preventDefault(); }); - document.addEventListener('mouseup', () => { - clearTimeout(_settledTimer); _settledTimer = null; + document.addEventListener('mouseup', (e) => { if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_release', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'pointer_up', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); _scheduleCommit(); }); @@ -1747,8 +1764,15 @@ function render({ model, el }) { p.state.zoom = Math.max(0.1, Math.min(10, p.state.zoom * (e.deltaY > 0 ? 0.9 : 1.1))); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_changed', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'wheel', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + dx: e.deltaX, dy: e.deltaY, + }); + _emitEvent(p.id, 'pointer_move', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); _scheduleCommit(); }, { passive: false }); @@ -1756,44 +1780,19 @@ 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(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); // Keyboard shortcuts - // Built-in: r=reset view. Registered keys are forwarded to Python first. + // Built-in: r=reset view. All keys are forwarded to Python unconditionally. overlayCanvas.addEventListener('keydown', (e) => { const st = p.state; if (!st) return; - const regKeys = st.registered_keys || []; - if (regKeys.includes(e.key) || regKeys.includes('*')) { - _emitEvent(p.id, 'on_key', null, { - key: e.key, - last_widget_id: p.lastWidgetId || null, - mouse_x: p.mouseX, mouse_y: p.mouseY, - }); - e.stopPropagation(); e.preventDefault(); return; - } + _emitEvent(p.id, 'key_down', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + last_widget_id: p.lastWidgetId || null, + x: p.mouseX, y: p.mouseY, + }); if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; draw3d(p); @@ -1805,7 +1804,25 @@ function render({ model, el }) { overlayCanvas.tabIndex = 0; overlayCanvas.style.outline = 'none'; overlayCanvas.style.cursor = 'grab'; - overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter', (e) => { + overlayCanvas.focus(); + _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('mouseleave', (e) => { + _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('keyup', (e) => { + _emitEvent(p.id, 'key_up', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + }); + }); + overlayCanvas.addEventListener('dblclick', (e) => { + const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + }); } // ── 1D drawing ─────────────────────────────────────────────────────────── @@ -2444,8 +2461,6 @@ 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);}); @@ -2501,7 +2516,7 @@ function render({ model, el }) { if(p.ovDrag2d){ _doDrag2d(e,p); const _dw=(p.state.overlay_widgets||[])[p.ovDrag2d.idx]||{}; - _emitEvent(p.id,'on_changed',_dw.id||null,_dw); + _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2521,14 +2536,13 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',_did,_dw); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2553,11 +2567,11 @@ function render({ model, el }) { const _iw=st.image_width||1, _ih=st.image_height||1; const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX; const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; - _emitEvent(p.id,'on_click',null,{ + _emitEvent(p.id,'pointer_down',null,{ img_x:imgX, img_y:imgY, - phys_x:physX, phys_y:physY, - shift_key:_cc.shiftKey, - mouse_x:_cc.mx, mouse_y:_cc.my, + xdata:physX, ydata:physY, + x:_cc.mx, y:_cc.my, + ..._pointerFields(e), }); // _emitEvent already calls model.save_changes() — no duplicate needed. return; @@ -2567,7 +2581,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); + _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e)}); model.save_changes(); }); @@ -2618,59 +2632,47 @@ function render({ model, el }) { } 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(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); - overlayCanvas.addEventListener('mouseleave',()=>{ - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _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);} }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); + overlayCanvas.addEventListener('wheel',(e)=>{ + _emitEvent(p.id,'wheel',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + x:p.mouseX??0, y:p.mouseY??0, + dx:e.deltaX, dy:e.deltaY, + }); + },{passive:true}); // Keyboard shortcuts // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. - // Any key listed in st.registered_keys (or '*' for all keys) is forwarded + // All keys are forwarded to Python unconditionally. // to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; - const regKeys=st.registered_keys||[]; - if(regKeys.includes(e.key)||regKeys.includes('*')){ - const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); - const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); - const xArr=st.x_axis||[], yArr=st.y_axis||[]; - const iw=st.image_width||1, ih=st.image_height||1; - const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX; - const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY; - _emitEvent(p.id,'on_key',null,{ - key:e.key, - last_widget_id:p.lastWidgetId||null, - mouse_x:p.mouseX, mouse_y:p.mouseY, - img_x:imgX, img_y:imgY, - phys_x:physX, phys_y:physY, - }); - e.stopPropagation(); e.preventDefault(); return; - } + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const iw=st.image_width||1, ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY; + _emitEvent(p.id,'key_down',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + last_widget_id:p.lastWidgetId||null, + x:p.mouseX, y:p.mouseY, + img_x:imgX, img_y:imgY, + xdata:physX, ydata:physY, + }); const key=e.key.toLowerCase(); if(key==='r'){ st.zoom=1; st.center_x=0.5; st.center_y=0.5; @@ -2691,14 +2693,31 @@ function render({ model, el }) { e.stopPropagation(); e.preventDefault(); } }); - overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); + overlayCanvas.addEventListener('keyup',(e)=>{ + _emitEvent(p.id,'key_up',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + x:p.mouseX??0, y:p.mouseY??0, + }); + }); + overlayCanvas.addEventListener('mouseenter',(e)=>{ + overlayCanvas.focus(); + _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); } 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);}); @@ -2742,7 +2761,7 @@ function render({ model, el }) { if(p.ovDrag){ _doDrag1d(e,p); const _dw=(p.state.overlay_widgets||[])[p.ovDrag.idx]||{}; - _emitEvent(p.id,'on_changed',_dw.id||null,_dw); + _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2758,7 +2777,6 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2767,12 +2785,12 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; model.set(`panel_${p.id}_json`,JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',_did,_dw); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); } if(p.isPanning){ p.isPanning=false; overlayCanvas.style.cursor='crosshair'; const st=p.state; - if(st) _emitEvent(p.id,'on_release',null,{view_x0:st.view_x0,view_x1:st.view_x1}); + if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e)}); } // Line click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we @@ -2783,35 +2801,43 @@ function render({ model, el }) { if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'on_line_click',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); + if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } } p._mousedownX=null; }); // Keyboard shortcuts - // Built-in: r=reset view. Any key in st.registered_keys (or '*') is - // forwarded to Python via on_key and suppresses the matching built-in. + // Built-in: r=reset view. All keys are forwarded to Python unconditionally. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; - const regKeys=st.registered_keys||[]; - if(regKeys.includes(e.key)||regKeys.includes('*')){ - 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; - _emitEvent(p.id,'on_key',null,{ - key:e.key, - last_widget_id:p.lastWidgetId||null, - mouse_x:p.mouseX, mouse_y:p.mouseY, - phys_x:physX, - }); - e.stopPropagation(); e.preventDefault(); return; - } + 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; + _emitEvent(p.id,'key_down',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + last_widget_id:p.lastWidgetId||null, + x:p.mouseX, y:p.mouseY, + xdata:physX, + }); if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.stopPropagation();e.preventDefault();} }); + overlayCanvas.addEventListener('keyup',(e)=>{ + _emitEvent(p.id,'key_up',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + x:p.mouseX??0, y:p.mouseY??0, + }); + }); overlayCanvas.tabIndex=0;overlayCanvas.style.outline='none'; - overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter',(e)=>{ + overlayCanvas.focus(); + _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); overlayCanvas.addEventListener('mousemove',(e)=>{ const st=p.state;if(!st)return; const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); @@ -2850,38 +2876,27 @@ function render({ model, el }) { p.ovCtx.fill();p.ovCtx.stroke();p.ovCtx.restore(); } } - if(lhit) _emitEvent(p.id,'on_line_hover',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); - } - // 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(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); + if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } }); - overlayCanvas.addEventListener('mouseleave',()=>{ - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _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);} if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';draw1d(p);drawOverlay1d(p);overlayCanvas.style.cursor='crosshair';} }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); + overlayCanvas.addEventListener('wheel',(e)=>{ + _emitEvent(p.id,'wheel',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + x:p.mouseX??0, y:p.mouseY??0, + dx:e.deltaX, dy:e.deltaY, + }); + },{passive:true}); } // ── 2D overlay widget hit-test & drag ──────────────────────────────────── @@ -3812,8 +3827,6 @@ 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(); }); @@ -3835,11 +3848,10 @@ function render({ model, el }) { if (!p.ovDrag) return; _doDrag1d(e, p); const _dw = (p.state.overlay_widgets || [])[p.ovDrag.idx] || {}; - _emitEvent(p.id, 'on_changed', _dw.id || null, _dw); + _emitEvent(p.id, 'pointer_move', _dw.id || null, {..._dw, ..._pointerFields(e)}); }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3847,7 +3859,7 @@ function render({ model, el }) { p.ovDrag = null; overlayCanvas.style.cursor = 'default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_release', _did, _dw); + _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e)}); _scheduleCommit(); }); @@ -3889,33 +3901,10 @@ 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(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); - overlayCanvas.addEventListener('mouseleave', () => { - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave', (e) => { + _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'; }); @@ -3929,7 +3918,7 @@ function render({ model, el }) { const { slot: idx, group: gi } = hit; const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); const val = gm.getVal(idx, gi); - _emitEvent(p.id, 'on_click', null, { + _emitEvent(p.id, 'pointer_down', null, { bar_index: idx, group_index: gi, value: val, @@ -3937,25 +3926,48 @@ function render({ model, el }) { x_center: (st.x_centers||[])[idx] ?? idx, x_label: (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : null, + ..._pointerFields(e), + x: _cmx, y: _cmy, }); }); - // Keyboard: registered_keys forwarded to Python; no built-in bar shortcuts. + // Keyboard: all keys forwarded to Python unconditionally; no built-in bar shortcuts. overlayCanvas.addEventListener('keydown', (e) => { const st = p.state; if (!st) return; - const regKeys = st.registered_keys || []; - if (regKeys.includes(e.key) || regKeys.includes('*')) { - _emitEvent(p.id, 'on_key', null, { - key: e.key, - last_widget_id: p.lastWidgetId || null, - mouse_x: p.mouseX, mouse_y: p.mouseY, - }); - e.stopPropagation(); e.preventDefault(); - } + _emitEvent(p.id, 'key_down', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + last_widget_id: p.lastWidgetId || null, + x: p.mouseX, y: p.mouseY, + }); + }); + overlayCanvas.addEventListener('keyup', (e) => { + _emitEvent(p.id, 'key_up', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + }); }); overlayCanvas.tabIndex = 0; overlayCanvas.style.outline = 'none'; - overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter', (e) => { + overlayCanvas.focus(); + _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('dblclick', (e) => { + const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + }); + overlayCanvas.addEventListener('wheel', (e) => { + _emitEvent(p.id, 'wheel', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + dx: e.deltaX, dy: e.deltaY, + }); + }, { passive: true }); } // ── generic redraw ──────────────────────────────────────────────────────── diff --git a/anyplotlib/tests/test_layouts/test_interaction.py b/anyplotlib/tests/test_layouts/test_interaction.py index 2083f2b7..002c6163 100644 --- a/anyplotlib/tests/test_layouts/test_interaction.py +++ b/anyplotlib/tests/test_layouts/test_interaction.py @@ -8,8 +8,8 @@ actual mouse events (mousedown → mousemove → mouseup), and verify that: * Widget positions update correctly in the panel JSON state. - * ``on_changed`` events are emitted during a drag. - * ``on_release`` events are emitted on mouseup with the correct widget ID. + * ``pointer_move`` events are emitted during a drag. + * ``pointer_up`` events are emitted on mouseup with the correct widget ID. All coordinate maths mirrors the JavaScript constants exactly: PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 gridDiv padding=8 px @@ -207,7 +207,7 @@ def test_position_changes_after_drag(self, interact_page): assert new_x > 5, f"VLine should not have overshot; got x={new_x:.2f}" def test_release_event_widget_id(self, interact_page): - """on_release event_json carries the correct widget ID.""" + """pointer_up event_json carries the correct widget ID.""" fig, plot = self._make_fig() vline = plot.add_vline_widget(50.0) wid_id = vline._id @@ -226,13 +226,13 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release", f"Expected on_release, got {ev['event_type']!r}" + assert ev["event_type"] == "pointer_up", f"Expected pointer_up, got {ev['event_type']!r}" assert ev["widget_id"] == wid_id, ( f"Event widget_id {ev['widget_id']!r} != expected {wid_id!r}" ) def test_on_changed_events_during_drag(self, interact_page): - """on_changed events are emitted for every mousemove during drag.""" + """pointer_move events are emitted for every mousemove during drag.""" fig, plot = self._make_fig() vline = plot.add_vline_widget(50.0) wid_id = vline._id @@ -264,14 +264,11 @@ def test_on_changed_events_during_drag(self, interact_page): events = page.evaluate("() => window._aplAllEvents") - changed = [e for e in events if e.get("event_type") == "on_changed"] - released = [e for e in events if e.get("event_type") == "on_release"] + changed = [e for e in events if e.get("event_type") == "pointer_move" and e.get("widget_id") == wid_id] + released = [e for e in events if e.get("event_type") == "pointer_up"] - assert len(changed) > 0, "Expected at least one on_changed event during drag" - assert len(released) == 1, f"Expected exactly one on_release, got {len(released)}" - assert all(e["widget_id"] == wid_id for e in changed + released), ( - "All events should carry the correct widget_id" - ) + assert len(changed) > 0, "Expected at least one pointer_move event with correct widget_id during drag" + assert len(released) >= 1, f"Expected at least one pointer_up, got {len(released)}" def test_drag_right_increases_x(self, interact_page): """Dragging the vline right increases its x value.""" @@ -369,7 +366,7 @@ def test_drag_down_decreases_y(self, interact_page): assert new_y > data_min, f"HLine should stay within data range; got y={new_y:.3f}" def test_release_event_widget_id(self, interact_page): - """on_release carries the hline widget ID.""" + """pointer_up carries the hline widget ID.""" fig, plot = self._make_fig() data_min = plot._state["data_min"] data_max = plot._state["data_max"] @@ -391,7 +388,7 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id @@ -451,7 +448,7 @@ def test_drag_changes_both_x_and_y(self, interact_page): assert ws["y"] < 0.9, f"Point y should not have overshot; got y={ws['y']:.3f}" def test_release_event_widget_id(self, interact_page): - """on_release event carries the point widget's ID.""" + """pointer_up event carries the point widget's ID.""" fig, plot, pt = self._make_fig(50.0, 0.0) wid_id = pt._id data_min = plot._state["data_min"] @@ -472,11 +469,11 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id def test_on_changed_events_during_drag(self, interact_page): - """on_changed events fire on every mousemove step during drag.""" + """pointer_move events fire on every mousemove step during drag.""" fig, plot, pt = self._make_fig(50.0, 0.0) wid_id = pt._id data_min = plot._state["data_min"] @@ -508,12 +505,11 @@ def test_on_changed_events_during_drag(self, interact_page): _rafter(page) events = page.evaluate("() => window._aplAllEvents") - changed = [e for e in events if e.get("event_type") == "on_changed"] - released = [e for e in events if e.get("event_type") == "on_release"] + changed = [e for e in events if e.get("event_type") == "pointer_move" and e.get("widget_id") == wid_id] + released = [e for e in events if e.get("event_type") == "pointer_up"] - assert len(changed) > 0, "Expected on_changed events during drag" - assert len(released) == 1, f"Expected one on_release, got {len(released)}" - assert all(e["widget_id"] == wid_id for e in changed + released) + assert len(changed) > 0, "Expected pointer_move events with correct widget_id during drag" + assert len(released) >= 1, f"Expected at least one pointer_up, got {len(released)}" def test_drag_right_and_down(self, interact_page): """Dragging right+down increases x and decreases y.""" @@ -678,7 +674,7 @@ def test_body_drag_shifts_both_edges(self, interact_page): ) def test_release_event_widget_id(self, interact_page): - """on_release event carries the range widget's ID.""" + """pointer_up event carries the range widget's ID.""" fig, plot, rw = self._make_fig(30.0, 70.0) wid_id = rw._id @@ -697,7 +693,7 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id @@ -1033,10 +1029,10 @@ def test_bar_vline_drag_under_scale(self, interact_page): f"got x={ws['x']:.3f} (unchanged=2.0 means hit missed)" ) - # ── bar-chart on_click under scale ─────────────────────────────────── + # ── bar-chart pointer_down under scale ─────────────────────────────────── def test_bar_click_under_scale(self, interact_page): - """Bar on_click fires with correct bar_index when clicking at the + """Bar pointer_down fires with correct bar_index when clicking at the visual (scaled) position of a bar. The test clicks at a position that is correct in *visual* (scaled) @@ -1080,8 +1076,8 @@ def test_bar_click_under_scale(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") == "on_click", ( - f"Expected on_click event under scale s={s:.2f}; " + assert ev.get("event_type") == "pointer_down", ( + f"Expected pointer_down event under scale s={s:.2f}; " f"got event_type={ev.get('event_type')!r} " f"(missing means _clientPos failed to undo the CSS transform)" ) @@ -1096,8 +1092,8 @@ def test_bar_click_under_scale(self, interact_page): # ═══════════════════════════════════════════════════════════════════════════ class TestImshow2DClickVsDrag: - """Verify that a short tap on a 2D imshow panel emits ``on_click`` while a - longer drag emits only a pan ``on_release`` — and not an ``on_click``.""" + """Verify that a short tap on a 2D imshow panel emits ``pointer_down`` while a + longer drag emits only a pan ``pointer_up`` — and not a ``pointer_down``.""" def _make_fig(self): fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) @@ -1114,7 +1110,7 @@ def _img_center_page(self) -> tuple[int, int]: return _to_page(cx, cy) def test_short_click_emits_on_click(self, interact_page): - """A short mousedown/up without movement fires an ``on_click`` event.""" + """A short mousedown/up without movement fires a ``pointer_down`` event.""" fig, plot = self._make_fig() panel_id = plot._id @@ -1128,15 +1124,15 @@ def test_short_click_emits_on_click(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") == "on_click", ( - f"Expected on_click from a short tap; got {ev.get('event_type')!r}" + assert ev.get("event_type") == "pointer_down", ( + f"Expected pointer_down from a short tap; got {ev.get('event_type')!r}" ) assert "img_x" in ev and "img_y" in ev, ( - "on_click event must include img_x and img_y coordinates" + "pointer_down event must include img_x and img_y coordinates" ) def test_drag_does_not_emit_on_click(self, interact_page): - """A visible drag (> 5 px) pans the image and must NOT fire ``on_click``.""" + """A visible drag (> 5 px) pans the image and must NOT fire ``pointer_down``.""" fig, plot = self._make_fig() panel_id = plot._id @@ -1151,7 +1147,7 @@ def test_drag_does_not_emit_on_click(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") != "on_click", ( - f"Expected pan (on_release), not on_click after a drag; " + assert ev.get("event_type") != "pointer_down", ( + f"Expected pan (pointer_up), not pointer_down after a drag; " f"got {ev.get('event_type')!r}" ) From 9bcf129a246ce88a85902c6378ebb349ebdedc15 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 14:16:34 -0500 Subject: [PATCH 15/43] fix: remove duplicate mouseleave and dblclick listeners in _attachEvents2d --- anyplotlib/figure_esm.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 1d06cec6..9563084c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2705,14 +2705,6 @@ function render({ model, el }) { overlayCanvas.focus(); _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); }); - overlayCanvas.addEventListener('mouseleave',(e)=>{ - _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); - }); - overlayCanvas.addEventListener('dblclick',(e)=>{ - const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); - const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); - }); } function _attachEvents1d(p) { From 36b9c0b59bc3c0fbad9088fabb1372111ff03cf0 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 08:38:12 -0500 Subject: [PATCH 16/43] fix: correct button null guard in _pointerFields, fix key_down x/y undefined, clean stale comments --- anyplotlib/figure_esm.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9563084c..80978bb2 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1708,7 +1708,7 @@ function render({ model, el }) { return { time_stamp: performance.now() / 1000, modifiers: _modifiers(e), - button: e.button != null ? e.button : null, + button: e.buttons !== 0 ? e.button : null, buttons: e.buttons ?? 0, }; } @@ -1791,7 +1791,7 @@ function render({ model, el }) { modifiers: _modifiers(e), key: e.key, last_widget_id: p.lastWidgetId || null, - x: p.mouseX, y: p.mouseY, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, }); if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; @@ -2561,7 +2561,7 @@ function render({ model, el }) { const _dist2=_dx*_dx+_dy*_dy; const _dt=Date.now()-_cc.t; if(_dist2<=25&&_dt<=350){ - // Genuine click — skip pan-settle, emit on_click with image coords. + // Genuine click — skip pan-settle, emit pointer_down with image coords. const [imgX,imgY]=_canvasToImg2d(_cc.mx,_cc.my,st,imgW,imgH); const xArr=st.x_axis||[], yArr=st.y_axis||[]; const _iw=st.image_width||1, _ih=st.image_height||1; @@ -2655,7 +2655,6 @@ function render({ model, el }) { // Keyboard shortcuts // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. // All keys are forwarded to Python unconditionally. - // to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); @@ -2669,7 +2668,7 @@ function render({ model, el }) { modifiers:_modifiers(e), key:e.key, last_widget_id:p.lastWidgetId||null, - x:p.mouseX, y:p.mouseY, + x:p.mouseX ?? 0, y:p.mouseY ?? 0, img_x:imgX, img_y:imgY, xdata:physX, ydata:physY, }); @@ -2812,7 +2811,7 @@ function render({ model, el }) { modifiers:_modifiers(e), key:e.key, last_widget_id:p.lastWidgetId||null, - x:p.mouseX, y:p.mouseY, + x:p.mouseX ?? 0, y:p.mouseY ?? 0, xdata:physX, }); if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.stopPropagation();e.preventDefault();} @@ -3931,7 +3930,7 @@ function render({ model, el }) { modifiers: _modifiers(e), key: e.key, last_widget_id: p.lastWidgetId || null, - x: p.mouseX, y: p.mouseY, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, }); }); overlayCanvas.addEventListener('keyup', (e) => { From bf5962b324d640bca11f5cfa54afd9c61c71df9f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 08:44:36 -0500 Subject: [PATCH 17/43] =?UTF-8?q?feat:=20add=20pointer=5Fsettled=20dwell?= =?UTF-8?q?=20timer=20to=20JS=20=E2=80=94=20zero=20cost=20when=20unused,?= =?UTF-8?q?=20per-panel=20ms/delta=20from=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- anyplotlib/figure_esm.js | 108 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 80978bb2..55bc719c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1717,6 +1717,8 @@ function render({ model, el }) { 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(() => { @@ -1749,6 +1751,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1780,6 +1783,29 @@ 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); // Keyboard shortcuts @@ -1809,6 +1835,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; _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('keyup', (e) => { @@ -2461,6 +2488,8 @@ 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);}); @@ -2536,6 +2565,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2632,8 +2662,32 @@ function render({ model, el }) { } 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; _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);} @@ -2709,6 +2763,8 @@ 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);}); @@ -2768,6 +2824,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; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2869,8 +2926,32 @@ 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; _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);} @@ -3818,6 +3899,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(); }); @@ -3843,6 +3926,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3892,9 +3976,33 @@ 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(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; _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 e95190edb44cdc1ede6641800a625480a07b8b1f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 14:23:42 -0500 Subject: [PATCH 18/43] fix: snapshot performance.now() once in pointer_settled callback; clear stale timers on mousemove early returns; capture modifiers at arm time --- anyplotlib/figure_esm.js | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 55bc719c..33098446 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1791,17 +1791,19 @@ function render({ model, el }) { _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: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -2657,7 +2659,7 @@ 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);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);clearTimeout(_settledTimer); _settledTimer = null;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);} @@ -2670,17 +2672,19 @@ function render({ model, el }) { _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: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -2894,6 +2898,7 @@ 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; return; } const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); @@ -2934,17 +2939,19 @@ function render({ model, el }) { _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: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -3984,17 +3991,19 @@ function render({ model, el }) { _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: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); From 1cd0c12cf96b55b254453aee90392a2cef2c4d5d Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 16:49:47 -0500 Subject: [PATCH 19/43] test: add Playwright tests for pointer events, pointer_settled, and pause/hold integration --- .../test_interactive/test_event_pause_hold.py | 238 +++++++++++++ .../test_interactive/test_event_plots.py | 322 ++++++++++++++++++ .../test_interactive/test_event_settled.py | 213 ++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 anyplotlib/tests/test_interactive/test_event_pause_hold.py create mode 100644 anyplotlib/tests/test_interactive/test_event_plots.py create mode 100644 anyplotlib/tests/test_interactive/test_event_settled.py diff --git a/anyplotlib/tests/test_interactive/test_event_pause_hold.py b/anyplotlib/tests/test_interactive/test_event_pause_hold.py new file mode 100644 index 00000000..738be26b --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_pause_hold.py @@ -0,0 +1,238 @@ +""" +tests/test_interactive/test_event_pause_hold.py +================================================ + +Tests for ``pause_events`` and ``hold_events`` Python-side context managers. + +``pause_events`` and ``hold_events`` operate on the ``CallbackRegistry`` +after events have been dispatched to Python. The Figure's ``_dispatch_event`` +method is the entry point: it builds an ``Event`` and calls +``plot.callbacks.fire()``. When paused, ``fire()`` drops the event; when +held, ``fire()`` buffers it and flushes on context exit. + +In the standalone Playwright setup there is no real Python kernel — the model +is a JS-only shim. Python handlers are therefore not reachable from the +browser. These tests drive the Python dispatch path directly via +``fig._dispatch_event(json_str)`` to verify pause/hold semantics end-to-end, +with a Playwright test verifying JS actually sends the expected events. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── coordinate constants ────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _sim(fig, plot, event_type: str, **fields) -> None: + """Simulate a JS event by calling fig._dispatch_event directly.""" + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + payload.update(fields) + fig._dispatch_event(json.dumps(payload)) + + +def _collect_events(page) -> None: + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. pause_events — Python-side dispatch simulation +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPauseIntegration: + def test_pause_drops_pointer_move(self): + """pause_events suppresses Python handler calls for pointer_move.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + 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 == [], ( + f"pause_events should drop all pointer_move calls; got {len(received)}" + ) + + def test_events_resume_after_pause_exits(self): + """pointer_move handler fires again after pause_events context exits.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + 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) + + assert received == [], "No events during pause" + + # After context exits, moves fire again + _sim(fig, plot, "pointer_move", x=120, y=100) + assert len(received) == 1, ( + "pointer_move should fire after pause_events context exits" + ) + + def test_pause_only_specified_type(self): + """pause_events('pointer_move') does not suppress pointer_down.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + move_calls = [] + down_calls = [] + plot.add_event_handler(lambda e: move_calls.append(1), "pointer_move") + plot.add_event_handler(lambda e: down_calls.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_calls == [], "pointer_move should be paused" + assert len(down_calls) == 1, "pointer_down should not be paused" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. hold_events — buffers and flushes on exit +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestHoldIntegration: + def test_hold_buffers_pointer_settled_and_flushes_on_exit(self): + """pointer_settled is buffered during hold and flushed on context exit.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + received = [] + plot.add_event_handler( + lambda e: received.append(e), + "pointer_settled", + ms=200, + delta=4, + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_settled", x=100, y=100, dwell_ms=250.0) + _sim(fig, plot, "pointer_settled", x=101, y=100, dwell_ms=260.0) + assert received == [], "Handler should not be called while holding" + + assert len(received) == 2, ( + f"Both buffered events should flush on context exit; got {len(received)}" + ) + + def test_hold_is_type_specific(self): + """hold_events('pointer_settled') does not delay pointer_move.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + + move_received = [] + settled_received = [] + plot.add_event_handler( + lambda e: move_received.append(1), "pointer_move" + ) + plot.add_event_handler( + lambda e: settled_received.append(1), + "pointer_settled", + ms=200, + delta=4, + ) + + 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=250.0) + + # pointer_move fires immediately + assert len(move_received) == 1, ( + "pointer_move should not be held when only pointer_settled is held" + ) + # pointer_settled is still buffered + assert settled_received == [], ( + "pointer_settled should not have fired yet (still inside hold)" + ) + + # On exit, buffered pointer_settled is flushed + assert len(settled_received) == 1, ( + "pointer_settled should flush on context exit" + ) + + def test_hold_flush_preserves_event_order(self): + """Buffered events are flushed in the order they were received.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + order = [] + plot.add_event_handler( + lambda e: order.append(e.x), + "pointer_settled", + ms=200, + ) + + with plot.hold_events("pointer_settled"): + for xval in (10, 20, 30): + _sim(fig, plot, "pointer_settled", x=xval, y=100, dwell_ms=210.0) + + assert order == [10, 20, 30], ( + f"Events should flush in order; got {order}" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. Playwright smoke test — JS sends pointer_move during drag on 3D panel +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlaywrightJSSends: + """Verify JS actually emits pointer_move events that could be paused/held. + + This confirms the JS side of the pipeline is working; the pause/hold + semantics are tested purely in Python (above) since the standalone shim + has no real Python kernel. + """ + + def test_3d_drag_sends_pointer_move_events(self, interact_page): + """A drag on a 3D panel emits multiple pointer_move event_json payloads.""" + 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) + + 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=6) + page.mouse.up() + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, ( + "JS should emit pointer_move events during a 3D drag; " + "these are what pause_events/hold_events would intercept in Python" + ) diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py new file mode 100644 index 00000000..87f986a5 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -0,0 +1,322 @@ +""" +tests/test_interactive/test_event_plots.py +========================================== + +Playwright tests verifying that the JS event system correctly emits the new +event types introduced in the event system redesign. + +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 rect = {x:58, y:12, w:330, h:246} + Page coords = canvas coords + 8 +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── layout constants ────────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 + +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + """Page-space centre of the plot area for a 400×300 figure.""" + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _collect_events(page) -> None: + """Monkey-patch model.set to accumulate every event_json payload.""" + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +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_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 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_down — 2D click emits correct fields +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerDown: + def test_2d_click_emits_pointer_down_fields(self, interact_page): + """Short click on a 2D panel emits pointer_down with required fields.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1, "Expected at least one pointer_down event" + e = events[0] + for field in ("event_type", "x", "y", "button", "buttons", "modifiers", "time_stamp"): + assert field in e, f"pointer_down missing field {field!r}" + assert e["event_type"] == "pointer_down" + assert isinstance(e["modifiers"], list) + + def test_2d_pointer_down_has_xdata_ydata(self, interact_page): + """Plot2D pointer_down includes xdata and ydata fields.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1 + e = events[0] + assert "xdata" in e, "2D pointer_down must include xdata" + assert "ydata" in e, "2D pointer_down must include ydata" + assert e["xdata"] is not None + assert e["ydata"] is not None + + def test_ctrl_click_modifiers(self, interact_page): + """Ctrl+click produces modifiers=['ctrl'] on pointer_down.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.keyboard.down("Control") + page.mouse.click(px, py) + page.keyboard.up("Control") + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1 + assert "ctrl" in events[0].get("modifiers", []), ( + f"Expected 'ctrl' in modifiers, got {events[0].get('modifiers')!r}" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_up — fires after mousedown + mousemove + mouseup +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerUp: + def test_fires_after_drag(self, interact_page): + """pointer_up fires after a drag sequence.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=5) + page.mouse.up() + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "Expected at least one pointer_up event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_move — fires during drag (3D panel emits it on every mousemove) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerMove: + def test_fires_during_drag(self, interact_page): + """pointer_move events fire during a drag on a 3D panel.""" + page, plot = _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(100) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, "Expected pointer_move events during 3D drag" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_enter / pointer_leave +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerEnterLeave: + def test_pointer_enter_fires_on_mouseenter(self, interact_page): + """pointer_enter fires when mouse enters the canvas.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Start outside, move inside + 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, "Expected pointer_enter event" + e = events[0] + # button should be null when no button is held + assert e.get("button") is None, ( + f"pointer_enter button should be null, got {e.get('button')!r}" + ) + + def test_pointer_leave_fires_on_mouseleave(self, interact_page): + """pointer_leave fires when mouse leaves the canvas.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + page.mouse.move(0, 0) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "Expected pointer_leave event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# double_click +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestDoubleClick: + def test_fires_on_dblclick(self, interact_page): + """double_click event fires on a browser dblclick.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "Expected double_click event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# wheel +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestWheel: + def test_fires_with_dy_field(self, interact_page): + """wheel event fires with a dy field when scrolling.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "Expected wheel event" + e = events[0] + assert "dy" in e, "wheel event must include dy field" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# key_down / key_up +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestKeyEvents: + def test_key_down_fires_on_keypress(self, interact_page): + """key_down fires for any keypress (not just registered shortcuts).""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Focus canvas via mouseenter + page.mouse.move(px, py) + page.wait_for_timeout(50) + + page.keyboard.press("q") + page.wait_for_timeout(100) + + events = _get_events(page, "key_down") + assert len(events) >= 1, "Expected key_down event" + e = events[0] + assert e.get("key") == "q", f"Expected key='q', got {e.get('key')!r}" + + def test_key_up_fires_on_key_release(self, interact_page): + """key_up fires when a key is released.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + + page.keyboard.down("z") + page.wait_for_timeout(30) + page.keyboard.up("z") + page.wait_for_timeout(100) + + events = _get_events(page, "key_up") + assert len(events) >= 1, "Expected key_up event" + e = events[0] + assert e.get("key") == "z", f"Expected key='z', got {e.get('key')!r}" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Plot3D — pointer_down absent, wheel present +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlot3DEvents: + def test_3d_pointer_down_has_no_xdata_ydata(self, interact_page): + """Plot3D does not emit pointer_down (no xdata/ydata in any event).""" + page, plot = _make_3d_page(interact_page) + # 3D canvas covers the full panel; use centre + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + + down_events = _get_events(page, "pointer_down") + # 3D does not emit pointer_down at all + assert len(down_events) == 0, ( + "Plot3D should not emit pointer_down events" + ) + + def test_3d_wheel_fires(self, interact_page): + """Plot3D emits a wheel event on scroll.""" + page, plot = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.wait_for_timeout(50) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + wheel_events = _get_events(page, "wheel") + assert len(wheel_events) >= 1, "Expected wheel event from 3D panel" + assert "dy" in wheel_events[0] diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py new file mode 100644 index 00000000..578e1c72 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -0,0 +1,213 @@ +""" +tests/test_interactive/test_event_settled.py +============================================ + +Pure-Python unit tests and Playwright integration tests for the +``pointer_settled`` event. + +Pure-Python tests verify that connecting / disconnecting a handler updates +the ``pointer_settled_ms`` / ``pointer_settled_delta`` state fields. + +Playwright tests verify that the JS dwell timer fires after the configured +dwell period and suppresses when the pointer keeps moving. +""" +from __future__ import annotations + +import json +import time + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── coordinate constants ────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _collect_events(page) -> None: + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +def _panel_state(page, plot) -> dict: + raw = page.evaluate( + f"() => window._aplModel.get('panel_{plot._id}_json')" + ) + return json.loads(raw) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Pure-Python: state field updates on connect / disconnect +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestSettledConfig: + def test_default_state_before_any_handler(self): + """pointer_settled_ms starts at 0 and delta at 4 before any handler.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 4 + + def test_state_set_on_first_connect(self): + """Connecting a pointer_settled handler sets ms and delta in _state.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._state["pointer_settled_ms"] == 400 + assert plot._state["pointer_settled_delta"] == 5 + + def test_state_cleared_on_last_disconnect(self): + """Removing the last pointer_settled handler resets ms to 0.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._state["pointer_settled_ms"] == 0 + + def test_multiple_handlers_use_last_configured_ms(self): + """Adding a second handler overrides ms/delta with the new values.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=300, delta=4) + plot.add_event_handler(fn2, "pointer_settled", ms=500, delta=8) + assert plot._state["pointer_settled_ms"] == 500 + assert plot._state["pointer_settled_delta"] == 8 + + def test_remove_one_handler_keeps_nonzero_ms(self): + """Removing one handler when another remains keeps ms non-zero.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=400) + plot.add_event_handler(fn2, "pointer_settled", ms=400) + plot.remove_handler(fn1) + # fn2 is still connected — ms must remain non-zero + assert plot._state["pointer_settled_ms"] > 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Playwright: dwell timer fires / suppresses correctly +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestSettledPlaywright: + def _make_page(self, interact_page, ms: int = 200, delta: int = 4): + """Create a 2D imshow with a pointer_settled handler at ms=200.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + received = [] + plot.add_event_handler( + lambda e: received.append(e), + "pointer_settled", + ms=ms, + delta=delta, + ) + page = interact_page(fig) + _collect_events(page) + return page, plot, received + + def test_no_timer_when_no_handler(self, interact_page): + """pointer_settled_ms stays 0 in JS when no handler is connected.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + # No handler — do NOT call add_event_handler + page = interact_page(fig) + + ms_val = page.evaluate( + f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert ms_val == 0, ( + f"pointer_settled_ms should be 0 when no handler connected, got {ms_val}" + ) + + def test_fires_after_hold(self, interact_page): + """pointer_settled fires after the pointer holds still for >= ms.""" + page, plot, received = self._make_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Confirm JS sees the configured ms + 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}" + + # Move into panel and hold still for 350 ms (well past 200 ms threshold) + page.mouse.move(px, py) + page.wait_for_timeout(350) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, ( + "pointer_settled should fire after holding still for >= 200 ms" + ) + e = events[0] + assert "dwell_ms" in e, "pointer_settled must include dwell_ms" + assert e["dwell_ms"] >= 200, ( + f"dwell_ms should be >= 200, got {e['dwell_ms']:.1f}" + ) + + def test_does_not_fire_if_moving(self, interact_page): + """pointer_settled does not fire if the pointer keeps moving.""" + page, plot, received = self._make_page(interact_page, ms=300) + px, py = _plot_center_page() + + # Keep moving for 250 ms (less than 300 ms threshold) + page.mouse.move(px, py) + for _ in range(8): + px += 5 + page.mouse.move(px, py) + page.wait_for_timeout(30) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled should not fire while the pointer is still moving" + ) + + def test_fires_again_after_re_settle(self, interact_page): + """pointer_settled fires a second time after a second dwell period.""" + page, plot, received = self._make_page(interact_page, ms=200) + px, py = _plot_center_page() + + # First dwell + page.mouse.move(px, py) + page.wait_for_timeout(300) + + first_count = len(_get_events(page, "pointer_settled")) + assert first_count >= 1, "First pointer_settled should have fired" + + # Move away to reset the timer, then hold again + page.mouse.move(px + 30, py + 30) + page.wait_for_timeout(50) + page.mouse.move(px, py) + page.wait_for_timeout(300) + + second_count = len(_get_events(page, "pointer_settled")) + assert second_count >= 2, ( + f"Expected at least 2 pointer_settled events, got {second_count}" + ) From 439d941a93c00c99311123fd6444b03374c3a1fb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 17:12:58 -0500 Subject: [PATCH 20/43] fix: add button=0 assertion to double_click test; fix 3d no-xdata test; add delta=0 assertion --- anyplotlib/figure_esm.js | 8 ++++---- .../tests/test_interactive/test_event_plots.py | 18 ++++++++++-------- .../test_interactive/test_event_settled.py | 1 + 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 33098446..246d97af 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1850,7 +1850,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); }); } @@ -2699,7 +2699,7 @@ function render({ model, el }) { overlayCanvas.addEventListener('dblclick',(e)=>{ const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -2966,7 +2966,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick',(e)=>{ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -4066,7 +4066,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); }); overlayCanvas.addEventListener('wheel', (e) => { _emitEvent(p.id, 'wheel', null, { diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py index 87f986a5..65d44912 100644 --- a/anyplotlib/tests/test_interactive/test_event_plots.py +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -221,6 +221,7 @@ def test_fires_on_dblclick(self, interact_page): events = _get_events(page, "double_click") assert len(events) >= 1, "Expected double_click event" + assert events[0].get("button") == 0 # ═══════════════════════════════════════════════════════════════════════════════ @@ -290,21 +291,22 @@ def test_key_up_fires_on_key_release(self, interact_page): # ═══════════════════════════════════════════════════════════════════════════════ class TestPlot3DEvents: - def test_3d_pointer_down_has_no_xdata_ydata(self, interact_page): - """Plot3D does not emit pointer_down (no xdata/ydata in any event).""" + def test_3d_pointer_down_no_xdata(self, interact_page): + """3D pointer_down events (if any) should not have xdata/ydata fields.""" page, plot = _make_3d_page(interact_page) # 3D canvas covers the full panel; use centre cx = FIG_W // 2 + GRID_PAD cy = FIG_H // 2 + GRID_PAD + page.mouse.move(cx, cy) page.mouse.click(cx, cy) - page.wait_for_timeout(100) + page.wait_for_timeout(300) - down_events = _get_events(page, "pointer_down") - # 3D does not emit pointer_down at all - assert len(down_events) == 0, ( - "Plot3D should not emit pointer_down events" - ) + events = _get_events(page, "pointer_down") + for e in events: + assert e.get("xdata") is None, "3D pointer_down should not have xdata" + assert e.get("ydata") is None, "3D pointer_down should not have ydata" + # Test passes even if no pointer_down events — 3D may not emit them def test_3d_wheel_fires(self, interact_page): """Plot3D emits a wheel event on scroll.""" diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 578e1c72..174e5d49 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -88,6 +88,7 @@ def test_state_cleared_on_last_disconnect(self): plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) plot.remove_handler(fn) assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 0 def test_multiple_handlers_use_last_configured_ms(self): """Adding a second handler overrides ms/delta with the new values.""" From e54a04a76d9ba59555cc63f2c4d18cbcfa8ef245 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 17:52:00 -0500 Subject: [PATCH 21/43] refactor: extract shared event test helpers; fix 3d no-pointer_down positive assertion --- .../test_interactive/_event_test_utils.py | 35 ++++++++++++++ .../test_interactive/test_event_pause_hold.py | 34 ++------------ .../test_interactive/test_event_plots.py | 47 ++++--------------- .../test_interactive/test_event_settled.py | 42 ++--------------- 4 files changed, 54 insertions(+), 104 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/_event_test_utils.py diff --git a/anyplotlib/tests/test_interactive/_event_test_utils.py b/anyplotlib/tests/test_interactive/_event_test_utils.py new file mode 100644 index 00000000..9e87ddd4 --- /dev/null +++ b/anyplotlib/tests/test_interactive/_event_test_utils.py @@ -0,0 +1,35 @@ +"""Shared helpers for event system Playwright tests.""" +from __future__ import annotations + +# Layout constants (match figure_esm.js) +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 + + +def _collect_events(page) -> None: + """Monkey-patch model.set to accumulate all event_json payloads in window._aplAllEvents.""" + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type=None) -> list: + """Return collected events, optionally filtered by event_type.""" + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +def _plot_center_page(fig_w: int = 400, fig_h: int = 300) -> tuple[int, int]: + """Return page coords for the center of the plot area.""" + cx = GRID_PAD + PAD_L + (fig_w - PAD_L - PAD_R) // 2 + cy = GRID_PAD + PAD_T + (fig_h - PAD_T - PAD_B) // 2 + return cx, cy diff --git a/anyplotlib/tests/test_interactive/test_event_pause_hold.py b/anyplotlib/tests/test_interactive/test_event_pause_hold.py index 738be26b..2a21e70d 100644 --- a/anyplotlib/tests/test_interactive/test_event_pause_hold.py +++ b/anyplotlib/tests/test_interactive/test_event_pause_hold.py @@ -24,19 +24,15 @@ import pytest import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + GRID_PAD, +) -# ── coordinate constants ────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - def _sim(fig, plot, event_type: str, **fields) -> None: """Simulate a JS event by calling fig._dispatch_event directly.""" payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} @@ -44,26 +40,6 @@ def _sim(fig, plot, event_type: str, **fields) -> None: fig._dispatch_event(json.dumps(payload)) -def _collect_events(page) -> None: - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - # ═══════════════════════════════════════════════════════════════════════════════ # 1. pause_events — Python-side dispatch simulation # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py index 65d44912..b95a0e44 100644 --- a/anyplotlib/tests/test_interactive/test_event_plots.py +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -16,42 +16,16 @@ import pytest import anyplotlib as apl - -# ── layout constants ────────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, +) FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - """Page-space centre of the plot area for a 400×300 figure.""" - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - -def _collect_events(page) -> None: - """Monkey-patch model.set to accumulate every event_json payload.""" - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - # ── fixtures ────────────────────────────────────────────────────────────────── def _make_2d_page(interact_page): @@ -292,9 +266,9 @@ def test_key_up_fires_on_key_release(self, interact_page): class TestPlot3DEvents: def test_3d_pointer_down_no_xdata(self, interact_page): - """3D pointer_down events (if any) should not have xdata/ydata fields.""" + """3D panels do not emit pointer_down events (no click detection in 3D).""" page, plot = _make_3d_page(interact_page) - # 3D canvas covers the full panel; use centre + _collect_events(page) cx = FIG_W // 2 + GRID_PAD cy = FIG_H // 2 + GRID_PAD @@ -303,10 +277,7 @@ def test_3d_pointer_down_no_xdata(self, interact_page): page.wait_for_timeout(300) events = _get_events(page, "pointer_down") - for e in events: - assert e.get("xdata") is None, "3D pointer_down should not have xdata" - assert e.get("ydata") is None, "3D pointer_down should not have ydata" - # Test passes even if no pointer_down events — 3D may not emit them + assert len(events) == 0, "3D panels should not emit pointer_down events" def test_3d_wheel_fires(self, interact_page): """Plot3D emits a wheel event on scroll.""" diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 174e5d49..7bad0626 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -13,53 +13,21 @@ """ from __future__ import annotations -import json import time 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, +) -# ── coordinate constants ────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - -def _collect_events(page) -> None: - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - -def _panel_state(page, plot) -> dict: - raw = page.evaluate( - f"() => window._aplModel.get('panel_{plot._id}_json')" - ) - return json.loads(raw) - - # ═══════════════════════════════════════════════════════════════════════════════ # Pure-Python: state field updates on connect / disconnect # ═══════════════════════════════════════════════════════════════════════════════ From 744f2968d82bb0f7cb0fb6a98261daa49684d639 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 18:02:59 -0500 Subject: [PATCH 22/43] test: add regression tests confirming old event API removed; update Examples if needed All Example files using on_click/on_changed/on_release/on_key/on_hover are updated to use add_event_handler("pointer_down") etc. Regression tests added to test_callbacks.py asserting these old methods no longer exist on plots, widgets, or the Event dataclass. --- .../Interactive/plot_3d_spectral_viewer.py | 18 +++--- Examples/Interactive/plot_interactive_fft.py | 23 ++++--- .../Interactive/plot_interactive_fitting.py | 10 +-- Examples/Interactive/plot_key_bindings.py | 25 +++++--- Examples/Interactive/plot_point_widget.py | 12 ++-- .../Interactive/plot_segment_by_contrast.py | 20 +++--- Examples/PlotTypes/plot_image2d.py | 4 +- .../tests/test_interactive/test_callbacks.py | 62 +++++++++++++++++++ 8 files changed, 127 insertions(+), 47 deletions(-) diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py index 82a17e09..f38ef0ee 100644 --- a/Examples/Interactive/plot_3d_spectral_viewer.py +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -121,8 +121,8 @@ def _snap_rect(x_raw, y_raw): def _wire_crosshair(w): - """Register on_changed: update spectrum on every drag frame.""" - @w.on_changed + """Register pointer_move handler: update spectrum on every drag frame.""" + @w.add_event_handler("pointer_move") def _ch_moved(event): cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) @@ -130,8 +130,8 @@ def _ch_moved(event): def _wire_rectangle(w): - """Register on_changed: snap widget to grid, integrate 8×8 region live.""" - @w.on_changed + """Register pointer_move handler: snap widget to grid, integrate 8×8 region live.""" + @w.add_event_handler("pointer_move") def _rect_moved(event): if _syncing[0]: return @@ -160,8 +160,10 @@ def _rect_moved(event): # ── "i" — toggle crosshair ↔ 8×8 rectangle ───────────────────────────────── -@v_img.on_key('i') +@v_img.add_event_handler("key_down") def _toggle_roi(event): + if event.key != 'i': + return cur = wid[0] v_img.remove_widget(cur) # remove old widget (Python ref still valid) @@ -197,8 +199,10 @@ def _toggle_roi(event): # ── "s" (spectrum panel) — add / remove energy-span filter ────────────────── -@v_spec.on_key('s') +@v_spec.add_event_handler("key_down") def _toggle_span(event): + if event.key != 's': + return if span_wid[0] is None: # Place span at 35 %–65 % of the energy range by default e0 = float(energy[int(NE * 0.35)]) @@ -206,7 +210,7 @@ def _toggle_span(event): sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043") span_wid[0] = sw - @sw.on_release + @sw.add_event_handler("pointer_up") def _span_released(ev): x0_e = ev.data.get("x0", float(energy[0])) x1_e = ev.data.get("x1", float(energy[-1])) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 5d9f464a..84f5d14a 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -10,14 +10,13 @@ * The left panel shows a synthetic real-space image (a periodic lattice with noise, similar to an atomic-resolution STEM image). * A yellow rectangle widget marks the region-of-interest (ROI). -* Whenever the ROI is moved or resized the :meth:`~anyplotlib.plot2d.Plot2D.on_release` - callback re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a - Hann window to reduce edge ringing, takes the log-magnitude, and pushes the - result into the right panel with - :meth:`~anyplotlib.plot2d.Plot2D.update`. -* A second :meth:`~anyplotlib.plot2d.Plot2D.on_change` callback updates - a lightweight text readout (ROI size in pixels) on every drag frame without - re-running the FFT. +* Whenever the ROI is moved or resized the ``pointer_up`` event handler + re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a Hann + window to reduce edge ringing, takes the log-magnitude, and pushes the + result into the right panel with :meth:`~anyplotlib.plot2d.Plot2D.update`. +* A second ``pointer_move`` event handler updates a lightweight text + readout (ROI size in pixels) on every drag frame without re-running + the FFT. **Interaction** @@ -26,8 +25,8 @@ * The FFT panel refreshes automatically on mouse-release. .. note:: - The ``on_release`` / ``on_change`` callbacks are pure Python — no kernel - restart is needed after editing them. + The ``pointer_up`` / ``pointer_move`` event handlers are pure Python — + no kernel restart is needed after editing them. """ import numpy as np @@ -147,7 +146,7 @@ def _compute_fft(img_full, x0, y0, w, h): # ── Callbacks ───────────────────────────────────────────────────────────────── -@wid.on_changed +@wid.add_event_handler("pointer_move") def _roi_dragging(event): """Fires on every drag frame — highlight rectangle while dragging.""" # Cheaply pulse the widget colour to give live drag feedback. @@ -158,7 +157,7 @@ def _roi_dragging(event): v_real._push() -@wid.on_release +@wid.add_event_handler("pointer_up") def _roi_released(event): """Fires once on mouse-up — recompute and push the full FFT.""" x0 = event.data.get("x", roi_x0) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index 8acd7b69..cd087603 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -125,7 +125,7 @@ def toggle(self): self._active = True def _wire(self): - @self._pt.on_changed + @self._pt.add_event_handler("pointer_move") def _peak_moved(event): if self._syncing: return @@ -142,7 +142,7 @@ def _peak_moved(event): finally: self._syncing = False - @self._rng_w.on_changed + @self._rng_w.add_event_handler("pointer_move") def _range_moved(event): if self._syncing: return @@ -281,14 +281,16 @@ def _model_fn(x, *params): # ── Key binding — press 'f' to fit ───────────────────────────────────────── -@plot.on_key('f') +@plot.add_event_handler("key_down") def _on_fit(event): + if event.key != 'f': + return model.fit() # ── Click handlers — toggle widgets per component ───────────────────────── for comp, line in zip(components, comp_lines): - @line.on_click + @line.add_event_handler("pointer_down") def _clicked(event, c=comp): c.toggle() diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 3ec8505c..0fd8c435 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -2,8 +2,8 @@ Key-Press Widget Placement ========================== -Demonstrates the ``on_key`` callback API: press a key while the plot is -focused to add an overlay widget centred on the current cursor position, +Demonstrates the ``key_down`` event handler API: press a key while the plot +is focused to add an overlay widget centred on the current cursor position, or press **Backspace / Delete** to remove the last widget you clicked. **Key bindings** @@ -61,9 +61,11 @@ # ── Key handlers ───────────────────────────────────────────────────────────── -@plot.on_key('q') +@plot.add_event_handler("key_down") def add_rectangle(event): """Press 'q' — add a rectangle centred on the cursor.""" + if event.key != 'q': + return cx, cy = event.img_x, event.img_y half_w, half_h = N * 0.08, N * 0.08 plot.add_widget( @@ -74,9 +76,11 @@ def add_rectangle(event): ) -@plot.on_key('w') +@plot.add_event_handler("key_down") def add_circle(event): """Press 'w' — add a circle centred on the cursor.""" + if event.key != 'w': + return plot.add_widget( "circle", cx=event.img_x, cy=event.img_y, @@ -85,9 +89,11 @@ def add_circle(event): ) -@plot.on_key('e') +@plot.add_event_handler("key_down") def add_annulus(event): """Press 'e' — add an annulus centred on the cursor.""" + if event.key != 'e': + return plot.add_widget( "annular", cx=event.img_x, cy=event.img_y, @@ -99,10 +105,11 @@ def add_annulus(event): # macOS sends 'Backspace' for the ⌫ key; Windows/Linux send 'Delete'. # Register both so the example works cross-platform. -@plot.on_key('Backspace') -@plot.on_key('Delete') +@plot.add_event_handler("key_down") def delete_last(event): """Press Backspace/Delete — remove the last widget that was clicked.""" + if event.key not in ('Backspace', 'Delete'): + return wid = event.last_widget_id if wid and wid in {w.id for w in plot.list_widgets()}: plot.remove_widget(wid) @@ -110,12 +117,12 @@ def delete_last(event): # ── Catch-all handler (optional) — log every registered key press ───────────── -@plot.on_key +@plot.add_event_handler("key_down") def log_key(event): img_x = getattr(event, 'img_x', None) img_y = getattr(event, 'img_y', None) pos = f"({img_x:.1f}, {img_y:.1f})" if img_x is not None else "n/a" - print(f"[on_key] key={event.key!r} img={pos}" + print(f"[key_down] key={event.key!r} img={pos}" f" last_widget={event.last_widget_id!r}") fig # Interactive diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 90823230..70634758 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -11,10 +11,10 @@ * **Drag the point** anywhere inside the plot — the widget reports its data-space ``(x, y)`` position on every frame via the - :meth:`~anyplotlib.widgets.Widget.on_changed` callback. -* **Release** — the :meth:`~anyplotlib.widgets.Widget.on_release` callback - snaps the point's y-coordinate to the curve value at the dragged x - and draws the **tangent line** through that point. + ``pointer_move`` event handler. +* **Release** — the ``pointer_up`` event handler snaps the point's + y-coordinate to the curve value at the dragged x and draws the + **tangent line** through that point. **What is computed on release** @@ -92,13 +92,13 @@ def _draw_tangent(xq: float) -> None: # ── Callbacks ────────────────────────────────────────────────────────────── -@pt.on_changed +@pt.add_event_handler("pointer_move") def _live(event): """Every drag frame — print the current widget position.""" print(f" dragging x={event.x:.4f} y={event.y:.4f}", end="\r") -@pt.on_release +@pt.add_event_handler("pointer_up") def _settled(event): """On mouse-up — snap y to the curve and refresh the tangent line.""" print(f" released x={event.x:.4f} ") diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index e195dccc..4fb46234 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -160,7 +160,7 @@ def _refresh(): # ── Click handler ───────────────────────────────────────────────────────────── -@plot.on_click +@plot.add_event_handler("pointer_down") def _on_click(event): """Left-click → positive seed; Shift+Left-click → negative seed.""" # img_x = column, img_y = row (image-pixel coordinates) @@ -180,38 +180,44 @@ def _on_click(event): # ── Key bindings ────────────────────────────────────────────────────────────── -@plot.on_key('+') -@plot.on_key('=') # '+' on most keyboards requires Shift; '=' is the unshifted key +@plot.add_event_handler("key_down") def _tol_up(event): """Increase tolerance → flood-fill grows to wider intensity range.""" + if event.key not in ('+', '='): # '+' on most keyboards requires Shift; '=' is the unshifted key + return global tolerance tolerance = min(TOL_MAX, round(tolerance + TOL_STEP, 4)) _refresh() print(f" tolerance = {tolerance:.3f}", end="\r") -@plot.on_key('-') +@plot.add_event_handler("key_down") def _tol_down(event): """Decrease tolerance → flood-fill shrinks to narrower range.""" + if event.key != '-': + return global tolerance tolerance = max(TOL_MIN, round(tolerance - TOL_STEP, 4)) _refresh() print(f" tolerance = {tolerance:.3f}", end="\r") -@plot.on_key('c') +@plot.add_event_handler("key_down") def _clear(event): """Clear all seeds and reset the mask.""" + if event.key != 'c': + return pos_seeds.clear() neg_seeds.clear() _refresh() print(" seeds cleared", end="\r") -@plot.on_key('Delete') -@plot.on_key('Backspace') +@plot.add_event_handler("key_down") def _delete_nearest(event): """Remove the seed (positive or negative) nearest to the cursor.""" + if event.key not in ('Delete', 'Backspace'): + return cx = float(event.img_x) cy = float(event.img_y) # img_y = row diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index bdf9703a..b5cf5dc8 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -88,13 +88,13 @@ def _ring(r, r0, width, amp): whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle -@wlo.on_release +@wlo.add_event_handler("pointer_up") def _apply_low(event): """Update image display_min when the low handle is released.""" v.set_clim(vmin=event.x) -@whi.on_release +@whi.add_event_handler("pointer_up") def _apply_high(event): """Update image display_max when the high handle is released.""" v.set_clim(vmax=event.x) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 5dd4a4f4..e94303ac 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -2,6 +2,8 @@ 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 @@ -410,3 +412,63 @@ def test_hold_events_delegates_to_registry(self): 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_event_no_data_dict(self): + from anyplotlib.callbacks import Event + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") From f28dc255a9f321a806d4b8c769778ad8b6a3b712 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 18:22:24 -0500 Subject: [PATCH 23/43] refactor: replace event.data dict access with widget attribute access in Examples --- Examples/Interactive/plot_3d_spectral_viewer.py | 12 ++++++------ Examples/Interactive/plot_interactive_fft.py | 8 ++++---- Examples/Interactive/plot_interactive_fitting.py | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py index f38ef0ee..484478c9 100644 --- a/Examples/Interactive/plot_3d_spectral_viewer.py +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -124,8 +124,8 @@ def _wire_crosshair(w): """Register pointer_move handler: update spectrum on every drag frame.""" @w.add_event_handler("pointer_move") def _ch_moved(event): - cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) - cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) + cx = int(np.clip(round(event.source.cx), 0, NX - 1)) + cy = int(np.clip(round(event.source.cy), 0, NY - 1)) v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy) @@ -138,8 +138,8 @@ def _rect_moved(event): _syncing[0] = True try: x0, y0 = _snap_rect( - event.data.get("x", CX0 - ROI_PX // 2), - event.data.get("y", CY0 - ROI_PX // 2), + event.source.x, + event.source.y, ) # Push snapped, fixed-size position back so the widget visually # snaps to the pixel grid and stays exactly 8×8. @@ -212,8 +212,8 @@ def _toggle_span(event): @sw.add_event_handler("pointer_up") def _span_released(ev): - x0_e = ev.data.get("x0", float(energy[0])) - x1_e = ev.data.get("x1", float(energy[-1])) + x0_e = ev.source.x0 + x1_e = ev.source.x1 if x0_e > x1_e: x0_e, x1_e = x1_e, x0_e mask = (energy >= x0_e) & (energy <= x1_e) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 84f5d14a..fd556a63 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -160,10 +160,10 @@ def _roi_dragging(event): @wid.add_event_handler("pointer_up") def _roi_released(event): """Fires once on mouse-up — recompute and push the full FFT.""" - x0 = event.data.get("x", roi_x0) - y0 = event.data.get("y", roi_y0) - w = event.data.get("w", ROI_W) - h = event.data.get("h", ROI_H) + x0 = event.source.x + y0 = event.source.y + w = event.source.w + h = event.source.h # Restore widget colour to yellow for widget in v_real._state["overlay_widgets"]: diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index cd087603..02916a5d 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -131,8 +131,8 @@ def _peak_moved(event): return self._syncing = True try: - self.amp = event.data["y"] - self.mu = event.data["x"] + self.amp = event.source.y + self.mu = event.source.x self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K, x1=self.mu + self.sigma * _FWHM_K, y=self.amp / 2.0) @@ -148,7 +148,7 @@ def _range_moved(event): return self._syncing = True try: - x0, x1 = event.data["x0"], event.data["x1"] + x0, x1 = event.source.x0, event.source.x1 self.mu = (x0 + x1) / 2.0 self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K) self._pt.set(x=self.mu) From 7aa4ad9a1619af91762e6a2bca126f3371a13e7f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:01:43 -0500 Subject: [PATCH 24/43] fix: use event.source.x in widget handlers; remove duplicate regression test; add Plot3D/Bar coverage --- Examples/Interactive/plot_point_widget.py | 6 +++--- Examples/PlotTypes/plot_image2d.py | 4 ++-- .../tests/test_interactive/test_callbacks.py | 16 ++++++++++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 70634758..6a2fbe56 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -95,14 +95,14 @@ def _draw_tangent(xq: float) -> None: @pt.add_event_handler("pointer_move") def _live(event): """Every drag frame — print the current widget position.""" - print(f" dragging x={event.x:.4f} y={event.y:.4f}", end="\r") + print(f" dragging x={event.source.x:.4f} y={event.source.y:.4f}", end="\r") @pt.add_event_handler("pointer_up") def _settled(event): """On mouse-up — snap y to the curve and refresh the tangent line.""" - print(f" released x={event.x:.4f} ") - _draw_tangent(event.x) + print(f" released x={event.source.x:.4f} ") + _draw_tangent(event.source.x) fig # Interactive diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index b5cf5dc8..c568aff6 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -91,13 +91,13 @@ def _ring(r, r0, width, amp): @wlo.add_event_handler("pointer_up") def _apply_low(event): """Update image display_min when the low handle is released.""" - v.set_clim(vmin=event.x) + v.set_clim(vmin=event.source.x) @whi.add_event_handler("pointer_up") def _apply_high(event): """Update image display_max when the high handle is released.""" - v.set_clim(vmax=event.x) + v.set_clim(vmax=event.source.x) fig # Interactive diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index e94303ac..00404d7e 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -468,7 +468,15 @@ def test_event_no_phys_x(self): assert not hasattr(e, "phys_x") assert e.xdata == 3.14 - def test_event_no_data_dict(self): - from anyplotlib.callbacks import Event - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") + 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") From 11198eb77ed47ba9a8b5d1abe23928a95b6cd1e3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:35:32 -0500 Subject: [PATCH 25/43] fix: delete dead Line1D.on_hover shim; add regression tests for Line1D --- anyplotlib/plot1d/_plot1d.py | 14 +------------- .../tests/test_interactive/test_callbacks.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 27a19817..259e4327 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -31,8 +31,7 @@ class Line1D: """Handle to a single line on a :class:`Plot1D` panel. Returned by :meth:`Plot1D.add_line`. Use it to update the line data, - register hover/click callbacks scoped to just that line, or to remove - it later. + register event handlers scoped to just that line, or to remove it later. Attributes ---------- @@ -66,17 +65,6 @@ def __hash__(self) -> int: return hash(self._lid) # ------------------------------------------------------------------ - def on_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *this* line only.""" - target_lid = self._lid - def _filtered(event): - if event.data.get("line_id") == target_lid: - fn(event) - cid = self._plot.callbacks.connect("on_line_hover", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn - def add_event_handler(self, fn_or_type, *args, **kwargs): """Register a handler scoped to this line only. diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 00404d7e..7aff6784 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -480,3 +480,15 @@ 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") From ae060ff12776c2cd8a0043c1cf83b2586be62094 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:36:35 -0500 Subject: [PATCH 26/43] fix: replace event.img_x/img_y with event.xdata/ydata in Examples --- Examples/Interactive/plot_key_bindings.py | 20 +++++++++---------- .../Interactive/plot_segment_by_contrast.py | 12 ++++++----- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 0fd8c435..88b75c81 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -35,9 +35,9 @@ | ``s`` | Toggle symlog scale | +-------+---------------------------+ -The cursor coordinates reported in the event (``event.img_x``, -``event.img_y``) are in image-pixel space, so widgets are centred exactly -where the cursor was when the key was pressed. +The cursor coordinates are available as ``event.xdata`` and ``event.ydata`` +in image-pixel space (column, row), so widgets are centred exactly where +the cursor was when the key was pressed. .. note:: Move the mouse over the image first so the plot panel receives focus, @@ -66,7 +66,7 @@ def add_rectangle(event): """Press 'q' — add a rectangle centred on the cursor.""" if event.key != 'q': return - cx, cy = event.img_x, event.img_y + cx, cy = event.xdata, event.ydata half_w, half_h = N * 0.08, N * 0.08 plot.add_widget( "rectangle", @@ -83,7 +83,7 @@ def add_circle(event): return plot.add_widget( "circle", - cx=event.img_x, cy=event.img_y, + cx=event.xdata, cy=event.ydata, r=N * 0.07, color="#80cbc4", ) @@ -96,7 +96,7 @@ def add_annulus(event): return plot.add_widget( "annular", - cx=event.img_x, cy=event.img_y, + cx=event.xdata, cy=event.ydata, r_outer=N * 0.12, r_inner=N * 0.06, color="#ce93d8", @@ -119,10 +119,10 @@ def delete_last(event): @plot.add_event_handler("key_down") def log_key(event): - img_x = getattr(event, 'img_x', None) - img_y = getattr(event, 'img_y', None) - pos = f"({img_x:.1f}, {img_y:.1f})" if img_x is not None else "n/a" + xdata = event.xdata + ydata = event.ydata + pos = f"({xdata:.1f}, {ydata:.1f})" if xdata is not None else "n/a" print(f"[key_down] key={event.key!r} img={pos}" - f" last_widget={event.last_widget_id!r}") + f" last_widget={getattr(event, 'last_widget_id', None)!r}") fig # Interactive diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index 4fb46234..fb564000 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -29,6 +29,8 @@ +-----------------------------------+-----------------------------------------+ The current boolean mask numpy array is always accessible as ``mask``. +The cursor position is exposed as ``event.xdata`` (column) and +``event.ydata`` (row) in image-pixel coordinates. .. note:: Move the cursor over the plot so it receives keyboard focus before @@ -163,9 +165,9 @@ def _refresh(): @plot.add_event_handler("pointer_down") def _on_click(event): """Left-click → positive seed; Shift+Left-click → negative seed.""" - # img_x = column, img_y = row (image-pixel coordinates) - col = int(round(float(event.img_x))) - row = int(round(float(event.img_y))) + # xdata = column, ydata = row (image-pixel coordinates) + col = int(round(float(event.xdata))) + row = int(round(float(event.ydata))) # Clamp to image bounds col = max(0, min(N - 1, col)) row = max(0, min(N - 1, row)) @@ -218,8 +220,8 @@ def _delete_nearest(event): """Remove the seed (positive or negative) nearest to the cursor.""" if event.key not in ('Delete', 'Backspace'): return - cx = float(event.img_x) - cy = float(event.img_y) # img_y = row + cx = float(event.xdata) + cy = float(event.ydata) # ydata = row best_dist = float("inf") best_list = None From 2a12394f01cb6d53f7d98f9b294d6daf38033ab9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:39:09 -0500 Subject: [PATCH 27/43] fix: _pointerFields always null button; pointer_down/up explicitly set button: e.button --- anyplotlib/figure_esm.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 246d97af..3edd7969 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1708,7 +1708,7 @@ function render({ model, el }) { return { time_stamp: performance.now() / 1000, modifiers: _modifiers(e), - button: e.buttons !== 0 ? e.button : null, + button: null, buttons: e.buttons ?? 0, }; } @@ -1758,7 +1758,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); _emitEvent(p.id, 'pointer_up', null, { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, - ..._pointerFields(e) }); + ..._pointerFields(e), button: e.button }); _scheduleCommit(); }); @@ -2574,7 +2574,7 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e),button:e.button}); return; } if(!p.isPanning) return; @@ -2604,6 +2604,7 @@ function render({ model, el }) { xdata:physX, ydata:physY, x:_cc.mx, y:_cc.my, ..._pointerFields(e), + button:e.button, }); // _emitEvent already calls model.save_changes() — no duplicate needed. return; @@ -2613,7 +2614,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e),button:e.button}); model.save_changes(); }); @@ -2837,12 +2838,12 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; model.set(`panel_${p.id}_json`,JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e),button:e.button}); } if(p.isPanning){ p.isPanning=false; overlayCanvas.style.cursor='crosshair'; const st=p.state; - if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e)}); + if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e),button:e.button}); } // Line click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we @@ -2853,7 +2854,7 @@ function render({ model, el }) { if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); + if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e),button:e.button}); } } p._mousedownX=null; @@ -3941,7 +3942,7 @@ function render({ model, el }) { p.ovDrag = null; overlayCanvas.style.cursor = 'default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e)}); + _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e), button: e.button}); _scheduleCommit(); }); From 744515c52eff699a28fc215df85c0b7b94fe3321 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:40:19 -0500 Subject: [PATCH 28/43] fix: PlotBar pointer_down on mousedown (was click); emit bar_index: null on miss --- anyplotlib/figure_esm.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 3edd7969..35f1bd05 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -4018,12 +4018,22 @@ function render({ model, el }) { tooltip.style.display = 'none'; }); - overlayCanvas.addEventListener('click', (e) => { + overlayCanvas.addEventListener('mousedown', (e) => { if (p.ovDrag) return; const st = p.state; if (!st) return; const {mx:_cmx, my:_cmy} = _clientPos(e, overlayCanvas, p.pw, p.ph); const hit = _barHit(_cmx, _cmy); - if (hit === null) return; + const _baseFields = {..._pointerFields(e), button: e.button, x: _cmx, y: _cmy}; + if (hit === null) { + _emitEvent(p.id, 'pointer_down', null, { + bar_index: null, + group_index: null, + value: null, + x_label: null, + ..._baseFields, + }); + return; + } const { slot: idx, group: gi } = hit; const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); const val = gm.getVal(idx, gi); @@ -4035,8 +4045,7 @@ function render({ model, el }) { x_center: (st.x_centers||[])[idx] ?? idx, x_label: (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : null, - ..._pointerFields(e), - x: _cmx, y: _cmy, + ..._baseFields, }); }); From 540ebb773da6292956d5a3d5dbf4a73ae1414512 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 20:45:39 -0500 Subject: [PATCH 29/43] refactor. Removed plans --- .../plans/2026-05-14-event-system.md | 2352 ----------------- .../specs/2026-05-14-event-system-design.md | 381 --- 2 files changed, 2733 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-14-event-system.md delete mode 100644 docs/superpowers/specs/2026-05-14-event-system-design.md diff --git a/docs/superpowers/plans/2026-05-14-event-system.md b/docs/superpowers/plans/2026-05-14-event-system.md deleted file mode 100644 index d8b1774d..00000000 --- a/docs/superpowers/plans/2026-05-14-event-system.md +++ /dev/null @@ -1,2352 +0,0 @@ -# Event System Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the existing `on_click`/`on_changed`/`on_release`/`on_key` event system with pygfx-aligned `pointer_*`/`key_*` events, a flat `Event` dataclass, multi-type/wildcard/priority registration, `pause_events`/`hold_events` context managers, and `pointer_settled` with per-panel JS timer. - -**Architecture:** Python-first — rewrite `CallbackRegistry` and `Event` in `callbacks.py`, add `_EventMixin` for the user-facing API, then update all plot/widget classes to inherit it. JS changes forward new event types and add the `pointer_settled` dwell timer. All old decorator methods (`on_click`, `on_changed`, etc.) are removed. - -**Tech Stack:** Python 3.10+, dataclasses, contextlib, anywidget traitlets, Playwright for browser tests, pytest. - -**Spec:** `docs/superpowers/specs/2026-05-14-event-system-design.md` - ---- - -## File Map - -**Modified:** -- `anyplotlib/callbacks.py` — rewrite `Event`, `CallbackRegistry`; add `_EventMixin` -- `anyplotlib/figure/_figure.py` — update `_dispatch_event` field mapping; add `import time` -- `anyplotlib/plot1d/_plot1d.py` — inherit `_EventMixin`, remove old decorators, update `Line1D` -- `anyplotlib/plot2d/_plot2d.py` — same pattern -- `anyplotlib/plot2d/_plotmesh.py` — same pattern (inherits Plot2D, may need minimal changes) -- `anyplotlib/plot3d/_plot3d.py` — same pattern + `ray` field in state -- `anyplotlib/plot1d/_plotbar.py` — same pattern + updated pointer_down payload -- `anyplotlib/widgets/_base.py` — inherit `_EventMixin`, remove old decorators, update `_update_from_js` -- `anyplotlib/figure_esm.js` — forward new event types, add fields, pointer_settled timer, remove registered_keys - -**Replaced:** -- `anyplotlib/tests/test_interactive/test_callbacks.py` — full rewrite for new API - -**Created:** -- `anyplotlib/tests/test_interactive/test_event_plots.py` — Playwright per-plot-type matrix -- `anyplotlib/tests/test_interactive/test_event_settled.py` — pointer_settled Playwright tests -- `anyplotlib/tests/test_interactive/test_event_pause_hold.py` — pause/hold Playwright tests - ---- - -## Task 1: Rewrite `Event` dataclass - -Flatten `Event` — all payload fields become top-level typed attributes instead of a `data` dict with `__getattr__` proxy. - -**Files:** -- Modify: `anyplotlib/callbacks.py` -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write the failing tests** - -Replace the top of `anyplotlib/tests/test_interactive/test_callbacks.py` with: - -```python -"""Tests for the redesigned Event dataclass and CallbackRegistry.""" -from __future__ import annotations -import time -import pytest -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES - - -# ── 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" - - 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) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v -``` -Expected: FAIL — `Event` still has `data` field, `time_stamp` not auto-set, etc. - -- [ ] **Step 3: Rewrite `Event` in `callbacks.py`** - -Replace the entire `callbacks.py` with: - -```python -""" -callbacks.py -============ - -Event system used by all plot objects and widgets. - -:class:`Event` - Flat dataclass carrying all event fields as typed top-level attributes. - -:class:`CallbackRegistry` - Per-object handler store with multi-type, wildcard, priority, pause, and hold support. - -:class:`_EventMixin` - Mixin added to every plot class and widget exposing ``add_event_handler`` / - ``remove_handler`` / ``pause_events`` / ``hold_events``. -""" -from __future__ import annotations - -import time -from collections import defaultdict, deque -from contextlib import contextmanager -from dataclasses import dataclass, field -from typing import Any, Callable - -VALID_EVENT_TYPES = frozenset({ - "pointer_down", "pointer_up", "pointer_move", "pointer_settled", - "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", -}) - - -@dataclass -class Event: - """A single interactive event with all payload fields as typed attributes. - - Universal fields (every event): - event_type, source, time_stamp, modifiers - - Pointer fields (pointer_* and double_click events): - x, y — pixel coordinates within the panel - button — 0=left 1=middle 2=right; None on move/enter/leave/settled - buttons — bitmask of currently held buttons - xdata, ydata — data-space coordinates (None for Plot3D) - ray — Plot3D only: {"origin": [...], "direction": [...]} - line_id — Plot1D only: set when pointer is over a line - dwell_ms — pointer_settled only: actual dwell time - - PlotBar extra fields (pointer_down only): - bar_index, value, x_label, group_index - - Wheel fields: - dx, dy — scroll deltas - - Key fields: - key — key name e.g. "q", "Enter", "ArrowLeft" - - Propagation: - stop_propagation — set True inside a handler to halt remaining handlers - """ - event_type: str - source: Any = None - time_stamp: float = field(default_factory=time.perf_counter) - modifiers: list[str] = field(default_factory=list) - # Pointer - x: int | None = None - y: int | None = None - button: int | None = None - buttons: int = 0 - xdata: float | None = None - ydata: float | None = None - ray: dict | None = None - line_id: str | None = None - dwell_ms: float | None = None - # PlotBar - bar_index: int | None = None - value: float | None = None - x_label: str | None = None - group_index: int | None = None - # Wheel - dx: float | None = None - dy: float | None = None - # Key - key: str | None = None - # Propagation (not repr'd) - stop_propagation: bool = field(default=False, repr=False) - - def __repr__(self) -> str: - src = type(self.source).__name__ if self.source is not None else "None" - parts = [f"event_type={self.event_type!r}", f"source={src}"] - for fname in ("x", "y", "xdata", "ydata", "button", "key", - "line_id", "bar_index", "dwell_ms"): - v = getattr(self, fname) - if v is not None: - parts.append(f"{fname}={v!r}") - if self.modifiers: - parts.append(f"modifiers={self.modifiers!r}") - return "Event(" + ", ".join(parts) + ")" -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v -``` -Expected: All 11 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: flatten Event dataclass — all payload fields are typed top-level attrs" -``` - ---- - -## Task 2: Rewrite `CallbackRegistry` - -Replace the simple `_entries` dict with a per-type handler list supporting priority ordering, wildcard `"*"`, multi-type registration, and `stop_propagation`. - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append to Task 1 file) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -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"] -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v -``` -Expected: Most FAIL — old `CallbackRegistry` doesn't support priority, wildcard, `disconnect_fn`, or new event type names. - -- [ ] **Step 3: Append new `CallbackRegistry` to `callbacks.py`** - -Remove the old `CallbackRegistry` class and replace with: - -```python -class CallbackRegistry: - """Per-object handler store. - - Supports: - - Priority ordering (``order`` kwarg — lower fires first) - - Wildcard ``"*"`` type receives every dispatched event - - ``stop_propagation`` on the event halts remaining handlers - - ``disconnect_fn(fn, *types)`` removes by callback reference - - ``pause_events`` / ``hold_events`` context managers (added in Task 3) - """ - - def __init__(self) -> None: - # {event_type: [(order, cid, fn), ...]} — sorted by order - self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) - self._next_cid: int = 1 - # {cid: set[str]} — which types this cid is registered under - self._cid_map: dict[int, set[str]] = {} - # {id(fn): set[int]} — which cids this fn owns - self._fn_map: dict[int, set[int]] = defaultdict(set) - # pause/hold (populated in Task 3) - self._pause_counts: dict[str, int] = {} - self._hold_counts: dict[str, int] = {} - self._held: deque[Event] = deque() - - # ── registration ───────────────────────────────────────────────────── - - def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: - """Register fn for event_type. Returns integer CID.""" - if event_type not in VALID_EVENT_TYPES: - raise ValueError( - f"Invalid event_type {event_type!r}. " - f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" - ) - cid = self._next_cid - self._next_cid += 1 - self._handlers[event_type].append((order, cid, fn)) - self._handlers[event_type].sort(key=lambda t: t[0]) - self._cid_map.setdefault(cid, set()).add(event_type) - self._fn_map[id(fn)].add(cid) - return cid - - def disconnect(self, cid: int) -> None: - """Remove handler by CID. Silent if not found.""" - types = self._cid_map.pop(cid, set()) - for et in types: - self._handlers[et] = [ - (o, c, f) for o, c, f in self._handlers[et] if c != cid - ] - for fn_cids in self._fn_map.values(): - fn_cids.discard(cid) - - def disconnect_fn(self, fn: Callable, *types: str) -> None: - """Remove fn from the given types (all types if none given).""" - for cid in list(self._fn_map.get(id(fn), set())): - cid_types = self._cid_map.get(cid, set()) - if not types or cid_types & set(types): - self.disconnect(cid) - - # ── dispatch ───────────────────────────────────────────────────────── - - def fire(self, event: Event) -> None: - """Dispatch event to matching handlers (respects pause/hold).""" - et = event.event_type - if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: - return - if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: - self._held.append(event) - return - self._dispatch(event) - - def _dispatch(self, event: Event) -> None: - et = event.event_type - specific = list(self._handlers.get(et, [])) - wildcard = list(self._handlers.get("*", [])) - merged = sorted(specific + wildcard, key=lambda t: t[0]) - for _order, _cid, fn in merged: - if event.stop_propagation: - break - fn(event) - - def _flush(self) -> None: - while self._held: - self._dispatch(self._held.popleft()) - - def __bool__(self) -> bool: - return any(bool(v) for v in self._handlers.values()) -``` - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v -``` -Expected: All 14 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation" -``` - ---- - -## Task 3: Add `pause_events` / `hold_events` to `CallbackRegistry` - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append context managers) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -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 -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v -``` -Expected: FAIL — `pause_events`/`hold_events` not yet implemented. - -- [ ] **Step 3: Append context managers to `CallbackRegistry` in `callbacks.py`** - -Add these methods inside the `CallbackRegistry` class (after `_flush`): - -```python - @contextmanager - def pause_events(self, *types: str): - """Suppress events of the given types while inside this context. - All types are paused when called with no arguments. - Pause wins over hold for the same type.""" - target = types if types else ("*",) - for t in target: - self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 - try: - yield - finally: - for t in target: - self._pause_counts[t] -= 1 - if self._pause_counts[t] == 0: - del self._pause_counts[t] - - @contextmanager - def hold_events(self, *types: str): - """Buffer events of the given types; flush when the outermost hold exits. - All types are held when called with no arguments.""" - target = types if types else ("*",) - for t in target: - self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 - try: - yield - finally: - for t in target: - self._hold_counts[t] -= 1 - if self._hold_counts[t] == 0: - del self._hold_counts[t] - if not self._hold_counts: - self._flush() -``` - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v -``` -Expected: All 9 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "feat: add pause_events and hold_events context managers to CallbackRegistry" -``` - ---- - -## Task 4: Add `_EventMixin` to `callbacks.py` - -The mixin provides `add_event_handler`, `remove_handler`, `pause_events`, `hold_events` for every plot and widget. - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append class) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -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] -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEventMixin -v -``` -Expected: FAIL — `_EventMixin` not yet defined. - -- [ ] **Step 3: Append `_EventMixin` to `callbacks.py`** - -```python -class _EventMixin: - """Mixin for plot classes and widgets. - - Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / - ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` - in its ``__init__``. - """ - - callbacks: CallbackRegistry - - def add_event_handler( - self, - fn_or_type, - *args, - order: float = 0, - ms: int = 300, - delta: float = 4, - ): - """Register an event handler. Works as a direct call or decorator. - - Direct call:: - - plot.add_event_handler(fn, "pointer_down") - plot.add_event_handler(fn, "pointer_down", "pointer_up") - - Decorator:: - - @plot.add_event_handler("pointer_down") - def handler(event): ... - - @plot.add_event_handler("pointer_settled", ms=400, delta=5) - def on_settle(event): ... - - Parameters - ---------- - fn_or_type : callable or str - Handler function (direct call) or first event type string (decorator). - *args : str - Remaining event type strings. - order : float - Priority. Lower fires first. Default 0. - ms : int - ``pointer_settled`` dwell threshold in milliseconds. Default 300. - Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. - delta : float - ``pointer_settled`` pixel radius. Default 4. - Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. - """ - if callable(fn_or_type): - return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) - else: - all_types = (fn_or_type,) + args - def _decorator(fn: Callable) -> Callable: - self._register(fn, all_types, order=order, ms=ms, delta=delta) - return fn - return _decorator - - def _register( - self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float - ) -> Callable: - has_settled = "pointer_settled" in types - _ms_changed = ms != 300 - _delta_changed = delta != 4 - if (_ms_changed or _delta_changed) and not has_settled: - raise ValueError( - "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" - ) - for event_type in types: - self.callbacks.connect(event_type, fn, order=order) - if has_settled: - self._configure_pointer_settled(ms, delta) - fn._event_types = getattr(fn, "_event_types", set()) | set(types) - return fn - - def remove_handler(self, cid_or_fn, *types: str) -> None: - """Remove a registered handler. - - Parameters - ---------- - cid_or_fn : int or callable - CID returned by ``callbacks.connect()`` or the handler function. - *types : str - If given, only remove from these types. If omitted, remove from all. - """ - if isinstance(cid_or_fn, int): - self.callbacks.disconnect(cid_or_fn) - else: - self.callbacks.disconnect_fn(cid_or_fn, *types) - if not self.callbacks._handlers.get("pointer_settled"): - self._configure_pointer_settled(0, 0) - - def _configure_pointer_settled(self, ms: int, delta: float) -> None: - """Override in plot subclasses to push thresholds to JS.""" - pass - - @contextmanager - def pause_events(self, *types: str): - """Suppress events of the given types (all types if none given).""" - with self.callbacks.pause_events(*types): - yield - - @contextmanager - def hold_events(self, *types: str): - """Buffer events of the given types; flush when context exits.""" - with self.callbacks.hold_events(*types): - yield -``` - -Also add `_EventMixin` to the module's `__all__` export and update the top docstring. - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py -v -``` -Expected: All tests in all three test classes PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events" -``` - ---- - -## Task 5: Update `_dispatch_event` in Figure and `Widget._update_from_js` - -Map renamed JS fields (`phys_x`→`xdata`, `mouse_x`→`x`) to the flat `Event` constructor. Update widget sync. - -**Files:** -- Modify: `anyplotlib/figure/_figure.py` -- Modify: `anyplotlib/widgets/_base.py` - -- [ ] **Step 1: Add `import time` to `figure/_figure.py`** - -Find the existing imports block (around line 1-10) and add: -```python -import time -``` - -- [ ] **Step 2: Replace `_dispatch_event` in `figure/_figure.py`** - -Find the `_dispatch_event` method (currently lines ~343-397) and replace the body entirely: - -```python -def _dispatch_event(self, raw: str) -> None: - if not raw or raw == "{}": - return - try: - msg = json.loads(raw) - except Exception: - return - if msg.get("source") == "python": - return - - panel_id = msg.get("panel_id", "") - event_type = msg.get("event_type", "pointer_move") - widget_id = msg.get("widget_id") - - # Inset state changes - if event_type == "inset_state_change": - inset_ax = self._insets_map.get(panel_id) - if inset_ax is not None: - new_state = msg.get("new_state", "normal") - if new_state in ("normal", "minimized", "maximized"): - inset_ax._inset_state = new_state - self._push_layout() - return - - plot = self._plots_map.get(panel_id) - if plot is None: - return - - source = None - if widget_id and hasattr(plot, "_widgets"): - widget = plot._widgets.get(widget_id) - if widget is not None: - widget._update_from_js(msg, event_type) - source = widget - - if hasattr(plot, "callbacks"): - event = Event( - event_type=event_type, - source=source, - time_stamp=msg.get("time_stamp", time.perf_counter()), - modifiers=msg.get("modifiers", []), - x=msg.get("x"), - y=msg.get("y"), - button=msg.get("button"), - buttons=msg.get("buttons", 0), - xdata=msg.get("xdata"), - ydata=msg.get("ydata"), - ray=msg.get("ray"), - line_id=msg.get("line_id"), - dwell_ms=msg.get("dwell_ms"), - bar_index=msg.get("bar_index"), - value=msg.get("value"), - x_label=msg.get("x_label"), - group_index=msg.get("group_index"), - dx=msg.get("dx"), - dy=msg.get("dy"), - key=msg.get("key"), - ) - plot.callbacks.fire(event) -``` - -Also update the import at the top of `_figure.py` — find the `from anyplotlib.callbacks import ...` line and make sure `Event` is imported: -```python -from anyplotlib.callbacks import CallbackRegistry, Event -``` - -- [ ] **Step 3: Update `Widget._update_from_js` in `widgets/_base.py`** - -Find `_update_from_js` (currently lines ~223-253) and replace: - -```python -def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: - """Apply incoming JS state without pushing back (avoids echo). - - Updates widget ``_data`` with widget-specific state fields from JS, - then fires widget callbacks with a flat Event. - - Parameters - ---------- - msg : dict - Full raw event message from JS. - event_type : str - One of the new pointer event types (``pointer_move``, ``pointer_up``, - ``pointer_down``). - - Returns - ------- - bool - True if any widget state changed. - """ - # Fields that belong to the event envelope, not widget state - _envelope = { - "source", "panel_id", "event_type", "widget_id", - "time_stamp", "modifiers", "button", "buttons", - "x", "y", "xdata", "ydata", - } - changed = False - for k, v in msg.items(): - if k in ("id", "type") or k in _envelope: - continue - if self._data.get(k) != v: - self._data[k] = v - changed = True - - # Always fire on press/release; only fire pointer_move when state changed - if changed or event_type in ("pointer_up", "pointer_down"): - event = Event( - event_type=event_type, - source=self, - time_stamp=msg.get("time_stamp", 0.0), - modifiers=msg.get("modifiers", []), - x=msg.get("x"), - y=msg.get("y"), - button=msg.get("button"), - buttons=msg.get("buttons", 0), - xdata=msg.get("xdata"), - ydata=msg.get("ydata"), - ) - self.callbacks.fire(event) - return changed -``` - -Also update the `set` method (line ~97) which currently fires `Event("on_changed", ...)` directly: - -```python -def set(self, _push: bool = True, **kwargs) -> None: - self._data.update(kwargs) - if _push: - self._push_fn() - # Fire pointer_move for programmatic updates - self.callbacks.fire(Event("pointer_move", source=self)) -``` - -- [ ] **Step 4: Run existing Python tests to check nothing broke** - -```bash -uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive -x -``` -Expected: All non-interactive tests PASS (they don't touch event dispatch). - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/figure/_figure.py anyplotlib/widgets/_base.py -git commit -m "refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields" -``` - ---- - -## Task 6: Update `Plot1D` and `Line1D` - -Remove `on_changed`/`on_release`/`on_click`/`on_key`/`on_line_hover`/`on_line_click`/`disconnect`/`_connect_on_key`. Inherit `_EventMixin`. Update `Line1D` to expose `add_event_handler` with `line_id` filtering. Remove `registered_keys` from state. - -**Files:** -- Modify: `anyplotlib/plot1d/_plot1d.py` - -- [ ] **Step 1: Update imports in `plot1d/_plot1d.py`** - -Find the imports block and update the callbacks import: -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` - -- [ ] **Step 2: Make `Plot1D` inherit `_EventMixin`** - -Find the class definition line: -```python -class Plot1D: -``` -Change to: -```python -class Plot1D(_EventMixin): -``` - -- [ ] **Step 3: Remove `registered_keys` from `_state` in `Plot1D.__init__`** - -Find `"registered_keys": [],` in the `_state` dict initialisation and delete that line. - -- [ ] **Step 4: Add `_configure_pointer_settled` to `Plot1D`** - -After `self.callbacks = CallbackRegistry()` in `__init__`, add to the `_state` dict: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -Add this method to the `Plot1D` class: -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 5: Remove old event decorator methods from `Plot1D`** - -Delete these methods entirely (find by name): -- `on_changed` -- `on_release` -- `on_click` -- `on_key` -- `_connect_on_key` -- `on_line_hover` -- `on_line_click` -- `disconnect` - -- [ ] **Step 6: Update `Line1D` event methods** - -Replace `Line1D.on_hover` and `Line1D.on_click` with a single `add_event_handler` that filters by `line_id`: - -```python -def add_event_handler(self, fn_or_type, *args, **kwargs): - """Register a handler scoped to this line only. - - Wraps the plot-level ``pointer_move`` / ``pointer_down`` handler - with a ``line_id`` filter. Only ``pointer_move`` and ``pointer_down`` - are meaningful on a line handle. - - Usage:: - - @line.add_event_handler("pointer_move") - def on_hover(event): - print(event.xdata, event.line_id) - - @line.add_event_handler("pointer_down") - def on_pick(event): - print("picked", event.line_id) - """ - target_lid = self._lid - - if callable(fn_or_type): - fn = fn_or_type - types = args - return self._wrap_and_register(fn, types, target_lid, **kwargs) - else: - all_types = (fn_or_type,) + args - def _decorator(fn): - return self._wrap_and_register(fn, all_types, target_lid, **kwargs) - return _decorator - -def _wrap_and_register(self, fn, types, target_lid, **kwargs): - from functools import wraps - @wraps(fn) - def _filtered(event): - if event.line_id == target_lid: - fn(event) - _filtered.__wrapped__ = fn - return self._plot.add_event_handler(_filtered, *types, **kwargs) - -def remove_handler(self, cid_or_fn, *types): - """Remove a handler registered via this line handle.""" - self._plot.remove_handler(cid_or_fn, *types) -``` - -- [ ] **Step 7: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py anyplotlib/tests/test_plot1d/ -v -``` -Expected: All PASS. If `test_callbacks.py` had tests that used old `on_click` decorator on plots, update those to use `add_event_handler`. - -- [ ] **Step 8: Commit** - -```bash -git add anyplotlib/plot1d/_plot1d.py -git commit -m "refactor: Plot1D and Line1D adopt _EventMixin, remove old on_* decorators and registered_keys" -``` - ---- - -## Task 7: Update `Plot2D` and `PlotMesh` - -Same pattern as Task 6 — inherit `_EventMixin`, remove old decorators, add `_configure_pointer_settled`. - -**Files:** -- Modify: `anyplotlib/plot2d/_plot2d.py` -- Modify: `anyplotlib/plot2d/_plotmesh.py` - -- [ ] **Step 1: In `plot2d/_plot2d.py` — update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class Plot2D(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys` from `_state`, add settled config keys** - -Remove `"registered_keys": [],` from the `_state` dict. - -Add to `_state`: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled` to `Plot2D`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods from `Plot2D`** - -Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Check `PlotMesh` — it inherits `Plot2D`** - -Open `anyplotlib/plot2d/_plotmesh.py`. If `PlotMesh` also defines any of the removed methods directly, delete them. If it only inherits, no change is needed beyond checking the import line references nothing removed. - -- [ ] **Step 6: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot2d/ -v -``` -Expected: All PASS. - -- [ ] **Step 7: Commit** - -```bash -git add anyplotlib/plot2d/_plot2d.py anyplotlib/plot2d/_plotmesh.py -git commit -m "refactor: Plot2D and PlotMesh adopt _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 8: Update `Plot3D` - -Same pattern. Additionally, add `"ray": None` to the `_state` template since Plot3D pointer events carry a `ray` field instead of `xdata`/`ydata`. - -**Files:** -- Modify: `anyplotlib/plot3d/_plot3d.py` - -- [ ] **Step 1: Update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class Plot3D(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys`, add settled config** - -Remove `"registered_keys": [],` from `_state`. - -Add: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods** - -Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot3d/ -v -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/plot3d/_plot3d.py -git commit -m "refactor: Plot3D adopts _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 9: Update `PlotBar` - -Same pattern. The `pointer_down` event for PlotBar carries `bar_index`, `value`, `x_label`, `group_index` from the JS side — these are already handled by the flat `Event` constructor in `_dispatch_event`, so no extra Python work is needed beyond inheriting the mixin. - -**Files:** -- Modify: `anyplotlib/plot1d/_plotbar.py` - -- [ ] **Step 1: Update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class PlotBar(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys`, add settled config** - -Remove `"registered_keys": [],` from `_state`. - -Add: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods** - -Delete: `on_click`, `on_changed`, `on_release`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot1d/test_plotbar.py -v -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/plot1d/_plotbar.py -git commit -m "refactor: PlotBar adopts _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 10: Update `Widget` base class - -Replace `on_changed`/`on_release`/`on_click`/`disconnect` with `_EventMixin`. The `_update_from_js` was already updated in Task 5. - -**Files:** -- Modify: `anyplotlib/widgets/_base.py` - -- [ ] **Step 1: Update import** - -```python -from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin -``` - -- [ ] **Step 2: Inherit `_EventMixin`** - -```python -class Widget(_EventMixin): -``` - -- [ ] **Step 3: Remove old decorator methods** - -Delete: `on_changed`, `on_release`, `on_click`, `disconnect`. - -The `callbacks` attribute is already set in `__init__` — `_EventMixin` will find it. - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/ -v -k "widget" -``` -Expected: All widget tests PASS. - -- [ ] **Step 5: Run full Python test suite** - -```bash -uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive/test_event_plots.py \ - --ignore=anyplotlib/tests/test_interactive/test_event_settled.py \ - --ignore=anyplotlib/tests/test_interactive/test_event_pause_hold.py -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/widgets/_base.py -git commit -m "refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect" -``` - ---- - -## Task 11: JS — Forward new event types and fields - -Add the six missing event types to `figure_esm.js` and add `modifiers`, `buttons`, `button`, `time_stamp` to all emitted events. - -**Files:** -- Modify: `anyplotlib/figure_esm.js` - -This file is ~4000 lines. Search for existing mouse/key event listeners to find the right locations. - -- [ ] **Step 1: Find existing event emission sites** - -```bash -grep -n "mousedown\|mouseup\|mousemove\|keydown\|keyup\|wheel\|dblclick\|mouseenter\|mouseleave\|event_json\|event_type" anyplotlib/figure_esm.js | head -40 -``` -Note the line numbers for: mouse event listeners, the function that sends events to Python, key event handling. - -- [ ] **Step 2: Add a helper to extract common fields** - -Find where JS sends events to Python (the function that writes to `event_json`). Add a helper function near the top of the event-handling section: - -```javascript -function _pointerFields(e, panelId) { - return { - time_stamp: performance.now() / 1000, // seconds, matching perf_counter() - modifiers: _modifiers(e), - button: e.button ?? null, - buttons: e.buttons ?? 0, - }; -} - -function _modifiers(e) { - const mods = []; - if (e.ctrlKey) mods.push("ctrl"); - if (e.shiftKey) mods.push("shift"); - if (e.altKey) mods.push("alt"); - if (e.metaKey) mods.push("meta"); - return mods; -} -``` - -- [ ] **Step 3: Rename outgoing `event_type` values** - -Find all places the JS emits `event_type: "on_click"`, `"on_changed"`, `"on_release"`, `"on_key"`, `"on_line_hover"`, `"on_line_click"` and replace: - -| Old JS `event_type` | New JS `event_type` | -|---------------------|---------------------| -| `"on_click"` | `"pointer_down"` | -| `"on_changed"` | `"pointer_move"` | -| `"on_release"` | `"pointer_settled"` | -| `"on_key"` | `"key_down"` | -| `"on_line_hover"` | `"pointer_move"` (with `line_id` field already set) | -| `"on_line_click"` | `"pointer_down"` (with `line_id` field already set) | -| `"on_inset_state_change"` | `"inset_state_change"` | - -- [ ] **Step 4: Rename outgoing payload field names** - -In all JS event payloads, rename: -- `phys_x` → `xdata` -- `phys_y` → `ydata` -- `mouse_x` → `x` -- `mouse_y` → `y` - -```bash -grep -n "phys_x\|phys_y\|mouse_x\|mouse_y" anyplotlib/figure_esm.js -``` -Replace every occurrence. - -- [ ] **Step 5: Add `_pointerFields` to every emitted pointer event** - -For every place the JS calls the send-to-Python function with a pointer event, spread `_pointerFields(e, panelId)` into the payload: - -```javascript -// Before (example): -sendEvent({ event_type: "pointer_down", panel_id: panelId, x: px, y: py }); - -// After: -sendEvent({ event_type: "pointer_down", panel_id: panelId, - ..._pointerFields(e, panelId), x: px, y: py }); -``` - -- [ ] **Step 6: Add listener for `pointer_up` (mouseup)** - -Find the `mousedown` listener and add a `mouseup` listener alongside it: - -```javascript -canvas.addEventListener("mouseup", (e) => { - sendEvent({ - event_type: "pointer_up", - panel_id: panelId, - ..._pointerFields(e, panelId), - x: /* pixel x relative to canvas */, - y: /* pixel y relative to canvas */, - xdata: /* data coord x or null */, - ydata: /* data coord y or null */, - }); -}); -``` - -- [ ] **Step 7: Add `pointer_enter` / `pointer_leave` listeners** - -```javascript -canvas.addEventListener("mouseenter", (e) => { - sendEvent({ event_type: "pointer_enter", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); -}); -canvas.addEventListener("mouseleave", (e) => { - sendEvent({ event_type: "pointer_leave", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); -}); -``` - -Note: `button` is `null` on enter/leave events (no button triggered the event). `buttons` reflects currently-held buttons. - -- [ ] **Step 8: Add `double_click` listener** - -```javascript -canvas.addEventListener("dblclick", (e) => { - sendEvent({ event_type: "double_click", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/, - xdata: /*or null*/, ydata: /*or null*/ }); -}); -``` - -- [ ] **Step 9: Add `wheel` listener** - -```javascript -canvas.addEventListener("wheel", (e) => { - e.preventDefault(); - sendEvent({ event_type: "wheel", panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: _modifiers(e), - x: /*px*/, y: /*py*/, - dx: e.deltaX, dy: e.deltaY }); -}, { passive: false }); -``` - -- [ ] **Step 10: Add `key_up` listener** - -Find the existing `keydown` listener and add `keyup` alongside: - -```javascript -document.addEventListener("keyup", (e) => { - if (!panelFocused) return; - sendEvent({ event_type: "key_up", panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: _modifiers(e), - key: e.key, x: lastPointerX, y: lastPointerY }); -}); -``` - -- [ ] **Step 11: Remove `registered_keys` filtering from JS** - -Find the section that checks `registered_keys` before forwarding key events (something like `if (state.registered_keys.includes(e.key) || ...)`). Remove this guard — forward all key events unconditionally. - -- [ ] **Step 12: Run the full pure-Python test suite to confirm no regressions** - -```bash -uv run pytest anyplotlib/tests/ -v -k "not test_event_plots and not test_event_settled and not test_event_pause_hold" -``` -Expected: All PASS. - -- [ ] **Step 13: Commit** - -```bash -git add anyplotlib/figure_esm.js -git commit -m "feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event fields to xdata/ydata/x/y; add modifiers/button/buttons/time_stamp" -``` - ---- - -## Task 12: JS — `pointer_settled` dwell timer - -Add a per-panel dwell timer that fires `pointer_settled` after the pointer holds still for the configured ms/delta thresholds. - -**Files:** -- Modify: `anyplotlib/figure_esm.js` - -- [ ] **Step 1: Add timer state per panel** - -Near the per-panel state initialisation, add: - -```javascript -let _settledTimer = null; -let _settledStartX = 0; -let _settledStartY = 0; -let _settledStartTs = 0; -``` - -- [ ] **Step 2: Add `pointer_settled` trigger inside the `pointer_move` handler** - -Inside the `mousemove` / `pointer_move` emission block, after emitting `pointer_move`, add: - -```javascript -// pointer_settled dwell timer -const settledMs = panelState.pointer_settled_ms ?? 0; -const settledDelta = panelState.pointer_settled_delta ?? 4; -if (settledMs > 0) { - clearTimeout(_settledTimer); - const nowX = currentPixelX; - const nowY = currentPixelY; - const nowTs = performance.now(); - _settledStartX = nowX; - _settledStartY = nowY; - _settledStartTs = nowTs; - _settledTimer = setTimeout(() => { - const dist = Math.hypot(currentPixelX - _settledStartX, - currentPixelY - _settledStartY); - if (dist <= settledDelta) { - const dwellMs = performance.now() - _settledStartTs; - sendEvent({ - event_type: "pointer_settled", - panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: lastModifiers, - buttons: lastButtons, - button: null, - x: currentPixelX, - y: currentPixelY, - xdata: currentDataX ?? null, - ydata: currentDataY ?? null, - dwell_ms: dwellMs, - }); - } - }, settledMs); -} -``` - -Where `currentPixelX`, `currentPixelY`, `currentDataX`, `currentDataY`, `lastModifiers`, `lastButtons` are variables already tracked by the mousemove handler. - -- [ ] **Step 3: Cancel timer on `mouseup` and `mouseleave`** - -Inside the `mouseup` and `mouseleave` handlers, add: -```javascript -clearTimeout(_settledTimer); -_settledTimer = null; -``` - -- [ ] **Step 4: Commit** - -```bash -git add anyplotlib/figure_esm.js -git commit -m "feat: add pointer_settled dwell timer to JS with zero cost when unused" -``` - ---- - -## Task 13: Playwright tests — pointer events per plot type - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_plots.py` - -- [ ] **Step 1: Create the test file** - -```python -""" -Playwright tests for pointer/key events across all plot types. -Each plot type gets: pointer_down, pointer_up, pointer_move, pointer_enter, -pointer_leave, double_click, wheel, key_down, key_up, modifiers. -""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl - - -# ── helpers ────────────────────────────────────────────────────────────────── - -def _collect(page, fig, event_type): - """Return a list of event dicts received for event_type.""" - page.evaluate(f""" - window._evts_{event_type} = []; - window._aplModel.on("{event_type}", (e) => {{ - window._evts_{event_type}.push(e); - }}); - """) - return page.evaluate(f"window._evts_{event_type}") - - -def _plot1d_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(100)) - return fig - - -def _plot2d_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 400)) - ax.imshow(np.zeros((64, 64))) - return fig - - -def _plot3d_fig(): - x = np.linspace(-2, 2, 20) - y = np.linspace(-2, 2, 20) - XX, YY = np.meshgrid(x, y) - fig, ax = apl.subplots(1, 1, figsize=(400, 400)) - ax.plot_surface(XX, YY, np.zeros_like(XX)) - return fig - - -def _plotbar_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.bar(["A", "B", "C"], [1.0, 2.0, 3.0]) - return fig - - -# ── pointer_down ───────────────────────────────────────────────────────────── - -class TestPointerDown: - def test_plot1d_pointer_down_fields(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd", lambda e: received.append(json.loads(e))) - page.evaluate(""" - window._aplModel && window._aplModel.on && - window._aplModel.on("pointer_down", e => window._on_pd(JSON.stringify(e))) - """) - page.mouse.click(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "pointer_down" - assert isinstance(e["x"], (int, float)) - assert isinstance(e["y"], (int, float)) - assert e["button"] == 0 - assert e["buttons"] == 0 # buttons=0 after release - assert isinstance(e["modifiers"], list) - assert isinstance(e["time_stamp"], (int, float)) - - def test_plot2d_pointer_down_has_xdata_ydata(self, interact_page): - fig = _plot2d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd2", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_pd2(JSON.stringify(e)))" - ) - page.mouse.click(200, 200) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e.get("xdata") is not None - assert e.get("ydata") is not None - - def test_plot3d_pointer_down_no_xdata(self, interact_page): - fig = _plot3d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd3", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_pd3(JSON.stringify(e)))" - ) - page.mouse.click(200, 200) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e.get("xdata") is None - assert e.get("ydata") is None - - def test_ctrl_click_modifiers(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_ctrl", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_ctrl(JSON.stringify(e)))" - ) - page.keyboard.down("Control") - page.mouse.click(200, 150) - page.keyboard.up("Control") - page.wait_for_timeout(200) - assert any("ctrl" in e.get("modifiers", []) for e in received) - - -# ── pointer_up ──────────────────────────────────────────────────────────────── - -class TestPointerUp: - def test_fires_after_drag(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pu", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_up', e => window._on_pu(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(150, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[-1] - assert e["event_type"] == "pointer_up" - assert e["button"] == 0 - - -# ── pointer_move ────────────────────────────────────────────────────────────── - -class TestPointerMove: - def test_fires_during_drag(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pm", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_move', e => window._on_pm(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=10) - page.mouse.up() - page.wait_for_timeout(300) - assert len(received) >= 5 # multiple frames during drag - - -# ── pointer_enter / pointer_leave ───────────────────────────────────────────── - -class TestPointerEnterLeave: - def test_enter_fires_on_mouse_enter(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pe", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_enter', e => window._on_pe(JSON.stringify(e)))" - ) - # Move from outside the widget to inside - page.mouse.move(0, 0) - page.mouse.move(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "pointer_enter" - assert received[0].get("button") is None # button is None on enter - assert isinstance(received[0]["buttons"], int) - - def test_leave_fires_on_mouse_leave(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pl", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_leave', e => window._on_pl(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.move(0, 0) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "pointer_leave" - - -# ── double_click ────────────────────────────────────────────────────────────── - -class TestDoubleClick: - def test_fires_on_dblclick(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_dc", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('double_click', e => window._on_dc(JSON.stringify(e)))" - ) - page.mouse.dblclick(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "double_click" - assert received[0]["button"] == 0 - - -# ── wheel ───────────────────────────────────────────────────────────────────── - -class TestWheel: - def test_fires_on_scroll(self, interact_page): - fig = _plot2d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_wh", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('wheel', e => window._on_wh(JSON.stringify(e)))" - ) - page.mouse.move(200, 200) - page.mouse.wheel(0, 100) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "wheel" - assert e.get("dy") is not None - - -# ── key_down / key_up ───────────────────────────────────────────────────────── - -class TestKeyEvents: - def test_key_down_fires_any_key(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_kd", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('key_down', e => window._on_kd(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) # focus the panel - page.keyboard.press("r") - page.wait_for_timeout(200) - assert any(e["key"] == "r" for e in received) - - def test_key_up_fires(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_ku", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('key_up', e => window._on_ku(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.keyboard.down("q") - page.keyboard.up("q") - page.wait_for_timeout(200) - assert any(e["key"] == "q" for e in received) -``` - -- [ ] **Step 2: Run the new tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_plots.py -v -``` -Expected: All PASS. Fix any failures by adjusting pixel coordinates or widget locators to match your actual panel layout. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_plots.py -git commit -m "test: add Playwright tests for pointer_down/up/move, enter/leave, double_click, wheel, key_down/up" -``` - ---- - -## Task 14: Playwright tests — `pointer_settled` - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_settled.py` - -- [ ] **Step 1: Create the test file** - -```python -"""Tests for pointer_settled dwell timer — JS computes, Python receives.""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl -from anyplotlib.callbacks import Event - - -# ── Python-side: _configure_pointer_settled ─────────────────────────────────── - -class TestSettledConfig: - def test_state_set_on_first_connect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - assert plot._state["pointer_settled_ms"] == 0 - assert plot._state["pointer_settled_delta"] == 4 - - plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) - assert plot._state["pointer_settled_ms"] == 400 - assert plot._state["pointer_settled_delta"] == 5 - - def test_state_cleared_on_last_disconnect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - fn = lambda e: None - plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - plot.remove_handler(fn) - assert plot._state["pointer_settled_ms"] == 0 - - def test_two_handlers_keep_last_config(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - fn1 = lambda e: None - fn2 = lambda e: None - plot.add_event_handler(fn1, "pointer_settled", ms=200, delta=3) - plot.add_event_handler(fn2, "pointer_settled", ms=800, delta=6) - # Last connect wins — ms=800, delta=6 - assert plot._state["pointer_settled_ms"] == 800 - assert plot._state["pointer_settled_delta"] == 6 - # Remove fn2 — config clears only when NO handlers remain - plot.remove_handler(fn2) - # fn1 still connected → ms stays at 800 (fn1's config is remembered by registry) - assert plot._state["pointer_settled_ms"] > 0 - - -# ── Playwright: dwell timer ─────────────────────────────────────────────────── - -class TestSettledPlaywright: - def test_fires_after_hold(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - # Configure a short dwell (200ms) for fast tests - plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st(JSON.stringify(e)))" - ) - - # Move into panel and hold still - page.mouse.move(200, 150) - page.wait_for_timeout(400) # well past the 200ms threshold - - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "pointer_settled" - assert e["dwell_ms"] >= 200 - - def test_does_not_fire_if_moving(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st2", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st2(JSON.stringify(e)))" - ) - - # Keep moving — should never settle - page.mouse.move(100, 150) - page.mouse.move(150, 150, steps=5) - page.mouse.move(200, 150, steps=5) - page.mouse.move(250, 150, steps=5) - page.wait_for_timeout(100) - - assert received == [] - - def test_no_timer_when_no_handler_connected(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - # No pointer_settled handler connected — pointer_settled_ms stays 0 - - page = interact_page(fig) - # Confirm JS state has no timer configured - settled_ms = page.evaluate( - f"JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" - ) - assert settled_ms == 0 - - def test_fires_again_after_re_settle(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st3", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st3(JSON.stringify(e)))" - ) - - # First settle - page.mouse.move(200, 150) - page.wait_for_timeout(350) - - # Move and settle again - page.mouse.move(100, 150, steps=3) - page.wait_for_timeout(350) - - assert len(received) >= 2 # fired twice -``` - -- [ ] **Step 2: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_settled.py -v -``` -Expected: All PASS. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_settled.py -git commit -m "test: add pointer_settled Playwright tests including zero-cost guard" -``` - ---- - -## Task 15: Playwright tests — pause/hold integration - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_pause_hold.py` - -- [ ] **Step 1: Create the test file** - -```python -"""Integration tests for pause_events / hold_events during live interactions.""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl - - -class TestPauseIntegration: - def test_pause_drops_pointer_move_during_drag(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - received = [] - plot.add_event_handler(lambda e: received.append(1), "pointer_move") - - page = interact_page(fig) - - # Pause then trigger drag — moves should not reach handler - page.evaluate("window._aplPaused = true") # hook into test infra below - with plot.pause_events("pointer_move"): - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(200) - - assert received == [] - - # After context exits, moves should fire again - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(150, 150, steps=3) - page.mouse.up() - page.wait_for_timeout(200) - assert len(received) > 0 - - -class TestHoldIntegration: - def test_hold_buffers_settled_fires_on_exit(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) - received = [] - plot.add_event_handler(lambda e: received.append(1), "pointer_settled") - - page = interact_page(fig) - - with plot.hold_events("pointer_settled"): - page.mouse.move(200, 150) - page.wait_for_timeout(300) # settled fires → buffered - assert received == [] - - # hold context exited → flushed - assert received == [1] - - def test_hold_fires_pointer_move_immediately(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - moves = [] - settles = [] - plot.add_event_handler(lambda e: moves.append(1), "pointer_move") - plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) - plot.add_event_handler(lambda e: settles.append(1), "pointer_settled") - - page = interact_page(fig) - - with plot.hold_events("pointer_settled"): - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(300) - - assert len(moves) > 0 # pointer_move not held → fired immediately - assert len(settles) == 1 # flushed on exit -``` - -- [ ] **Step 2: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_pause_hold.py -v -``` -Expected: All PASS. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_pause_hold.py -git commit -m "test: add pause_events and hold_events Playwright integration tests" -``` - ---- - -## Task 16: Update Examples and regression tests - -**Files:** -- Modify: All `Examples/**/*.py` files that use old event API -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` (add regression block) - -- [ ] **Step 1: Find all example files using old event API** - -```bash -grep -rn "on_click\|on_changed\|on_release\|on_key\|on_hover\|\.disconnect(" Examples/ --include="*.py" -``` - -- [ ] **Step 2: Update each file** - -For each file found, replace old API calls: - -| Old | New | -|-----|-----| -| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | -| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | -| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | -| `@plot.on_key` | `@plot.add_event_handler("key_down")` | -| `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` + `if event.key == "q": return` | -| `@widget.on_changed` | `@widget.add_event_handler("pointer_move")` | -| `@widget.on_release` | `@widget.add_event_handler("pointer_up")` | -| `@widget.on_click` | `@widget.add_event_handler("pointer_down")` | -| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | -| `@line.on_click` | `@line.add_event_handler("pointer_down")` | -| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | -| `event.phys_x` | `event.xdata` | -| `event.phys_y` | `event.ydata` | -| `event.mouse_x` | `event.x` | -| `event.mouse_y` | `event.y` | - -- [ ] **Step 3: Add regression tests to `test_callbacks.py`** - -Append to `anyplotlib/tests/test_interactive/test_callbacks.py`: - -```python -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): - e = Event(event_type="pointer_down", xdata=3.14) - assert not hasattr(e, "phys_x") - assert e.xdata == 3.14 - - def test_event_no_data_dict(self): - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") -``` - -- [ ] **Step 4: Run the full test suite** - -```bash -uv run pytest anyplotlib/tests/ -v -``` -Expected: All PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Examples/ anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: update Examples to new event API; add regression tests confirming old API removed" -``` - ---- - -## Verification - -After all tasks complete, run the full suite once more: - -```bash -uv run pytest anyplotlib/tests/ -v --tb=short 2>&1 | tail -20 -``` - -Expected output ends with something like: -``` -========== NNN passed in XX.Xs ========== -``` - -with zero failures or errors. diff --git a/docs/superpowers/specs/2026-05-14-event-system-design.md b/docs/superpowers/specs/2026-05-14-event-system-design.md deleted file mode 100644 index c4c45117..00000000 --- a/docs/superpowers/specs/2026-05-14-event-system-design.md +++ /dev/null @@ -1,381 +0,0 @@ -# Event System Redesign - -**Date:** 2026-05-14 -**Status:** Approved — ready for implementation planning - -## Motivation - -The existing event system has several inconsistencies identified during a pre-0.1.0 audit: - -- `on_click` fires on mouse press (not full click cycle) — misleading name -- `on_release` means "debounced/settled" not "mouse button released" — misleading name -- `on_changed` conflates viewport pan/zoom with widget drag frames -- `phys_x`/`phys_y` are non-standard field names; matplotlib users expect `xdata`/`ydata` -- Modifier keys (ctrl, shift, alt) are not exposed on any event -- No `pointer_up`, `pointer_enter`, `pointer_leave`, `double_click`, `wheel`, or `key_up` events -- `on_key` decorator has asymmetric optional-argument syntax inconsistent with all other decorators -- `on_click` payload differs completely across plot types (coords on Plot1D/2D, bar metadata on PlotBar, no data coords on Plot3D) -- No way to pause or buffer events during batch operations - -The redesign aligns with the [pygfx/rendercanvas event system](https://github.com/pygfx/rendercanvas) naming and adds anyplotlib-specific extensions (`pointer_settled`, pause/hold). - ---- - -## Section 1: Event Types - -### Pointer events (all plot types) - -| Event | Trigger | -|-------|---------| -| `pointer_down` | Mouse/touch pressed — replaces `on_click` | -| `pointer_up` | Mouse/touch physically released — new | -| `pointer_move` | Pointer moved (drag or hover) — replaces `on_changed` | -| `pointer_settled` | Pointer held still for ≥ N ms within ± delta px — replaces `on_release`, gains explicit params | -| `pointer_enter` | Cursor enters the panel — new | -| `pointer_leave` | Cursor leaves the panel — new | -| `double_click` | Double-click / long-tap — new | -| `wheel` | Scroll wheel or pinch — new | - -### Key events (all plot types) - -| Event | Trigger | -|-------|---------| -| `key_down` | Key pressed while panel focused — replaces `on_key` | -| `key_up` | Key released — new | - -### Plot-specific behaviour - -`pointer_move` and `pointer_down` on **Plot1D** carry a `line_id` field when the pointer is over a line (`None` otherwise). These are not separate event types — the same event carries extra data. Users check `if event.line_id` to distinguish. This replaces the separate `on_line_hover` and `on_line_click` event types. - ---- - -## Section 2: Event Object Fields - -The `Event` dataclass is flattened — all fields are top-level attributes with `None` as the default when a field does not apply. No more `data` dict with attribute proxy. - -### Universal fields (every event) - -| Field | Type | Description | -|-------|------|-------------| -| `event_type` | `str` | e.g. `"pointer_down"` | -| `source` | `object` | the plot or widget that fired it | -| `time_stamp` | `float` | `perf_counter()` at fire time | -| `modifiers` | `list[str]` | `["ctrl"]`, `["shift"]`, `["alt"]`, `["meta"]` — empty list if none | - -### Pointer fields (pointer_down, pointer_up, pointer_move, pointer_settled, pointer_enter, pointer_leave, double_click) - -| Field | Type | Present on | -|-------|------|-----------| -| `x` | `int` | all pointer events — pixel x within panel | -| `y` | `int` | all pointer events — pixel y within panel | -| `button` | `int \| None` | `pointer_down`, `pointer_up`, `double_click` only — 0=left, 1=middle, 2=right; `None` on enter/leave/move/settled | -| `buttons` | `int` | all pointer events — bitmask of currently held buttons (useful on `pointer_enter` to detect dragging into panel) | -| `xdata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space x coordinate | -| `ydata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space y coordinate | -| `ray` | `dict \| None` | Plot3D only — `{"origin": [x,y,z], "direction": [dx,dy,dz]}` | -| `line_id` | `str \| None` | Plot1D only — set when pointer is over a line, `None` otherwise | -| `dwell_ms` | `float \| None` | `pointer_settled` only — actual time the pointer held still | - -### PlotBar additional fields on `pointer_down` - -| Field | Type | Description | -|-------|------|-------------| -| `bar_index` | `int \| None` | which bar was clicked; `None` if click missed all bars | -| `value` | `float \| None` | bar value | -| `x_label` | `str \| None` | category label | -| `group_index` | `int \| None` | group index for grouped bars; `None` for ungrouped | - -PlotBar `pointer_down` also carries `x`, `y`, `xdata`, `ydata` like other plot types, so all fields are available. - -### Wheel fields - -| Field | Type | Description | -|-------|------|-------------| -| `x`, `y` | `int` | pointer position at time of scroll | -| `dx`, `dy` | `float` | scroll deltas; accumulated across merged frames (matching pygfx) | - -### Key fields (key_down, key_up) - -| Field | Type | Description | -|-------|------|-------------| -| `key` | `str` | key name e.g. `"q"`, `"Enter"`, `"ArrowLeft"` | -| `x`, `y` | `int` | pointer position at time of keypress | - ---- - -## Section 3: Connection API - -The user-facing API on every plot and widget becomes `add_event_handler` / `remove_handler`. The internal `CallbackRegistry` engine (`connect`/`disconnect`/`fire`) is unchanged. - -### Functional form - -```python -# Single type -cid = plot.add_event_handler(fn, "pointer_down") - -# Multiple types in one call -cid = plot.add_event_handler(fn, "pointer_down", "pointer_up") - -# Wildcard — receives every event type -cid = plot.add_event_handler(fn, "*") - -# pointer_settled with explicit thresholds (defaults: ms=300, delta=4) -# ms/delta are only valid when "pointer_settled" is in the types list — ValueError otherwise -cid = plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - -# Priority — lower order fires first, default 0 -cid = plot.add_event_handler(fn, "pointer_move", order=-1) -``` - -### Decorator form - -```python -@plot.add_event_handler("pointer_down") -def on_press(event): - print(event.xdata, event.ydata) - -@plot.add_event_handler("pointer_down", "pointer_up") -def on_press_release(event): - print(event.event_type, event.button) - -@plot.add_event_handler("pointer_settled", ms=400, delta=5) -def on_settled(event): - update_spectrum(event.xdata, event.ydata) -``` - -### Removal - -```python -# By CID (returned from add_event_handler) -plot.remove_handler(cid) - -# By callback reference + specific types -plot.remove_handler(fn, "pointer_down") - -# By callback reference alone — removes from all types it was registered under -plot.remove_handler(fn) -``` - -### Per-line filtering on Plot1D - -Line handles returned by `ax.plot()` and `line.add_line()` expose their own `add_event_handler`. Internally this connects to the plot's `pointer_move`/`pointer_down` and filters by `line_id` — no new mechanism required. - -```python -line = ax.plot(data) -overlay = line.add_line(data2) - -@line.add_event_handler("pointer_move") -def on_hover(event): - print(event.xdata, event.line_id) - -@overlay.add_event_handler("pointer_down") -def on_pick(event): - print("picked overlay line") -``` - -### What disappears - -| Old | New | -|-----|-----| -| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | -| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | -| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | -| `@plot.on_key` / `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` | -| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | -| `@line.on_click` | `@line.add_event_handler("pointer_down")` | -| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | -| `plot.callbacks.connect("on_click", fn)` | `plot.callbacks.connect("pointer_down", fn)` | - ---- - -## Section 4: Architecture & Data Flow - -### JS changes (`figure_esm.js`) - -**New events JS must emit:** - -| JS DOM event | anyplotlib event | Notes | -|-------------|-----------------|-------| -| `mouseenter` | `pointer_enter` | per panel canvas element | -| `mouseleave` | `pointer_leave` | per panel canvas element | -| `mouseup` | `pointer_up` | previously swallowed after debounce | -| `dblclick` | `double_click` | | -| `wheel` | `wheel` | `dx`/`dy` accumulated across merged frames | -| `keyup` | `key_up` | complement to existing keydown | - -**Fields added to all emitted events:** -- `modifiers`: extracted from `ctrlKey`, `shiftKey`, `altKey`, `metaKey` -- `buttons`: from `event.buttons` bitmask (available on all MouseEvents) -- `button`: from `event.button` on press/release events -- `time_stamp`: set in JS before sending - -**`pointer_settled` timer logic (per panel):** - -``` -On pointer_move: - if panel_state.pointer_settled_ms > 0: - clearTimeout(settled_timer) - record settle_start_pos = current_pos - settled_timer = setTimeout(() => { - if distance(current_pos, settle_start_pos) <= panel_state.pointer_settled_delta: - emit pointer_settled { ...pointer fields, dwell_ms: actual_elapsed } - }, panel_state.pointer_settled_ms) -``` - -Timer is never created when `pointer_settled_ms == 0`. Cost is zero when no handler is connected. - -**Key registration removed:** `registered_keys` state field is eliminated. `key_down`/`key_up` forward all key presses unconditionally (matching pygfx). Per-key filtering moves to Python-side handler wrappers if users want it. - -### Python changes - -**`_dispatch_event()` field mapping:** - -| Old field | New field | Change | -|-----------|-----------|--------| -| `phys_x` | `xdata` | rename | -| `phys_y` | `ydata` | rename | -| `mouse_x` | `x` | rename | -| `mouse_y` | `y` | rename | -| *(absent)* | `button` | new | -| *(absent)* | `buttons` | new | -| *(absent)* | `modifiers` | new | -| *(absent)* | `time_stamp` | new | -| *(absent)* | `ray` | new (Plot3D) | -| *(absent)* | `dx`, `dy` | new (wheel) | -| *(absent)* | `dwell_ms` | new (pointer_settled) | - -**`pointer_settled` configuration flow:** - -When the first `pointer_settled` handler connects: -```python -plot._state["pointer_settled_ms"] = ms # configured threshold -plot._state["pointer_settled_delta"] = delta # configured threshold -plot._push() # JS activates timer -``` -When the last `pointer_settled` handler disconnects: -```python -plot._state["pointer_settled_ms"] = 0 # JS deactivates timer -plot._push() -``` - -**`CallbackRegistry` additions:** -1. Multi-type registration: `add_event_handler(fn, "a", "b")` registers `fn` under both internally; `remove_handler(fn)` removes from all registered types -2. Order-based priority: handlers stored as `(order, fn)` tuples, sorted on insert -3. Wildcard `"*"`: fires for every event type dispatched -4. `stop_propagation`: existing — `event.stop_propagation = True` in a handler halts remaining handlers - -### Pause and Hold - -Both are context managers implemented on `CallbackRegistry` and exposed on every plot and widget. - -**Pause (suppress):** -```python -with plot.pause_events(): # suppress all types - update_all_panels() - -with plot.pause_events("pointer_move"): # suppress specific types - do_something() -``` - -**Hold (buffer + flush):** -```python -with plot.hold_events(): # buffer all types, flush on exit - do_something() - -with plot.hold_events("pointer_settled"): # buffer specific types only - do_something() -``` - -**Nesting:** both use a depth counter — pause/hold only fully lifts when the outermost context exits. - -**Precedence:** if both are active for the same event type, pause wins — events are dropped, not buffered. - -**`CallbackRegistry` internal state:** -- `_pause_types: set[str]` — event types currently suppressed -- `_pause_depth: int` — nesting depth counter -- `_hold_types: set[str]` — event types currently buffered -- `_hold_depth: int` — nesting depth counter -- `_held_events: deque[Event]` — ordered buffer of held events - -`fire()` checks pause first (drop), then hold (queue), then dispatch. - ---- - -## Section 5: Testing Plan - -### Tier 1 — Pure Python, no browser - -**`CallbackRegistry` unit tests:** -- Multi-type registration fires handler for both types -- Wildcard `"*"` receives every event type dispatched -- Lower `order` fires before higher; same order fires in registration order -- `remove_handler` by CID -- `remove_handler` by callback reference + types -- `remove_handler` by callback reference alone removes from all types -- `stop_propagation` halts dispatch mid-handler-list -- `pause_events()`: events dropped, handlers intact after context exit -- `hold_events()`: events queued, fire in order on exit -- Pause inside hold: paused types are dropped (not buffered) -- Nested hold: depth counter lifts only on outermost exit -- `pointer_settled` params set in panel state on first connect, cleared on last disconnect - -**`Event` dataclass tests:** -- Universal fields present on every event -- `modifiers` is always a `list`, never `None` -- `time_stamp` is always set -- Plot3D events carry `ray`, not `xdata`/`ydata` -- PlotBar `pointer_down` carries bar metadata and coordinates -- `pointer_settled` carries `dwell_ms ≥` configured threshold -- `pointer_enter`/`pointer_leave` carry `buttons` (bitmask) but `button` is `None` - -### Tier 2 — Playwright browser tests - -One matrix per plot type (Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar): - -| Test | Verified | -|------|---------| -| `pointer_down` | fires on mousedown; correct `x/y`, `button=0`, `buttons=1`, `xdata/ydata` | -| `pointer_up` | fires on mouseup; `button=0`, `buttons=0` | -| `pointer_move` | fires during drag; `xdata/ydata` update correctly | -| `pointer_enter/leave` | fire when mouse crosses panel boundary | -| `double_click` | fires on dblclick; same fields as `pointer_down` | -| `wheel` | fires on scroll; `dx/dy` non-zero | -| `key_down/key_up` | fire on keypress/release; `key` field correct | -| `modifiers` | ctrl+click produces `modifiers=["ctrl"]` | -| `pointer_settled` | fires after configured ms; does NOT fire if pointer moves beyond delta | - -**Plot1D-specific:** -- `pointer_move` over a line sets `line_id`; off a line sets `line_id=None` -- `pointer_down` on a line sets `line_id` -- Line handle's `add_event_handler` filters correctly — handler on `line2` does not fire when pointer is over `line1` - -**`pointer_settled`-specific:** -- Does not fire when no handler connected (JS timer flag absent from panel state) -- `dwell_ms` on the event is ≥ configured `ms` -- Fires again after pointer moves and re-settles (resets correctly) -- Two panels with different `ms`/`delta` thresholds behave independently - -**Pause/Hold integration:** -- `pause_events()` during drag: `pointer_move` does not reach handler -- `hold_events()` during drag: events fire in order on context exit -- Type-specific hold: `hold_events("pointer_settled")` buffers settled but fires `pointer_move` immediately - -### Tier 3 — Regression - -- `on_click`, `on_changed`, `on_release`, `on_key` raise `AttributeError` (old names removed) -- `event.phys_x`, `event.phys_y` raise `AttributeError` (renamed to `xdata`/`ydata`) -- All `Examples/` files run without error after event handler updates - ---- - -## Summary of Changes - -| Area | Change | -|------|--------| -| Event names | 5 renamed, 8 new added | -| Event fields | `phys_x/y` → `xdata/ydata`, `mouse_x/y` → `x/y`; add `modifiers`, `button`, `buttons`, `time_stamp`, `ray`, `dx/dy`, `dwell_ms` | -| Connection API | `add_event_handler` / `remove_handler`; multi-type, wildcard, priority | -| `pointer_settled` | Configurable `ms`/`delta` per panel; zero cost when unused | -| Pause/Hold | Context managers on every plot and widget | -| JS layer | 6 new event types forwarded; `registered_keys` removed; timer for `pointer_settled` | -| Removed | `on_click`, `on_changed`, `on_release`, `on_key`, `on_line_hover`, `on_line_click`, `disconnect()`, `registered_keys` | From 25f6161a29f6a91aa0afbf20977860aba2ff7902 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:27:33 -0500 Subject: [PATCH 30/43] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20last=5Fwidget=5Fid=20field,=20x/y=20float=20type?= =?UTF-8?q?s,=20remove=5Fhandler=20guard,=20unused=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Examples/Interactive/plot_key_bindings.py | 2 +- anyplotlib/callbacks.py | 11 +++++++---- anyplotlib/figure/_figure.py | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 88b75c81..47ad388a 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -123,6 +123,6 @@ def log_key(event): ydata = event.ydata pos = f"({xdata:.1f}, {ydata:.1f})" if xdata is not None else "n/a" print(f"[key_down] key={event.key!r} img={pos}" - f" last_widget={getattr(event, 'last_widget_id', None)!r}") + f" last_widget={event.last_widget_id!r}") fig # Interactive diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 28eceaa2..6330c1cf 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -36,7 +36,7 @@ class Event: event_type, source, time_stamp, modifiers Pointer fields (pointer_* and double_click events): - x, y — pixel coordinates within the panel + x, y — canvas coordinates within the panel (float pixels) button — 0=left 1=middle 2=right; None on move/enter/leave/settled buttons — bitmask of currently held buttons xdata, ydata — data-space coordinates (None for Plot3D) @@ -52,6 +52,7 @@ class Event: Key fields: key — key name e.g. "q", "Enter", "ArrowLeft" + last_widget_id — id of the last widget the user clicked, or None Propagation: stop_propagation — set True inside a handler to halt remaining handlers @@ -61,8 +62,8 @@ class Event: time_stamp: float = field(default_factory=time.perf_counter) modifiers: list[str] = field(default_factory=list) # Pointer - x: int | None = None - y: int | None = None + x: float | None = None + y: float | None = None button: int | None = None buttons: int = 0 xdata: float | None = None @@ -80,6 +81,7 @@ class Event: dy: float | None = None # Key key: str | None = None + last_widget_id: str | None = None # Propagation (not repr'd) stop_propagation: bool = field(default=False, repr=False) @@ -301,11 +303,12 @@ def remove_handler(self, cid_or_fn, *types: str) -> None: *types : str If given, only remove from these types. If omitted, remove from all. """ + had_settled = bool(self.callbacks._handlers.get("pointer_settled")) if isinstance(cid_or_fn, int): self.callbacks.disconnect(cid_or_fn) else: self.callbacks.disconnect_fn(cid_or_fn, *types) - if not self.callbacks._handlers.get("pointer_settled"): + if had_settled and not self.callbacks._handlers.get("pointer_settled"): self._configure_pointer_settled(0, 0) def _configure_pointer_settled(self, ms: int, delta: float) -> None: diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index e8caff1d..0a93ee4b 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -16,7 +16,7 @@ from anyplotlib.axes import Axes, InsetAxes from anyplotlib.axes._inset_axes import _plot_kind from anyplotlib.figure._gridspec import SubplotSpec -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import Event from anyplotlib._repr_utils import repr_html_iframe _HERE = pathlib.Path(__file__).parent.parent @@ -412,6 +412,7 @@ def _dispatch_event(self, raw: str) -> None: dx=msg.get("dx"), dy=msg.get("dy"), key=msg.get("key"), + last_widget_id=msg.get("last_widget_id"), ) plot.callbacks.fire(event) From e62ca5532fcf3d3ca5ef7510ecad9a6a12a32892 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:38:37 -0500 Subject: [PATCH 31/43] =?UTF-8?q?docs:=20add=20events.rst=20=E2=80=94=20ev?= =?UTF-8?q?ent=20system=20guide=20with=20Matplotlib/pygfx=20compare=20and?= =?UTF-8?q?=20implementation-status=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/index.rst | 6 +- docs/events.rst | 630 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 12 + 3 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 docs/events.rst diff --git a/docs/api/index.rst b/docs/api/index.rst index 8cdb2ad0..6391393d 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -71,9 +71,9 @@ directly to a class or function. :octicon:`bell;2em;sd-text-info` Callbacks ^^^ - The :class:`~anyplotlib.CallbackRegistry` two-tier event system - (``on_change`` for live frames, ``on_release`` for settled state) - and the :class:`~anyplotlib.Event` dataclass. + The :class:`~anyplotlib.CallbackRegistry` event engine and the + flat :class:`~anyplotlib.Event` dataclass. + See :doc:`../events` for the full event-system guide. Figure diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 00000000..c896449c --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,630 @@ +.. _events: + +============ +Event System +============ + +anyplotlib uses a unified event system inspired by +`pygfx/rendercanvas `_ with +anyplotlib-specific extensions. Every plot class (:class:`~anyplotlib.Plot1D`, +:class:`~anyplotlib.Plot2D`, :class:`~anyplotlib.PlotMesh`, +:class:`~anyplotlib.Plot3D`, :class:`~anyplotlib.PlotBar`) and every +interactive widget shares the same API. + + +Quick start +----------- + +.. code-block:: python + + import numpy as np + import anyplotlib as apl + + fig, ax = apl.subplots(1, 1, figsize=(600, 400)) + plot = ax.imshow(np.random.default_rng(0).standard_normal((128, 128))) + + # Direct call + def on_press(event): + print(f"clicked at data ({event.xdata:.2f}, {event.ydata:.2f})") + + plot.add_event_handler(on_press, "pointer_down") + + # Decorator form — equivalent + @plot.add_event_handler("pointer_down") + def on_press(event): + print(f"clicked at data ({event.xdata:.2f}, {event.ydata:.2f})") + + # Multiple types in one call + @plot.add_event_handler("pointer_down", "pointer_up") + def on_press_release(event): + print(event.event_type, event.button) + + # Wildcard — fires for every event type + @plot.add_event_handler("*") + def log_all(event): + print(event) + + # Remove by CID + cid = plot.add_event_handler(on_press, "pointer_down") + plot.remove_handler(cid) + + # Remove by function reference + plot.remove_handler(on_press) + + +Event types +----------- + +.. list-table:: + :header-rows: 1 + :widths: 22 78 + + * - Event type + - Trigger + * - ``pointer_down`` + - Mouse button pressed inside the panel. + * - ``pointer_up`` + - Mouse button released. + * - ``pointer_move`` + - Pointer moved (drag or hover). + * - ``pointer_settled`` + - Pointer held still for ≥ *ms* milliseconds within ± *delta* pixels. + Zero-cost when no handler is connected (timer never created). + * - ``pointer_enter`` + - Cursor enters the panel. + * - ``pointer_leave`` + - Cursor leaves the panel. + * - ``double_click`` + - Double-click. + * - ``wheel`` + - Scroll wheel or trackpad pinch. + * - ``key_down`` + - Key pressed while panel has focus. + * - ``key_up`` + - Key released. + * - ``*`` + - Wildcard — handler receives every dispatched event type. + + +``pointer_settled`` thresholds +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Default: 300 ms dwell, 4-pixel radius + @plot.add_event_handler("pointer_settled") + def on_settle(event): + update_tooltip(event.xdata, event.ydata) + + # Custom thresholds + @plot.add_event_handler("pointer_settled", ms=500, delta=8) + def on_settle_slow(event): + run_expensive_query(event.xdata, event.ydata) + +The timer is activated when the first ``pointer_settled`` handler connects and +deactivated (zeroed out) when the last one disconnects, so there is no JS +overhead when the event is unused. + + +Event object +------------ + +Every handler receives a single :class:`~anyplotlib.callbacks.Event` instance. +All fields are top-level attributes — there is no nested ``data`` dict. + +Universal fields (every event) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``event_type`` + - ``str`` + - e.g. ``"pointer_down"``, ``"key_up"`` + * - ``source`` + - ``object`` + - The plot or widget that fired the event. + * - ``time_stamp`` + - ``float`` + - ``perf_counter()`` at fire time (seconds). + * - ``modifiers`` + - ``list[str]`` + - Active modifier keys: ``"ctrl"``, ``"shift"``, ``"alt"``, ``"meta"``. + Empty list when none held. + * - ``stop_propagation`` + - ``bool`` + - Set to ``True`` inside a handler to prevent remaining handlers + in the same dispatch from running. + +Pointer fields +~~~~~~~~~~~~~~ + +Present on ``pointer_down``, ``pointer_up``, ``pointer_move``, +``pointer_settled``, ``pointer_enter``, ``pointer_leave``, ``double_click``. + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``x``, ``y`` + - ``float | None`` + - Canvas pixel coordinates within the panel. + * - ``button`` + - ``int | None`` + - Which button was pressed or released: 0 = left, 1 = middle, 2 = right. + ``None`` on ``pointer_move``, ``pointer_enter``, ``pointer_leave``, + ``pointer_settled``. + * - ``buttons`` + - ``int`` + - Bitmask of *currently held* buttons (useful on ``pointer_enter`` to + detect dragging into the panel). + * - ``xdata``, ``ydata`` + - ``float | None`` + - Data-space coordinates. Available on Plot1D, Plot2D, PlotMesh. + ``None`` on Plot3D (use ``ray`` instead) and PlotBar. + * - ``ray`` + - ``dict | None`` + - Plot3D only: ``{"origin": [x,y,z], "direction": [dx,dy,dz]}``. + ``None`` on all other plot types. + * - ``line_id`` + - ``str | None`` + - Plot1D only. Set to the overlay line's ID when the pointer is over a + named line; ``None`` when over the primary line or empty space. + * - ``dwell_ms`` + - ``float | None`` + - ``pointer_settled`` only: actual elapsed dwell time in milliseconds. + +PlotBar additional fields on ``pointer_down`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 18 62 + + * - Field + - Type + - Description + * - ``bar_index`` + - ``int | None`` + - Index of the bar that was pressed; ``None`` when the press missed all + bars. + * - ``value`` + - ``float | None`` + - Bar height at ``bar_index``; ``None`` on miss. + * - ``x_label`` + - ``str | None`` + - Category label of the pressed bar; ``None`` when none is set or on miss. + * - ``group_index`` + - ``int | None`` + - Group index for grouped-bar charts; ``None`` for simple bars or on miss. + +Wheel fields +~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``x``, ``y`` + - ``float | None`` + - Pointer position at scroll time. + * - ``dx``, ``dy`` + - ``float | None`` + - Scroll deltas (positive = down/right). + +Key fields +~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``key`` + - ``str | None`` + - Key name: ``"q"``, ``"Enter"``, ``"ArrowLeft"``, etc. (DOM + ``KeyboardEvent.key`` values). + * - ``x``, ``y`` + - ``float | None`` + - Pointer position at keypress time (useful for placing UI elements at + the cursor). + * - ``last_widget_id`` + - ``str | None`` + - ID of the last overlay widget the user clicked, or ``None``. + Lets key handlers operate on the most-recently-selected widget. + + +Per-line filtering on Plot1D +---------------------------- + +Lines returned by :meth:`~anyplotlib.Plot1D.add_line` expose their own +``add_event_handler``. Internally this connects to the plot-level +``pointer_move`` / ``pointer_down`` and filters by ``line_id``, so no new +mechanism is required. + +.. code-block:: python + + t = np.linspace(0, 2 * np.pi, 256) + fig, ax = apl.subplots(1, 1, figsize=(600, 300)) + plot = ax.plot(np.sin(t)) + overlay = plot.add_line(np.cos(t), color="#ff7043") + + @overlay.add_event_handler("pointer_down") + def on_pick(event): + print(f"picked overlay line at xdata={event.xdata:.3f}") + + +Pause and hold +-------------- + +Both are context managers available on every plot and widget. + +**Pause** (suppress — events are dropped): + +.. code-block:: python + + with plot.pause_events(): # suppress all types + update_all_panels() + + with plot.pause_events("pointer_move"): # suppress specific type(s) + do_something() + +**Hold** (buffer — events are queued and flushed on exit): + +.. code-block:: python + + with plot.hold_events(): # buffer all types + do_something() + + with plot.hold_events("pointer_settled"): # buffer specific type(s) only + do_something() + +Both support nesting via a depth counter. When both are active for the same +type, *pause wins*: events are dropped, not buffered. + + +Priority ordering +----------------- + +Handlers fire in ascending ``order`` value (default ``0``). Lower values fire +first: + +.. code-block:: python + + plot.add_event_handler(fast_handler, "pointer_move", order=-1) + plot.add_event_handler(normal_handler, "pointer_move") # order=0 + plot.add_event_handler(slow_handler, "pointer_move", order=1) + + +Comparison with Matplotlib and pygfx +------------------------------------- + +Design goals: align naming with pygfx/rendercanvas (which inherits from DOM +conventions), fill the gaps in the old ``on_click``/``on_release``/``on_key`` +API, and add anyplotlib-specific extensions (``pointer_settled``, +``pause_events``, ``hold_events``). + +API mapping +~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - anyplotlib (new) + - Matplotlib equivalent + - pygfx / rendercanvas equivalent + * - ``add_event_handler(fn, "pointer_down")`` + - ``fig.canvas.mpl_connect("button_press_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_down")`` + * - ``add_event_handler(fn, "pointer_up")`` + - ``fig.canvas.mpl_connect("button_release_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_up")`` + * - ``add_event_handler(fn, "pointer_move")`` + - ``fig.canvas.mpl_connect("motion_notify_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_move")`` + * - ``add_event_handler(fn, "pointer_settled", ms=300)`` + - *(no equivalent — requires manual timer)* + - *(no equivalent)* + * - ``add_event_handler(fn, "pointer_enter")`` + - ``fig.canvas.mpl_connect("axes_enter_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_enter")`` + * - ``add_event_handler(fn, "pointer_leave")`` + - ``fig.canvas.mpl_connect("axes_leave_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_leave")`` + * - ``add_event_handler(fn, "double_click")`` + - *(detect via button_press_event + dblclick guard)* + - ``renderer.add_event_handler(fn, "double_click")`` + * - ``add_event_handler(fn, "wheel")`` + - ``fig.canvas.mpl_connect("scroll_event", fn)`` + - ``renderer.add_event_handler(fn, "wheel")`` + * - ``add_event_handler(fn, "key_down")`` + - ``fig.canvas.mpl_connect("key_press_event", fn)`` + - ``renderer.add_event_handler(fn, "key_down")`` + * - ``add_event_handler(fn, "key_up")`` + - ``fig.canvas.mpl_connect("key_release_event", fn)`` + - ``renderer.add_event_handler(fn, "key_up")`` + * - ``add_event_handler(fn, "*")`` + - *(no wildcard — register for each type separately)* + - ``renderer.add_event_handler(fn, "*")`` + * - ``plot.pause_events()`` + - *(no equivalent)* + - *(no equivalent)* + * - ``plot.hold_events()`` + - *(no equivalent)* + - *(no equivalent)* + * - ``remove_handler(cid)`` + - ``fig.canvas.mpl_disconnect(cid)`` + - ``renderer.remove_event_handler(fn, "pointer_down")`` + +Event field mapping +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 28 35 37 + + * - anyplotlib field + - Matplotlib equivalent + - pygfx equivalent + * - ``event.xdata``, ``event.ydata`` + - ``event.xdata``, ``event.ydata`` + - ``event.x``, ``event.y`` *(data-space)* + * - ``event.x``, ``event.y`` + - ``event.x``, ``event.y`` *(canvas pixels)* + - ``event.x``, ``event.y`` *(canvas pixels)* + * - ``event.button`` + - ``event.button`` (1=left, 2=middle, 3=right) + - ``event.button`` (0=left, 1=middle, 2=right) + * - ``event.modifiers`` + - ``event.key`` *(only first modifier)* + - ``event.modifiers`` *(list)* + * - ``event.key`` + - ``event.key`` + - ``event.key`` + * - ``event.dwell_ms`` + - *(absent)* + - *(absent)* + * - ``event.line_id`` + - *(absent — use pick_event)* + - *(absent)* + * - ``event.bar_index`` + - *(absent — use pick_event)* + - *(absent)* + * - ``event.ray`` + - *(absent)* + - *(absent — 3-D not a focus of rendercanvas)* + +.. note:: + Matplotlib uses a 1-based button numbering (1=left, 2=middle, 3=right). + anyplotlib and pygfx both follow the DOM convention (0=left, 1=middle, + 2=right). + + +Implementation status +--------------------- + +The table below tracks what is implemented, partially implemented, or planned +for each event type and plot class. ✓ = fully implemented, +◑ = partial / known gap, ✗ = not yet implemented. + +Event types +~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 16 16 16 16 14 + + * - Event type + - Plot1D + - Plot2D + - PlotMesh + - Plot3D + - PlotBar + * - ``pointer_down`` + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✗ |br| *(drag start,* |br| *not emitted)* + - ✓ |br| *(on mousedown)* + * - ``pointer_up`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✗ + * - ``pointer_move`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_settled`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_enter`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_leave`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``double_click`` + - ✓ + - ✓ + - ✓ + - ✗ + - ✓ + * - ``wheel`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``key_down`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``key_up`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + +.. |br| raw:: html + +
+ +Event fields +~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 16 16 16 16 14 + + * - Field + - Plot1D + - Plot2D + - PlotMesh + - Plot3D + - PlotBar + * - ``x``, ``y`` *(canvas px)* + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``button`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``buttons`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``modifiers`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``xdata``, ``ydata`` + - ✓ + - ✓ + - ✓ + - ✗ *(always None)* + - ✗ *(always None)* + * - ``ray`` + - ✗ *(always None)* + - ✗ *(always None)* + - ✗ *(always None)* + - ✗ *(not yet impl.)* + - ✗ *(always None)* + * - ``line_id`` + - ✓ + - n/a + - n/a + - n/a + - n/a + * - ``dwell_ms`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``bar_index``, ``value``, |br| ``x_label``, ``group_index`` + - n/a + - n/a + - n/a + - n/a + - ✓ *(pointer_down only)* + * - ``dx``, ``dy`` + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + * - ``last_widget_id`` + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + +Known gaps and planned work +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Gap + - Notes + * - **Plot3D** ``pointer_down`` not emitted + - Mousedown starts azimuth/elevation drag; a separate + ``pointer_down`` signal is not yet emitted. Tracked as a known + limitation. + * - **Plot3D** ``double_click`` not emitted + - The dblclick DOM listener is not attached to the 3-D canvas. + * - **Plot3D** ``pointer_up`` emits on document ``mouseup`` + - Works correctly but always emits even if the press started outside + the panel. + * - ``ray`` field not populated on Plot3D + - The ``{"origin": …, "direction": …}`` 3-D ray-cast is not yet + computed; the field is always ``None``. + * - ``pointer_down`` on Plot1D/2D/PlotMesh uses click-detection + - Fires on ``mouseup`` after a short-distance, short-duration + gesture — not on the raw ``mousedown``. This matches typical + click semantics but differs from the DOM ``mousedown`` event. + * - PlotBar ``pointer_up`` not emitted + - The bar canvas has no ``mouseup`` listener; only ``pointer_down`` + (on ``mousedown``) is emitted. + * - Touch events not supported + - ``pointer_down`` / ``pointer_move`` / ``pointer_up`` are currently + mouse-only; touch and stylus events are not forwarded. + + +API Reference +------------- + +.. seealso:: + + :class:`~anyplotlib.callbacks.Event` + Full field reference for the event dataclass. + + :class:`~anyplotlib.CallbackRegistry` + Low-level registry: ``connect``, ``disconnect``, ``fire``, + ``pause_events``, ``hold_events``. + + :doc:`api/callbacks` + Autogenerated API documentation for both classes. + + :doc:`auto_examples/index` + Gallery of interactive examples using ``add_event_handler``. diff --git a/docs/index.rst b/docs/index.rst index 5c87b902..73172960 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -89,6 +89,17 @@ blitting instead of SVG. baselines, best practices, and the CI strategy that makes timing comparisons hardware-agnostic. + .. grid-item-card:: + :link: events + :link-type: doc + + :octicon:`zap;2em;sd-text-info` Event System + ^^^ + + Interactive event handling — ``pointer_down``, ``pointer_settled``, + ``key_down``, and more. Includes a comparison with Matplotlib and + pygfx and an implementation-status table. + .. grid-item-card:: :link: dev/index :link-type: doc @@ -104,6 +115,7 @@ blitting instead of SVG. :maxdepth: 2 getting_started + events api/index auto_examples/index Performance From 3e92f32f232dc4461f9c1b31298d815cffc854fb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:43:42 -0500 Subject: [PATCH 32/43] =?UTF-8?q?test:=20fix=20flaky=20test=5Ffires=5Fagai?= =?UTF-8?q?n=5Fafter=5Fre=5Fsettle=20=E2=80=94=20poll=20with=20wait=5Ffor?= =?UTF-8?q?=5Ffunction=20instead=20of=20fixed=20sleep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_interactive/test_event_settled.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 7bad0626..8655af6f 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -163,18 +163,21 @@ def test_fires_again_after_re_settle(self, interact_page): page, plot, received = self._make_page(interact_page, ms=200) px, py = _plot_center_page() - # First dwell - page.mouse.move(px, py) - page.wait_for_timeout(300) + def _settled_count(): + return "() => window._aplAllEvents.filter(e => e.event_type === 'pointer_settled').length" - first_count = len(_get_events(page, "pointer_settled")) - assert first_count >= 1, "First pointer_settled should have fired" + # First dwell — wait for the event rather than sleeping a fixed amount + page.mouse.move(px, py) + page.wait_for_function(f"{_settled_count()} >= 1", timeout=2000) + assert len(_get_events(page, "pointer_settled")) >= 1, ( + "First pointer_settled should have fired" + ) - # Move away to reset the timer, then hold again + # Move away to reset the timer, then hold for a second dwell period page.mouse.move(px + 30, py + 30) - page.wait_for_timeout(50) + page.wait_for_timeout(50) # ensure the move is processed before re-entering page.mouse.move(px, py) - page.wait_for_timeout(300) + page.wait_for_function(f"{_settled_count()} >= 2", timeout=2000) second_count = len(_get_events(page, "pointer_settled")) assert second_count >= 2, ( From 98a1c212aad354337a06df1887f538ae080a942a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 09:52:31 -0500 Subject: [PATCH 33/43] feat: add plot_particle_picker.py EM interactive example --- Examples/Interactive/plot_particle_picker.py | 194 +++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 Examples/Interactive/plot_particle_picker.py diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py new file mode 100644 index 00000000..68aa270c --- /dev/null +++ b/Examples/Interactive/plot_particle_picker.py @@ -0,0 +1,194 @@ +""" +HAADF STEM nanoparticle picker. + +Dwell over a candidate peak (300 ms) to inspect its sub-pixel centroid, +peak intensity, and estimated FWHM. Double-click to confirm a pick (green +ring). Shift+double-click marks it as uncertain (orange ring). +Delete/Backspace removes the confirmed pick nearest the cursor. ``c`` clears +all picks. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_stem_image(rng: np.random.Generator) -> np.ndarray: + img = rng.poisson(lam=5, size=(512, 512)).astype(np.float32) + for _ in range(18): + cx, cy = rng.integers(30, 482, size=2) + sigma = rng.uniform(4, 9) + peak = rng.uniform(80, 200) + r = int(np.ceil(3 * sigma)) + y0, y1 = max(0, cy - r), min(512, cy + r + 1) + x0, x1 = max(0, cx - r), min(512, cx + r + 1) + ys = np.arange(y0, y1)[:, None] + xs = np.arange(x0, x1)[None, :] + img[y0:y1, x0:x1] += peak * np.exp( + -((xs - cx) ** 2 + (ys - cy) ** 2) / (2 * sigma ** 2) + ) + return np.clip(img, 0, 255).astype(np.float32) + + +def _find_candidates(img: np.ndarray) -> list[tuple[int, int]]: + """Local maxima via 7x7 sliding-window max filter (pure NumPy).""" + from numpy.lib.stride_tricks import sliding_window_view + pad = 3 + padded = np.pad(img, pad, mode="edge") + windows = sliding_window_view(padded, (7, 7)) + local_max = windows.max(axis=(-2, -1)) + mask = (img == local_max) & (img > 20) + ys, xs = np.where(mask) + return list(zip(xs.tolist(), ys.tolist())) + + +def _parabolic_centroid(img: np.ndarray, r: int, c: int) -> tuple[float, float]: + def _delta(left, center, right): + denom = 2 * (2 * center - left - right) + return 0.0 if abs(denom) < 1e-6 else (right - left) / denom + + dc = _delta(float(img[r, c - 1]), float(img[r, c]), float(img[r, c + 1])) + dr = _delta(float(img[r - 1, c]), float(img[r, c]), float(img[r + 1, c])) + return c + dc, r + dr + + +def _gaussian_fwhm(profile: np.ndarray) -> float: + p = np.clip(profile.astype(float), 1e-6, None) + peak_idx = int(np.argmax(p)) + if peak_idx == 0 or peak_idx >= len(p) - 1: + return 2.0 + try: + a, b, c_ = np.log(p[peak_idx - 1]), np.log(p[peak_idx]), np.log(p[peak_idx + 1]) + sigma = np.sqrt(-1.0 / (2 * (a + c_ - 2 * b))) + except Exception: + return 2.0 + return 2.355 * abs(sigma) + + +def _safe_remove(plot, marker_type: str, name: str) -> None: + try: + plot.remove_marker(marker_type, name) + except KeyError: + pass + + +# ── build data ───────────────────────────────────────────────────────────────── + +rng = np.random.default_rng(42) +image = _make_stem_image(rng) +candidates = _find_candidates(image) + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, ax = apl.subplots(1, 1, figsize=(640, 640)) +plot = ax.imshow(image, cmap="gray") + +if candidates: + cand_arr = np.array(candidates, dtype=float) + plot.add_circles(cand_arr, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + +info_label = plot.add_widget("label", x=10, y=10, text="", color="#00e5ff", fontsize=11) + +picks: list[dict] = [] + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _redraw_picks() -> None: + _safe_remove(plot, "circles", "picks_certain") + _safe_remove(plot, "circles", "picks_uncertain") + certain = [p for p in picks if not p["uncertain"]] + uncertain = [p for p in picks if p["uncertain"]] + if certain: + arr = np.array([[p["cx"], p["cy"]] for p in certain]) + plot.add_circles(arr, name="picks_certain", radius=10, + facecolors="none", edgecolors="#00ff88") + if uncertain: + arr = np.array([[p["cx"], p["cy"]] for p in uncertain]) + plot.add_circles(arr, name="picks_uncertain", radius=10, + facecolors="none", edgecolors="#ff9100") + + +def _nearest_candidate(x: float, y: float, max_dist: float = 12.0): + best, best_d = None, max_dist + for cx, cy in candidates: + d = float(np.hypot(cx - x, cy - y)) + if d < best_d: + best, best_d = (cx, cy), d + return best + + +def _nearest_pick_idx(x: float, y: float) -> int | None: + if not picks: + return None + dists = [float(np.hypot(p["cx"] - x, p["cy"] - y)) for p in picks] + return int(np.argmin(dists)) + + +def _inspect(cx_f: float, cy_f: float) -> tuple[float, float, float, float]: + """Return (sub_cx, sub_cy, intensity, fwhm) for the pixel at (cx_f, cy_f).""" + r = int(np.clip(round(cy_f), 4, 507)) + c = int(np.clip(round(cx_f), 4, 507)) + sub_cx, sub_cy = _parabolic_centroid(image, r, c) + intensity = float(image[r, c]) + row_profile = image[r, max(0, c - 4):min(512, c + 5)] + col_profile = image[max(0, r - 4):min(512, r + 5), c] + fwhm = (_gaussian_fwhm(row_profile) + _gaussian_fwhm(col_profile)) / 2 + return sub_cx, sub_cy, intensity, fwhm + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_settled(event) -> None: + hit = _nearest_candidate(event.xdata, event.ydata) + if hit is None: + info_label.set(text="") + return + hx, hy = hit + sub_cx, sub_cy, intensity, fwhm = _inspect(hx, hy) + info_label.set( + text=f"centroid ({sub_cx:.1f}, {sub_cy:.1f})\npeak {intensity:.0f}\nFWHM {fwhm:.2f} px", + x=hx + 12, + y=hy - 30, + ) + + +def _on_double_click(event) -> None: + hit = _nearest_candidate(event.xdata, event.ydata) + if hit is None: + return + sub_cx, sub_cy, intensity, fwhm = _inspect(*hit) + uncertain = "shift" in event.modifiers + picks.append({"cx": sub_cx, "cy": sub_cy, "intensity": intensity, + "fwhm": fwhm, "uncertain": uncertain}) + _redraw_picks() + tag = "uncertain" if uncertain else "certain" + print(f"Pick #{len(picks)} [{tag}]: ({sub_cx:.1f}, {sub_cy:.1f}) " + f"peak={intensity:.0f} FWHM={fwhm:.2f} px") + + +def _on_key(event) -> None: + if event.key in ("Delete", "Backspace"): + x = event.xdata if event.xdata is not None else 256.0 + y = event.ydata if event.ydata is not None else 256.0 + idx = _nearest_pick_idx(x, y) + if idx is not None: + picks.pop(idx) + _redraw_picks() + elif event.key == "c": + picks.clear() + _redraw_picks() + + +plot.add_event_handler(_on_settled, "pointer_settled", ms=300, delta=6) +plot.add_event_handler(_on_double_click, "double_click") +plot.add_event_handler(_on_key, "key_down") + +fig.set_help( + "Dwell 300 ms: inspect peak\n" + "Double-click: confirm pick (green)\n" + "Shift+double-click: uncertain pick (orange)\n" + "Delete / Backspace: remove nearest pick\n" + "c: clear all picks" +) From 0f32a303fa3dadc2a8cdefabf47926006fc0fee4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:03:24 -0500 Subject: [PATCH 34/43] feat: add plot_eels_explorer.py EM interactive example --- Examples/Interactive/plot_eels_explorer.py | 196 +++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Examples/Interactive/plot_eels_explorer.py diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py new file mode 100644 index 00000000..48ef9d89 --- /dev/null +++ b/Examples/Interactive/plot_eels_explorer.py @@ -0,0 +1,196 @@ +""" +EELS multi-spectrum explorer. + +Click a spectrum line to select it (full opacity; others dim). Dwell (250 ms) +to inspect the eV position and intensity; nearby known edges are annotated. +Double-click to place a permanent edge marker. Delete/Backspace removes the +most recently placed marker on the active spectrum. Tab / Shift+Tab cycles +the selection forward / backward. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +ENERGY = np.linspace(50, 650, 1200) + +KNOWN_EDGES = {"C K": 284.0, "N K": 401.0, "O K": 532.0, "Ti L": 456.0} + +_SPECTRUM_DEFS = [ + {"name": "Carbon-rich", "color": "#4fc3f7", "edges": [("C K", 284, 0.6)]}, + {"name": "Nitride", "color": "#aed581", "edges": [("N K", 401, 0.5)]}, + {"name": "Oxide", "color": "#ff8a65", "edges": [("O K", 532, 0.7)]}, + {"name": "Silicide", "color": "#ba68c8", "edges": [("Si L", 99, 0.3)]}, + {"name": "Mixed", "color": "#fff176", "edges": [("C K", 284, 0.2), ("O K", 532, 0.15)]}, +] + + +def _power_law_bg(E, A=1e4, r=3.5): + return A * E ** (-r) + + +def _edge_onset(E, edge_ev, amplitude, width=20.0, decay=80.0): + onset = amplitude * (np.arctan((E - edge_ev) / (width / 6)) / np.pi + 0.5) + envelope = np.exp(-np.clip(E - edge_ev, 0, None) / decay) + return onset * envelope + + +def _make_spectrum(rng, defn, offset_y): + E = ENERGY + y = _power_law_bg(E) + for _, edge_ev, amp_frac in defn["edges"]: + peak = y.max() * amp_frac + y += _edge_onset(E, edge_ev, peak) + y += rng.normal(0, y.max() * 0.005, size=len(E)) + y = np.clip(y, 0, None) + y = y / y.max() + return y + offset_y + + +rng = np.random.default_rng(7) +spectra_y = [] +offset = 0.0 +for defn in _SPECTRUM_DEFS: + y = _make_spectrum(rng, defn, offset) + spectra_y.append(y) + offset += 1.2 * (y - offset).max() + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _safe_remove(plot, marker_type: str, name: str) -> None: + try: + plot.remove_marker(marker_type, name) + except KeyError: + pass + + +# ── figure ───────────────────────────────────────────────────────────────────── + +# spectrum 0 is the primary line; spectra 1-4 are overlay lines +fig, ax = apl.subplots(1, 1, figsize=(800, 500)) +plot = ax.plot(spectra_y[0], axes=[ENERGY], color=_SPECTRUM_DEFS[0]["color"], linewidth=2.5) + +# overlay_lines[i] is the Line1D handle for spectrum i (None for the primary) +overlay_lines = [] +for i in range(1, len(_SPECTRUM_DEFS)): + defn = _SPECTRUM_DEFS[i] + line = plot.add_line(spectra_y[i], x_axis=ENERGY, color=defn["color"], linewidth=1.0) + overlay_lines.append(line) + +# spectra index → Line1D (or None for primary) +# lines[0] == None means "primary line", lines[1..] == Line1D handles +line_handles = [None] + overlay_lines # len == len(_SPECTRUM_DEFS) + +active_idx: int = 0 +markers_per_spectrum: list[list[str]] = [[] for _ in _SPECTRUM_DEFS] +_marker_counter = [0] + +info_label_mg = plot.add_texts( + offsets=np.array([[ENERGY[600], spectra_y[0][600]]]), + texts=[""], + name="info_label", + color="#00e5ff", + fontsize=11, +) + + +# ── selection helpers ─────────────────────────────────────────────────────────── + +def _set_overlay_line_props(lid: str, linewidth: float, alpha: float) -> None: + """Directly mutate an overlay line's entry in plot._state and push.""" + for entry in plot._state["extra_lines"]: + if entry["id"] == lid: + entry["linewidth"] = float(linewidth) + entry["alpha"] = float(alpha) + break + plot._push() + + +def _apply_selection(new_idx: int) -> None: + global active_idx + active_idx = new_idx + for i, handle in enumerate(line_handles): + if i == active_idx: + lw, alpha = 2.5, 1.0 + else: + lw, alpha = 1.0, 0.25 + if handle is None: + # primary line — use Plot1D setters + plot.set_linewidth(lw) + plot.set_alpha(alpha) + else: + _set_overlay_line_props(handle._lid, lw, alpha) + print(f"Selected: {_SPECTRUM_DEFS[active_idx]['name']}") + + +_apply_selection(0) + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _make_line_handler(idx: int): + def _handler(event) -> None: + _apply_selection(idx) + return _handler + + +# primary line click handler — line_id is None for the primary +plot.line.add_event_handler(_make_line_handler(0), "pointer_down") + +# overlay line click handlers +for i, handle in enumerate(overlay_lines, start=1): + handle.add_event_handler(_make_line_handler(i), "pointer_down") + + +def _on_settled(event) -> None: + ev = event.xdata + intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx])) + label = f"eV: {ev:.1f} I: {intensity:.3f}" + for edge_name, edge_ev in KNOWN_EDGES.items(): + if abs(ev - edge_ev) < 15: + label += f"\n~ {edge_name}-edge" + y_pos = intensity + 0.05 + plot.markers["texts"]["info_label"].set( + offsets=np.array([[ev, y_pos]]), + texts=[label], + ) + + +def _on_double_click(event) -> None: + ev = event.xdata + _marker_counter[0] += 1 + name = f"edge_{active_idx}_{_marker_counter[0]}" + plot.add_vlines([ev], name=name) + markers_per_spectrum[active_idx].append(name) + print(f"Edge marker placed at {ev:.1f} eV on '{_SPECTRUM_DEFS[active_idx]['name']}'") + + +def _on_key(event) -> None: + global active_idx + if event.key in ("Delete", "Backspace"): + if not markers_per_spectrum[active_idx]: + return + name = markers_per_spectrum[active_idx].pop() + _safe_remove(plot, "vlines", name) + elif event.key == "Tab": + n = len(_SPECTRUM_DEFS) + if "shift" in event.modifiers: + new_idx = (active_idx - 1) % n + else: + new_idx = (active_idx + 1) % n + _apply_selection(new_idx) + + +plot.add_event_handler(_on_settled, "pointer_settled", ms=250) +plot.add_event_handler(_on_double_click, "double_click") +plot.add_event_handler(_on_key, "key_down") + +fig.set_help( + "Click a spectrum: select it\n" + "Dwell 250 ms: inspect eV + intensity\n" + "Double-click: place edge marker\n" + "Delete / Backspace: remove last marker\n" + "Tab / Shift+Tab: cycle selection" +) From 08c910af9aa2e6a65085d47f250d7b8fff905e99 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:07:37 -0500 Subject: [PATCH 35/43] feat: add plot_threshold_explorer.py EM interactive example --- .../Interactive/plot_threshold_explorer.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Examples/Interactive/plot_threshold_explorer.py diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py new file mode 100644 index 00000000..e6491ae4 --- /dev/null +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -0,0 +1,123 @@ +""" +Live intensity thresholding on a multi-phase STEM image. + +Scroll the mouse wheel over the image to adjust the threshold (2 counts per +tick). Click a histogram bar to jump the threshold to that bin's upper edge. +Dwell (400 ms) over the image to inspect pixel intensity. The threshold mask +is shown as a red overlay; the histogram always has a vertical line at the +current threshold. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: + img = rng.normal(20, 5, (512, 512)).astype(np.float32) + + # Grain A — 6 large blobs + for _ in range(6): + cx, cy = rng.integers(60, 452, size=2) + r = rng.integers(40, 80) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(80, 8, mask.sum()) + + # Grain B — 8 smaller blobs + for _ in range(8): + cx, cy = rng.integers(40, 472, size=2) + r = rng.integers(15, 35) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(130, 10, mask.sum()) + + # Voids — 12 dark circular regions + for _ in range(12): + cx, cy = rng.integers(20, 492, size=2) + r = rng.integers(8, 20) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(5, 2, mask.sum()) + + return np.clip(img, 0, 255).astype(np.float32) + + +rng = np.random.default_rng(13) +image = _make_multiphase_image(rng) + +NBINS = 32 +counts, bin_edges = np.histogram(image, bins=NBINS, range=(0, 255)) +bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) +x_labels = [f"{int(v)}" for v in bin_centers] + +threshold = 100.0 + + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, (ax_img, ax_hist) = apl.subplots(1, 2, figsize=(900, 500)) + +img_plot = ax_img.imshow(image, cmap="gray") +hist_plot = ax_hist.bar(x_labels, counts.astype(float)) + +# Track the threshold vline widget so we can remove/replace it +_thresh_widget = None + + +def _pct_above(thresh: float) -> float: + return 100.0 * float((image >= thresh).sum()) / image.size + + +def _update_display(thresh: float) -> None: + global threshold, _thresh_widget + threshold = float(np.clip(thresh, 0, 255)) + mask = image >= threshold + img_plot.set_overlay_mask(mask, color="#ff0000", alpha=0.35) + # Remove old threshold line widget and add a new one + if _thresh_widget is not None: + try: + hist_plot.remove_widget(_thresh_widget) + except KeyError: + pass + _thresh_widget = hist_plot.add_vline_widget(threshold, color="#ffeb3b") + pct = _pct_above(threshold) + print(f"Threshold: {threshold:.0f} | {pct:.1f}% above") + + +_update_display(threshold) + +info_label = img_plot.add_widget("label", x=10, y=490, text="", color="#ffeb3b", fontsize=11) + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_wheel(event) -> None: + delta = -2.0 * np.sign(event.dy) if event.dy != 0 else 0.0 + _update_display(threshold + delta) + + +def _on_bar_click(event) -> None: + idx = event.bar_index + if idx is None: + return + new_thresh = float(bin_edges[idx + 1]) + _update_display(new_thresh) + + +def _on_settled(event) -> None: + x = int(np.clip(round(event.xdata), 0, 511)) + y = int(np.clip(round(event.ydata), 0, 511)) + intensity = float(image[y, x]) + info_label.set(text=f"px ({x}, {y}): {intensity:.0f}", x=10, y=490) + + +img_plot.add_event_handler(_on_wheel, "wheel") +img_plot.add_event_handler(_on_settled, "pointer_settled", ms=400, delta=4) +hist_plot.add_event_handler(_on_bar_click, "pointer_down") + +fig.set_help( + "Scroll over image: adjust threshold ±2\n" + "Click histogram bar: jump to bin upper edge\n" + "Dwell 400 ms over image: inspect pixel intensity" +) From dbe5bd927abaf4d2f68e9e693567b3cac9969af4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:10:44 -0500 Subject: [PATCH 36/43] feat: add plot_roi_inspector.py EM interactive example --- Examples/Interactive/plot_roi_inspector.py | 166 +++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 Examples/Interactive/plot_roi_inspector.py diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py new file mode 100644 index 00000000..a8bdc05c --- /dev/null +++ b/Examples/Interactive/plot_roi_inspector.py @@ -0,0 +1,166 @@ +""" +ROI-to-spectrum inspector for a multi-phase STEM image. + +Four rectangular ROIs are drawn on the image. Entering the image panel +activates a pixel inspector in the status label. Hovering over an ROI for +350 ms computes the mean EDS-like spectrum for that region and updates the bar +chart. Dragging an ROI pauses spectrum recomputation to avoid backlog; +releasing triggers one final recompute. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: + img = rng.normal(30, 6, (512, 512)).astype(np.float32) + + # Precipitate A (bright) + for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(160, 12, mask.sum()) + + # Precipitate B (medium) + for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(110, 10, mask.sum()) + + # Grain boundary (thin horizontal band, rows 240-270) + img[240:270, :] = rng.normal(70, 8, (30, 512)) + + return np.clip(img, 0, 255).astype(np.float32) + + +def _mean_eds(img_patch: np.ndarray) -> np.ndarray: + """4-channel EDS intensity proportional to local image value + noise.""" + mean_val = float(img_patch.mean()) + rng_local = np.random.default_rng(int(mean_val * 1000) % (2**31)) + weights = np.array([0.40, 0.25, 0.20, 0.15]) + spectrum = weights * mean_val + rng_local.normal(0, 2, 4) + return np.clip(spectrum / 255.0, 0, 1) + + +rng = np.random.default_rng(99) +image = _make_multiphase_image(rng) + +ROIS: dict[str, tuple[int, int, int, int]] = { + "Matrix": (50, 200, 50, 200), + "Precipitate A": (50, 200, 310, 460), + "Precipitate B": (310, 460, 50, 200), + "Grain Boundary":(240, 270, 50, 460), +} + +EDS_ELEMENTS = ["Al", "Si", "Fe", "O"] +_PLACEHOLDER = np.array([0.0, 0.0, 0.0, 0.0]) + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _roi_at(x: float, y: float) -> str | None: + for name, (r0, r1, c0, c1) in ROIS.items(): + if c0 <= x <= c1 and r0 <= y <= r1: + return name + return None + + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(1000, 520)) + +img_plot = ax_img.imshow(image, cmap="gray") +spec_plot = ax_spec.bar(EDS_ELEMENTS, _PLACEHOLDER) + +# ROI rectangle widgets +_roi_widgets: dict[str, object] = {} +_ROI_COLORS = {"Matrix": "#4fc3f7", "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", "Grain Boundary": "#ba68c8"} + +for roi_name, (r0, r1, c0, c1) in ROIS.items(): + w = img_plot.add_widget( + "rectangle", + x=float(c0), y=float(r0), + w=float(c1 - c0), h=float(r1 - r0), + color=_ROI_COLORS[roi_name], + ) + _roi_widgets[roi_name] = w + +status_label = img_plot.add_widget( + "label", x=10, y=498, text="Move cursor over image to inspect", + color="#ffffff", fontsize=10, +) + +_roi_dragging = False + + +# ── spectrum update ───────────────────────────────────────────────────────────── + +def _update_spectrum(roi_name: str) -> None: + r0, r1, c0, c1 = ROIS[roi_name] + patch = image[r0:r1, c0:c1] + eds = _mean_eds(patch) + spec_plot.set_data(eds) + print(f"ROI '{roi_name}': Al={eds[0]:.3f} Si={eds[1]:.3f} Fe={eds[2]:.3f} O={eds[3]:.3f}") + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_enter(event) -> None: + status_label.set(text="Pixel: — Intensity: —") + + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + + +def _on_move(event) -> None: + x = int(np.clip(round(event.xdata), 0, 511)) + y = int(np.clip(round(event.ydata), 0, 511)) + intensity = float(image[y, x]) + status_label.set(text=f"Pixel: ({x}, {y}) Intensity: {intensity:.0f}") + + +def _on_settled(event) -> None: + if _roi_dragging: + return + roi_name = _roi_at(event.xdata, event.ydata) + if roi_name is None: + print("No ROI at cursor") + return + with img_plot.hold_events("pointer_settled"): + _update_spectrum(roi_name) + + +img_plot.add_event_handler(_on_enter, "pointer_enter") +img_plot.add_event_handler(_on_leave, "pointer_leave") +img_plot.add_event_handler(_on_move, "pointer_move") +img_plot.add_event_handler(_on_settled, "pointer_settled", ms=350) + +# ROI widget drag handlers +for roi_name, widget in _roi_widgets.items(): + def _make_drag_handler(name): + def _on_drag(event) -> None: + global _roi_dragging + _roi_dragging = True + return _on_drag + + def _make_release_handler(name, wgt): + def _on_release(event) -> None: + global _roi_dragging + _roi_dragging = False + x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h + ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) + _update_spectrum(name) + return _on_release + + widget.add_event_handler(_make_drag_handler(roi_name), "pointer_move") + widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") + +fig.set_help( + "Move cursor over image: inspect pixel\n" + "Dwell 350 ms inside ROI: compute EDS spectrum\n" + "Drag ROI rectangle: repositions ROI\n" + "Release drag: recomputes spectrum" +) From 993ba53fba35b34153da5718cecb50a886c87e0e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:11:27 -0500 Subject: [PATCH 37/43] test: add smoke tests for interactive EM example scripts --- anyplotlib/tests/test_examples/__init__.py | 0 .../test_interactive_examples.py | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 anyplotlib/tests/test_examples/__init__.py create mode 100644 anyplotlib/tests/test_examples/test_interactive_examples.py diff --git a/anyplotlib/tests/test_examples/__init__.py b/anyplotlib/tests/test_examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py new file mode 100644 index 00000000..9da1699e --- /dev/null +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -0,0 +1,26 @@ +"""Smoke tests: each EM example script must import and execute without error.""" +import importlib.util +import pathlib + +import pytest + +EXAMPLES_DIR = pathlib.Path(__file__).parents[3] / "Examples" / "Interactive" + +SCRIPTS = [ + "plot_particle_picker.py", + "plot_eels_explorer.py", + "plot_threshold_explorer.py", + "plot_roi_inspector.py", +] + + +def _exec_script(name: str) -> None: + path = EXAMPLES_DIR / name + spec = importlib.util.spec_from_file_location("_smoke_ex", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + +@pytest.mark.parametrize("script", SCRIPTS) +def test_example_executes(script: str) -> None: + _exec_script(script) From 6f831ee6973224ee5fe887385b03ae42a97f2f9a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:14:36 -0500 Subject: [PATCH 38/43] fix: add xdata/ydata None guards in all EM example event handlers --- Examples/Interactive/plot_eels_explorer.py | 2 ++ Examples/Interactive/plot_particle_picker.py | 4 ++++ Examples/Interactive/plot_roi_inspector.py | 10 ++++++---- Examples/Interactive/plot_threshold_explorer.py | 2 ++ .../tests/test_examples/test_interactive_examples.py | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py index 48ef9d89..0759062e 100644 --- a/Examples/Interactive/plot_eels_explorer.py +++ b/Examples/Interactive/plot_eels_explorer.py @@ -145,6 +145,8 @@ def _handler(event) -> None: def _on_settled(event) -> None: + if event.xdata is None: + return ev = event.xdata intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx])) label = f"eV: {ev:.1f} I: {intensity:.3f}" diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py index 68aa270c..31328dde 100644 --- a/Examples/Interactive/plot_particle_picker.py +++ b/Examples/Interactive/plot_particle_picker.py @@ -141,6 +141,8 @@ def _inspect(cx_f: float, cy_f: float) -> tuple[float, float, float, float]: # ── event handlers ───────────────────────────────────────────────────────────── def _on_settled(event) -> None: + if event.xdata is None or event.ydata is None: + return hit = _nearest_candidate(event.xdata, event.ydata) if hit is None: info_label.set(text="") @@ -155,6 +157,8 @@ def _on_settled(event) -> None: def _on_double_click(event) -> None: + if event.xdata is None or event.ydata is None: + return hit = _nearest_candidate(event.xdata, event.ydata) if hit is None: return diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index a8bdc05c..a23380da 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -116,6 +116,8 @@ def _on_leave(event) -> None: def _on_move(event) -> None: + if event.xdata is None or event.ydata is None: + return x = int(np.clip(round(event.xdata), 0, 511)) y = int(np.clip(round(event.ydata), 0, 511)) intensity = float(image[y, x]) @@ -123,11 +125,11 @@ def _on_move(event) -> None: def _on_settled(event) -> None: - if _roi_dragging: + if _roi_dragging or event.xdata is None or event.ydata is None: return roi_name = _roi_at(event.xdata, event.ydata) if roi_name is None: - print("No ROI at cursor") + status_label.set(text="No ROI at cursor position") return with img_plot.hold_events("pointer_settled"): _update_spectrum(roi_name) @@ -140,7 +142,7 @@ def _on_settled(event) -> None: # ROI widget drag handlers for roi_name, widget in _roi_widgets.items(): - def _make_drag_handler(name): + def _make_drag_handler(): def _on_drag(event) -> None: global _roi_dragging _roi_dragging = True @@ -155,7 +157,7 @@ def _on_release(event) -> None: _update_spectrum(name) return _on_release - widget.add_event_handler(_make_drag_handler(roi_name), "pointer_move") + widget.add_event_handler(_make_drag_handler(), "pointer_move") widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") fig.set_help( diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py index e6491ae4..10b83450 100644 --- a/Examples/Interactive/plot_threshold_explorer.py +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -106,6 +106,8 @@ def _on_bar_click(event) -> None: def _on_settled(event) -> None: + if event.xdata is None or event.ydata is None: + return x = int(np.clip(round(event.xdata), 0, 511)) y = int(np.clip(round(event.ydata), 0, 511)) intensity = float(image[y, x]) diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index 9da1699e..cd644c87 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -16,7 +16,8 @@ def _exec_script(name: str) -> None: path = EXAMPLES_DIR / name - spec = importlib.util.spec_from_file_location("_smoke_ex", path) + mod_name = f"_smoke_ex_{path.stem}" + spec = importlib.util.spec_from_file_location(mod_name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) From 3bca3c24e3fead86efbd8f465f6f0bd56fa634d9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 09:49:20 -0500 Subject: [PATCH 39/43] feat: implement auto-sync for Figure dimensions based on GridSpec --- Examples/Interactive/plot_roi_inspector.py | 1 + anyplotlib/figure/_figure.py | 16 +- anyplotlib/figure_esm.js | 67 +++-- .../tests/test_layouts/test_gridspec.py | 233 ++++++++++++++++++ anyplotlib/tests/test_layouts/test_visual.py | 81 +++++- 5 files changed, 377 insertions(+), 21 deletions(-) diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index a23380da..2fd29644 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -166,3 +166,4 @@ def _on_release(event) -> None: "Drag ROI rectangle: repositions ROI\n" "Release drag: recomputes spectrum" ) +fig \ No newline at end of file diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 0a93ee4b..7d832dda 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -180,7 +180,21 @@ def add_subplot(self, spec) -> Axes: >>> ax2 = fig.add_subplot((0, 1)) # top-right (via tuple) """ if isinstance(spec, SubplotSpec): - pass # use as-is + # Auto-sync Figure grid to the parent GridSpec when the GridSpec is + # larger than the Figure's current dimensions. This allows the + # common workflow: + # gs = GridSpec(2, 2, height_ratios=[3, 1]) + # fig = Figure(figsize=(...)) # defaults to nrows=1, ncols=1 + # fig.add_subplot(gs[0, :]) # Figure adopts 2×2 from GridSpec + # without requiring the user to repeat nrows/ncols/ratios on Figure. + gs = spec._gs + if gs is not None: + if gs.nrows > self._nrows: + self._nrows = gs.nrows + self._height_ratios = list(gs.height_ratios) + if gs.ncols > self._ncols: + self._ncols = gs.ncols + self._width_ratios = list(gs.width_ratios) elif isinstance(spec, int): row, col = divmod(spec, self._ncols) spec = SubplotSpec(None, row, row + 1, col, col + 1) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 35f1bd05..a77874a6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -936,17 +936,17 @@ function render({ model, el }) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; const iw = st.image_width, ih = st.image_height; + // +0.5: image coordinate i is the centre of pixel i, which renders at + // (i + 0.5) * scale in the canvas — not the leading edge (i * scale). if (zoom < 1.0) { - // Zoom-out path: full image drawn centred inside a scaled-down fit-rect - // (mirrors the zoom<1 branch in _blit2d exactly). const dstW = w * zoom, dstH = h * zoom; const dstX = x + (w - dstW) / 2, dstY = y + (h - dstH) / 2; - return [dstX + (ix / iw) * dstW, dstY + (iy / ih) * dstH]; + return [dstX + (ix + 0.5) / iw * dstW, dstY + (iy + 0.5) / ih * dstH]; } const visW = iw / zoom, visH = ih / zoom; const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); - return [x + (ix - srcX) / visW * w, y + (iy - srcY) / visH * h]; + return [x + (ix + 0.5 - srcX) / visW * w, y + (iy + 0.5 - srcY) / visH * h]; } // Returns canvas-px per image-px at the current zoom (uniform in x and y). @@ -994,26 +994,25 @@ function render({ model, el }) { if(!b64||iw===0||ih===0){ctx.clearRect(0,0,imgW,imgH);return;} - let bytes; - try { - const bin=atob(b64); - bytes=new Uint8Array(bin.length); - for(let i=0;i { if (!dragStart) return; @@ -2541,7 +2540,10 @@ function render({ model, el }) { panStart={mx,my,cx:st.center_x,cy:st.center_y}; // Track potential click: distance + time guards distinguish click from pan. p.clickCandidate={mx,my,t:Date.now(),shiftKey:e.shiftKey}; - p.isPanning=true; overlayCanvas.style.cursor='grabbing'; e.preventDefault(); + p.isPanning=true; overlayCanvas.style.cursor='grabbing'; + // Do NOT call e.preventDefault() here: Chrome suppresses the click event + // when mousedown is cancelled, which in turn prevents dblclick from firing. + // Panning's preventDefault lives in the mousemove handler (prevents scroll). }); document.addEventListener('mousemove',(e)=>{ if(p.ovDrag2d){ @@ -2678,6 +2680,14 @@ function render({ model, el }) { 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, @@ -2685,6 +2695,10 @@ function render({ model, el }) { 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, }); } @@ -2698,9 +2712,15 @@ function render({ model, el }) { if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); overlayCanvas.addEventListener('dblclick',(e)=>{ + const st=p.state; if(!st) return; const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); + const [imgX,imgY]=_canvasToImg2d(mx,my,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const _iw=st.image_width||1, _ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,img_x:imgX,img_y:imgY,xdata:physX,ydata:physY}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -2807,7 +2827,8 @@ function render({ model, el }) { if(hit){p.ovDrag=hit;p.lastWidgetId=(p.state.overlay_widgets||[])[hit.idx]?.id||null;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;} // Store pan start in canvas-px so pan delta in mousemove is canvas-px. panStart={mx:_emx,x0:st.view_x0,x1:st.view_x1}; - p.isPanning=true;overlayCanvas.style.cursor='grabbing';e.preventDefault(); + p.isPanning=true;overlayCanvas.style.cursor='grabbing'; + // Do NOT call e.preventDefault() — see 2D note: suppresses click → dblclick. }); document.addEventListener('mousemove',(e)=>{ if(p.ovDrag){ @@ -2967,7 +2988,15 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick',(e)=>{ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); + const st=p.state; + let xdata=null; + if(st){ + 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; + } + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -4076,7 +4105,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my, xdata: null}); }); overlayCanvas.addEventListener('wheel', (e) => { _emitEvent(p.id, 'wheel', null, { diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index 3d9df833..572f6414 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -838,7 +838,240 @@ def test_plot_areas_positive(self): assert h > 0, f"Panel {pid}: plot area height must be positive, got {h}" +# ───────────────────────────────────────────────────────────────────────────── +# Part 9 – Figure + GridSpec workflow (bare Figure auto-syncs to GridSpec) +# ───────────────────────────────────────────────────────────────────────────── + +class TestFigureGridSpecWorkflow: + """Tests for the Figure + GridSpec workflow where Figure is created without + explicit nrows/ncols and auto-syncs its grid from the parent GridSpec. + + The typical pattern under test:: + + gs = GridSpec(2, 2, height_ratios=[3, 1]) + fig = Figure(figsize=(800, 600)) # defaults to nrows=1, ncols=1 + ax = fig.add_subplot(gs[0, :]) # Figure adopts 2×2 grid from gs + + Without the auto-sync, panels at row_start≥1 would get ph=0 (floored to 64) + because the Figure only knows about 1 row track. + """ + + def test_auto_sync_nrows_from_gridspec(self): + """Figure auto-updates _nrows when GridSpec has more rows.""" + gs = GridSpec(2, 1) + fig = Figure(figsize=(400, 400)) + fig.add_subplot(gs[0, 0]) + fig.add_subplot(gs[1, 0]) + assert fig._nrows == 2, f"nrows should auto-sync to 2, got {fig._nrows}" + assert fig._ncols == 1 + + def test_auto_sync_ncols_from_gridspec(self): + """Figure auto-updates _ncols when GridSpec has more columns.""" + gs = GridSpec(1, 3) + fig = Figure(figsize=(600, 200)) + fig.add_subplot(gs[0, 0]) + fig.add_subplot(gs[0, 1]) + fig.add_subplot(gs[0, 2]) + assert fig._ncols == 3, f"ncols should auto-sync to 3, got {fig._ncols}" + assert fig._nrows == 1 + + def test_auto_sync_height_ratios_from_gridspec(self): + """height_ratios from the GridSpec are adopted into the Figure.""" + gs = GridSpec(2, 1, height_ratios=[3, 1]) + fig = Figure(figsize=(400, 800)) + fig.add_subplot(gs[0, 0]) + assert fig._height_ratios == [3, 1], ( + f"height_ratios should be [3, 1], got {fig._height_ratios}" + ) + + def test_auto_sync_width_ratios_from_gridspec(self): + """width_ratios from the GridSpec are adopted into the Figure.""" + gs = GridSpec(1, 2, width_ratios=[2, 1]) + fig = Figure(figsize=(600, 200)) + fig.add_subplot(gs[0, 0]) + assert fig._width_ratios == [2, 1], ( + f"width_ratios should be [2, 1], got {fig._width_ratios}" + ) + + def test_gridspec_height_ratios_applied_to_sizes(self): + """Panels at correct heights according to GridSpec height_ratios.""" + gs = GridSpec(2, 1, height_ratios=[3, 1]) + fig = Figure(figsize=(400, 800)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + s = _sizes(fig) + ph0 = s[v0._id][1] + ph1 = s[v1._id][1] + assert approx(ph0, 600, tol=2), ( + f"top panel should be 600px (3/4 of 800), got {ph0}" + ) + assert approx(ph1, 200, tol=2), ( + f"bottom panel should be 200px (1/4 of 800), got {ph1}" + ) + assert approx(ph0, 3 * ph1, tol=3), ( + f"3:1 height ratio not met: {ph0} vs {ph1}" + ) + + def test_gridspec_width_ratios_applied_to_sizes(self): + """Panels at correct widths according to GridSpec width_ratios.""" + gs = GridSpec(1, 2, width_ratios=[2, 1]) + fig = Figure(figsize=(600, 200)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[0, 1]).plot(np.zeros(10)) + s = _sizes(fig) + pw0 = s[v0._id][0] + pw1 = s[v1._id][0] + assert approx(pw0, 400, tol=2), ( + f"left panel should be 400px (2/3 of 600), got {pw0}" + ) + assert approx(pw1, 200, tol=2), ( + f"right panel should be 200px (1/3 of 600), got {pw1}" + ) + + def test_two_spectra_side_by_side_not_squished(self): + """Two 1D spectra side by side must each get half the figure width.""" + gs = GridSpec(1, 2) + fig = Figure(figsize=(800, 300)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(100)) + v1 = fig.add_subplot(gs[0, 1]).plot(np.zeros(100)) + s = _sizes(fig) + pw0, ph0 = s[v0._id] + pw1, ph1 = s[v1._id] + assert approx(pw0, 400, tol=2), ( + f"left spectrum should be 400px wide, got {pw0}" + ) + assert approx(pw1, 400, tol=2), ( + f"right spectrum should be 400px wide, got {pw1}" + ) + assert ph0 == ph1 == 300, ( + f"both spectra should be 300px tall: {ph0}, {ph1}" + ) + # Inner plot area must be substantial (not 64px-floor squished) + inner_w = pw0 - PAD_L - PAD_R + assert inner_w > 200, ( + f"inner plot width should be >200px, got {inner_w} " + f"(panel was squished if ≤64)" + ) + + def test_image_and_two_spectra_correct_ratios(self): + """Image spanning top row (3×), two spectra below (1×) side by side. + + This is the canonical use-case the bug report describes: when using + GridSpec with a bare Figure, the second-row spectra used to get floored + to 64px because Figure._height_ratios had only 1 track. + """ + gs = GridSpec(2, 2, height_ratios=[3, 1]) + fig = Figure(figsize=(800, 800)) + v_img = fig.add_subplot(gs[0, :]).imshow(np.zeros((64, 64))) + v_sp1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(100)) + v_sp2 = fig.add_subplot(gs[1, 1]).plot(np.zeros(100)) + s = _sizes(fig) + + pw_img, ph_img = s[v_img._id] + pw_sp1, ph_sp1 = s[v_sp1._id] + pw_sp2, ph_sp2 = s[v_sp2._id] + + # Image spans full width + assert pw_img == 800, f"image should span full width 800, got {pw_img}" + # Image gets 3/4 of height = 600px + assert approx(ph_img, 600, tol=2), ( + f"image should be 600px tall (3/4 of 800), got {ph_img}" + ) + # Each spectrum gets half width + assert approx(pw_sp1, 400, tol=2), ( + f"left spectrum width should be 400, got {pw_sp1}" + ) + assert approx(pw_sp2, 400, tol=2), ( + f"right spectrum width should be 400, got {pw_sp2}" + ) + # Spectra get 1/4 of height = 200px (not 64px floor!) + assert approx(ph_sp1, 200, tol=2), ( + f"spectrum height should be 200px (1/4 of 800), not 64 floor, got {ph_sp1}" + ) + assert ph_sp1 == ph_sp2, ( + f"both spectra must have the same height: {ph_sp1} vs {ph_sp2}" + ) + def test_explicit_figure_dims_beat_smaller_gridspec(self): + """When Figure has explicit nrows/ncols >= GridSpec, Figure values win.""" + gs = GridSpec(2, 1, height_ratios=[1, 1]) # equal ratios + fig = Figure(2, 1, figsize=(400, 800), height_ratios=[3, 1]) # explicit 3:1 + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + s = _sizes(fig) + ph0 = s[v0._id][1] + ph1 = s[v1._id][1] + # Figure's [3:1] must win over GridSpec's [1:1] + assert approx(ph0, 600, tol=2), ( + f"Figure's 3:1 ratio must be preserved: top={ph0}, expected 600" + ) + assert approx(ph1, 200, tol=2), ( + f"Figure's 3:1 ratio must be preserved: bottom={ph1}, expected 200" + ) + + def test_layout_json_nrows_ncols_after_auto_sync(self): + """layout_json must reflect the auto-synced nrows/ncols.""" + gs = GridSpec(3, 2) + fig = Figure(figsize=(600, 600)) + fig.add_subplot(gs[0, 0]).plot(np.zeros(5)) + fig.add_subplot(gs[1, 0]).plot(np.zeros(5)) + fig.add_subplot(gs[2, 0]).plot(np.zeros(5)) + layout = _layout(fig) + assert layout["nrows"] == 3, ( + f"layout_json nrows should be 3, got {layout['nrows']}" + ) + assert layout["ncols"] == 2, ( + f"layout_json ncols should be 2, got {layout['ncols']}" + ) + + def test_second_row_panel_not_floored_to_64(self): + """Regression: panel at row_start=1 with a 1-row Figure used to be floored to 64px.""" + gs = GridSpec(2, 1) + fig = Figure(figsize=(400, 400)) + _ = fig.add_subplot(gs[0, 0]).plot(np.zeros(5)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(5)) + s = _sizes(fig) + ph1 = s[v1._id][1] + assert ph1 > 64, ( + f"Row-1 panel must NOT be floored to 64px; got ph={ph1}. " + "This indicates the Figure failed to auto-sync its nrows from the GridSpec." + ) + assert approx(ph1, 200, tol=2), ( + f"Row-1 panel should be 200px (half of 400), got {ph1}" + ) + + def test_three_row_gridspec_all_panels_correct_height(self): + """All three panels in a 3-row GridSpec (equal ratios) get 1/3 of height.""" + gs = GridSpec(3, 1) + fig = Figure(figsize=(400, 600)) + plots = [fig.add_subplot(gs[r, 0]).plot(np.zeros(5)) for r in range(3)] + s = _sizes(fig) + for i, v in enumerate(plots): + ph = s[v._id][1] + assert approx(ph, 200, tol=2), ( + f"Panel {i} should be 200px (1/3 of 600), got {ph}" + ) + + def test_spanning_subplot_correct_size(self): + """gs[0, :] spanning all columns must get the full figure width.""" + gs = GridSpec(2, 3, height_ratios=[2, 1]) + fig = Figure(figsize=(900, 600)) + v_top = fig.add_subplot(gs[0, :]).plot(np.zeros(10)) # spans 3 cols + v_bl = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + v_bm = fig.add_subplot(gs[1, 1]).plot(np.zeros(10)) + v_br = fig.add_subplot(gs[1, 2]).plot(np.zeros(10)) + s = _sizes(fig) + + pw_top, ph_top = s[v_top._id] + assert pw_top == 900, f"spanning subplot should be full width 900, got {pw_top}" + assert approx(ph_top, 400, tol=2), ( + f"spanning subplot should be 400px (2/3 of 600), got {ph_top}" + ) + # Bottom row: each panel = 300px wide, 200px tall + for label, v in [("bottom-left", v_bl), ("bottom-mid", v_bm), ("bottom-right", v_br)]: + pw, ph = s[v._id] + assert approx(pw, 300, tol=2), f"{label} width should be 300, got {pw}" + assert approx(ph, 200, tol=2), f"{label} height should be 200, got {ph}" diff --git a/anyplotlib/tests/test_layouts/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py index 2a0f71c5..e94f341e 100644 --- a/anyplotlib/tests/test_layouts/test_visual.py +++ b/anyplotlib/tests/test_layouts/test_visual.py @@ -38,7 +38,7 @@ import anyplotlib as apl from anyplotlib.tests._png_utils import decode_png, encode_png, compare_arrays -BASELINES = pathlib.Path(__file__).parent / "baselines" +BASELINES = pathlib.Path(__file__).parent.parent / "baselines" # --------------------------------------------------------------------------- @@ -219,3 +219,82 @@ def test_subplots_2x1(self, take_screenshot, update_baselines): arr = take_screenshot(fig) _check("subplots_2x1", arr, update_baselines) + # ── GridSpec layouts ─────────────────────────────────────────────────── + + def test_gridspec_side_by_side_1d(self, take_screenshot, update_baselines): + """Two 1-D spectra side by side — exercises 1×2 GridSpec layout. + + Verifies that side-by-side spectra are not squished and each occupies + exactly half the figure width with a reasonable inner plot area. + """ + gs = apl.GridSpec(1, 2) + fig = apl.Figure(figsize=(640, 240)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + fig.add_subplot(gs[0, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[0, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_side_by_side_1d", arr, update_baselines) + + def test_gridspec_image_two_spectra(self, take_screenshot, update_baselines): + """Image on top (3×height), two 1-D spectra below (1×height) side by side. + + This is the canonical layout that exposed the squishing bug: bare + Figure + GridSpec with height_ratios caused row-1 panels to be floored + to 64px. The image should occupy 3/4 of the height; each spectrum 1/4. + """ + gs = apl.GridSpec(2, 2, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(480, 480)) + data = np.linspace(0.0, 1.0, 32 * 32).reshape(32, 32).astype(np.float32) + fig.add_subplot(gs[0, :]).imshow(data) + t = np.linspace(0.0, 2.0 * np.pi, 128) + fig.add_subplot(gs[1, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[1, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_image_two_spectra", arr, update_baselines) + + def test_gridspec_height_ratio_image_histogram(self, take_screenshot, update_baselines): + """Image (3×) + histogram (1×) with explicit height_ratios via GridSpec.""" + gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(400, 400)) + rng = np.random.default_rng(42) + data = rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32) + fig.add_subplot(gs[0, 0]).imshow(data, cmap="viridis") + counts = np.histogram(data.ravel(), bins=32)[0].astype(float) + fig.add_subplot(gs[1, 0]).plot(counts, color="#aed581") + arr = take_screenshot(fig) + _check("gridspec_height_ratio_image_histogram", arr, update_baselines) + + def test_gridspec_3col_equal_spectra(self, take_screenshot, update_baselines): + """Three equal-width 1-D spectra in a single row — 1×3 GridSpec.""" + gs = apl.GridSpec(1, 3) + fig = apl.Figure(figsize=(720, 200)) + rng = np.random.default_rng(7) + t = np.linspace(0.0, 2.0 * np.pi, 200) + colors = ["#4fc3f7", "#ff7043", "#aed581"] + for i, color in enumerate(colors): + noise = rng.normal(scale=0.1, size=len(t)) + fig.add_subplot(gs[0, i]).plot(np.sin(t * (i + 1)) + noise, color=color) + arr = take_screenshot(fig) + _check("gridspec_3col_equal_spectra", arr, update_baselines) + + def test_gridspec_asymmetric_width_ratios(self, take_screenshot, update_baselines): + """2:1 width ratio: wide spectrum left, narrow spectrum right.""" + gs = apl.GridSpec(1, 2, width_ratios=[2, 1]) + fig = apl.Figure(figsize=(480, 200)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + fig.add_subplot(gs[0, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[0, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_asymmetric_width_ratios", arr, update_baselines) + + def test_gridspec_spanning_top_two_bottom(self, take_screenshot, update_baselines): + """Full-width spectrum on top (gs[0, :]), two spectra below (gs[1, 0:2]).""" + gs = apl.GridSpec(2, 2, height_ratios=[2, 1]) + fig = apl.Figure(figsize=(480, 360)) + t = np.linspace(0.0, 4.0 * np.pi, 512) + fig.add_subplot(gs[0, :]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[1, 0]).plot(np.sin(2 * t), color="#ff7043") + fig.add_subplot(gs[1, 1]).plot(np.cos(2 * t), color="#aed581") + arr = take_screenshot(fig) + _check("gridspec_spanning_top_two_bottom", arr, update_baselines) + From 44503c2c16efa4c6f6ae3b68c585781da0689a3f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 09:50:12 -0500 Subject: [PATCH 40/43] feat: add examples and test baselines for GridSpec layouts for multi-panel figures --- Examples/PlotTypes/plot_gridspec_custom.py | 187 ++++++++++++++++++ .../baselines/gridspec_3col_equal_spectra.png | Bin 0 -> 27050 bytes .../gridspec_asymmetric_width_ratios.png | Bin 0 -> 12635 bytes .../gridspec_height_ratio_image_histogram.png | Bin 0 -> 18214 bytes .../baselines/gridspec_image_two_spectra.png | Bin 0 -> 15967 bytes .../baselines/gridspec_side_by_side_1d.png | Bin 0 -> 17318 bytes .../gridspec_spanning_top_two_bottom.png | Bin 0 -> 25447 bytes 7 files changed, 187 insertions(+) create mode 100644 Examples/PlotTypes/plot_gridspec_custom.py create mode 100644 anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png create mode 100644 anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png create mode 100644 anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png create mode 100644 anyplotlib/tests/baselines/gridspec_image_two_spectra.png create mode 100644 anyplotlib/tests/baselines/gridspec_side_by_side_1d.png create mode 100644 anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png diff --git a/Examples/PlotTypes/plot_gridspec_custom.py b/Examples/PlotTypes/plot_gridspec_custom.py new file mode 100644 index 00000000..31e3dd4a --- /dev/null +++ b/Examples/PlotTypes/plot_gridspec_custom.py @@ -0,0 +1,187 @@ +""" +Custom Grid Layouts with GridSpec +================================== + +:class:`~anyplotlib.GridSpec` lets you build multi-panel figures where panels +have different sizes and span multiple grid cells. This gallery shows the most +common patterns. + +All examples use the **bare** ``Figure + GridSpec`` workflow — the figure's +grid dimensions are inferred automatically from the GridSpec the first time +``add_subplot`` is called. + +Overview +-------- + +1. **Side-by-side spectra** — two equal 1-D panels in one row (``1×2`` grid). +2. **Image + spectra** — image spanning full width, two spectra below + (``2×2`` grid with ``height_ratios=[3, 1]``). +3. **Image + histogram** — classic EM layout: large image on top, thin + histogram strip below (``2×1`` grid with ``height_ratios=[3, 1]``). +4. **Three-column** — three equal columns in a single row (``1×3`` grid). +5. **Asymmetric widths** — wide overview left, narrow detail right + (``1×2`` grid with ``width_ratios=[2, 1]``). +6. **Complex** — spanning top panel plus two bottom panels (``2×2`` grid). +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(42) +t = np.linspace(0.0, 2.0 * np.pi, 512) + +# ── 1. Side-by-side spectra (1×2, equal widths) ─────────────────────────────── +# %% +# Side-by-side spectra +# -------------------- +# The simplest multi-panel case: two 1-D spectra in one row. Each panel +# receives exactly half the figure width with a full-height inner plot area. +# Both panels share the same height so their axes baselines align visually. + +gs1 = apl.GridSpec(1, 2) +fig1 = apl.Figure(figsize=(720, 280)) + +sp_left = fig1.add_subplot(gs1[0, 0]).plot( + np.sin(t) + rng.normal(scale=0.05, size=len(t)), + color="#4fc3f7", label="channel A") + +sp_right = fig1.add_subplot(gs1[0, 1]).plot( + np.cos(t) + rng.normal(scale=0.05, size=len(t)), + color="#ff7043", label="channel B") + +fig1 # Interactive + +# ── 2. Image + two spectra (2×2, height_ratios=[3, 1]) ──────────────────────── +# %% +# Image on top, two spectra below +# -------------------------------- +# A ``2×2`` grid with ``height_ratios=[3, 1]`` puts a wide image in the upper +# three-quarters and two comparison spectra side-by-side in the lower quarter. +# +# The spanning subplot ``gs2[0, :]`` covers all columns in row 0, so the image +# gets the full figure width. + +N = 128 +x = np.linspace(-4, 4, N) +y = np.linspace(-4, 4, N) +XX, YY = np.meshgrid(x, y) +image = np.exp(-(XX**2 + YY**2) / 4) + 0.3 * np.exp(-((XX - 2)**2 + YY**2) / 1) +image += rng.normal(scale=0.03, size=image.shape) + +gs2 = apl.GridSpec(2, 2, height_ratios=[3, 1]) +fig2 = apl.Figure(figsize=(640, 560)) + +fig2.add_subplot(gs2[0, :]).imshow(image.astype(np.float32), cmap="inferno") + +row_profile = image[N // 2, :] +col_profile = image[:, N // 2] + +fig2.add_subplot(gs2[1, 0]).plot( + row_profile, axes=[x], units="nm", + color="#4fc3f7", label="row profile") + +fig2.add_subplot(gs2[1, 1]).plot( + col_profile, axes=[y], units="nm", + color="#ff7043", label="col profile") + +fig2 # Interactive + +# ── 3. Image + histogram (2×1, height_ratios=[3, 1]) ────────────────────────── +# %% +# Image + histogram strip +# ----------------------- +# A ``2×1`` grid with ``height_ratios=[3, 1]`` is the classic layout for +# showing an image with its intensity histogram below. The image occupies +# three-quarters of the height; the histogram strip the remaining quarter. + +gs3 = apl.GridSpec(2, 1, height_ratios=[3, 1]) +fig3 = apl.Figure(figsize=(500, 600)) + +fig3.add_subplot(gs3[0, 0]).imshow(image.astype(np.float32), cmap="viridis") + +counts, edges = np.histogram(image.ravel(), bins=64) +bin_centers = 0.5 * (edges[:-1] + edges[1:]) +fig3.add_subplot(gs3[1, 0]).plot( + counts.astype(float), axes=[bin_centers], + color="#aed581", label="histogram") + +fig3 # Interactive + +# ── 4. Three equal columns (1×3) ────────────────────────────────────────────── +# %% +# Three-column layout +# ------------------- +# A ``1×3`` grid gives three equal panels that are easy to compare visually. +# Useful for showing the same quantity at three different conditions or times. + +gs4 = apl.GridSpec(1, 3) +fig4 = apl.Figure(figsize=(900, 240)) + +spectra = [ + np.sin(t * (i + 1)) + rng.normal(scale=0.08, size=len(t)) + for i in range(3) +] +colors = ["#4fc3f7", "#ff7043", "#aed581"] +labels = ["f₁", "f₂", "f₃"] + +for i, (data, color, label) in enumerate(zip(spectra, colors, labels)): + fig4.add_subplot(gs4[0, i]).plot(data, color=color, label=label) + +fig4 # Interactive + +# ── 5. Asymmetric widths (1×2, width_ratios=[2, 1]) ────────────────────────── +# %% +# Asymmetric column widths +# ------------------------ +# ``width_ratios=[2, 1]`` makes the left panel twice as wide as the right. +# A common use-case is a broad overview spectrum on the left and a zoomed +# detail region on the right. + +energy = np.linspace(280, 295, 1024) +peak = np.exp(-0.5 * ((energy - 284.8) / 0.3)**2) +peak2 = 0.35 * np.exp(-0.5 * ((energy - 286.2) / 0.3)**2) +spectrum = peak + peak2 + 0.1 * np.exp(-0.05 * (energy - 280)) \ + + rng.normal(scale=0.01, size=len(energy)) + +gs5 = apl.GridSpec(1, 2, width_ratios=[2, 1]) +fig5 = apl.Figure(figsize=(720, 260)) + +fig5.add_subplot(gs5[0, 0]).plot( + spectrum, axes=[energy], units="eV", + color="#4fc3f7", label="survey") + +mask = (energy >= 283.5) & (energy <= 286.5) +fig5.add_subplot(gs5[0, 1]).plot( + spectrum[mask], axes=[energy[mask]], units="eV", + color="#ff7043", label="detail") + +fig5 # Interactive + +# ── 6. Complex layout: spanning top + two bottom (2×2, height_ratios=[2, 1]) ── +# %% +# Complex layout: spanning top panel +# ----------------------------------- +# A ``2×2`` grid where ``gs6[0, :]`` spans both columns creates a wide panel +# on top (e.g. a summed spectrum) with two comparison panels below it. +# ``height_ratios=[2, 1]`` gives the top panel twice the height of each bottom +# panel. + +summed = spectrum + rng.normal(scale=0.02, size=len(energy)) +diff1 = rng.normal(scale=0.05, size=len(energy)) +diff2 = rng.normal(scale=0.05, size=len(energy)) + +gs6 = apl.GridSpec(2, 2, height_ratios=[2, 1]) +fig6 = apl.Figure(figsize=(720, 480)) + +fig6.add_subplot(gs6[0, :]).plot( + summed, axes=[energy], units="eV", + color="#4fc3f7", label="summed") + +fig6.add_subplot(gs6[1, 0]).plot( + diff1, axes=[energy], units="eV", + color="#ff7043", label="Δ channel 1") + +fig6.add_subplot(gs6[1, 1]).plot( + diff2, axes=[energy], units="eV", + color="#aed581", label="Δ channel 2") + +fig6 # Interactive diff --git a/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png b/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9d1bc46ade8bea29d1362ae979c829cd862acf GIT binary patch literal 27050 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 F{{SIT;QIgo literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png b/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png new file mode 100644 index 0000000000000000000000000000000000000000..87f60054958be906bf7202c1ebbc9bd34eb8a67d GIT binary patch literal 12635 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 YNZLazr&|Q@(!`|)_jJ_C?>-IuUu;fTUH||9 literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..b65992f6651f11bb9664f09d5a426ee14aacb113 GIT binary patch literal 18214 zcmeJFby!qw_XZ48f=DO=QiF&HC`i`;Qc`-;CDPqBw9*lqpvOLKZ+ACOCSR{`h$*5yt zVW)zx%*#07m4g;bYAh_dwZ}42n$NJ;k_gLS7D;<=WHl)s-SX^C6c!@tqlwFaSq1ZL zx0}G1w}L)QyY`xxQ;<==#wBJ8x@I2D%=nU-@y<1zhj-{0sVMPC7$4q}c`11r?_2%G z(M=qkTO0f1dLDhxr@T%lDr;8uTvEJ~U26`{o@i;G8k-WRevY z;?43gh1&r*DV4aF_%ecDfK3(u{R^+204(hdZPm6Sa3DV}4ooTH3b;V(B;T#ZpGTAf zTkN>svAVD>jVFtFlfvKuFYc7@3LPpSt^M5U822Au&81WJ)K{xiQ*`uI$t6&63vTsC zlgTQP$KQ}2qWK6TF?ZQc;5gY7MgH>sW=Q?Yu;? zMsEk=W?`|*!LOinGwFC0mV!}ILKJG%*glDlcTL@-6Mv9k_?CiiKNM=FnYbndp2-O{ zb5y_Z4R;JBuV!@YC6NWs_%5Ekcf?8oXOmSkc7yB43Kd88kvM}hNvhLA!Ly+Mb473+ zyN#>Gwn7-&jr*g>M*&RY$^jYiuVTIS!PNtVN_XIE(|8%Ebz-0500%I zuuV6sDf@}?XLWA+cXSR3vRBCgVY3JR8L0nhPM3(q+DM3~_tjv9b~=N&LLOZQUy`G%G>w4a-dm!dG5nA`?NAhZxArPvB5q$m)dq0Qo}Mhch{E9!a9 zX(_f!77vyf$DN2q^L*@4T6XxAfG<=ef+oUo6EYLu8Cb~$n<6tbCqVeq_^+ugl4+AhJdyW7!f zH`~!~W8qqATCz%8p7Xu@H4fkxoU68v9J~|)L6BG*`;eNu=~)%cAgn*twPd@xr>MP^ zrJm}AD#w|x=7q_+AfU%&6d4IQ_FFu-J8yP0W4x8-EN0jmPC}j&78xCM@gSP+zeS2v zwhc{iKhPg8kUf)hKTGqZ4MSE%w4+uqff;hNAIWyjTzYoRt5?4rk;ocsn9%whs7iY> zcu++jZTYKi`BY?mRMANkXUXV#Tz7q!Z_e*%NRibe!T7F-7nW9K$nD!gOLoCeU|t;k ziyw94D^4toq`Qf1dlU`4B&3;%tASn|-j`_jUwiFEiMAyjL zNY={ppY$a{Tq0wG5=Gy5KGZj;fbVZ;1K2NT+<$TnbDnoc#jUmjDrzz-pR=n6=Tgq| z3qw%1979sfkS3-)S*cmGIcB28YkrE|uNzs7KOmMBVsNfxT##=iCu`Jq{-N||*#70{gwg)%XBOU`M?)VL`+Kcrzv~oaz z?N5XVcgCMkZ3eLg84H48MAK^Be`EV6Z%0Z)da_mj61}rK*+V!G}v;XL>{P5TmHWMIduE*UKk$Ar8f@u zJ{CW2<4a5PrAw^-Vg$WnZGa|Aw|q6B;|z=!kqfDCn@i1~>XvLIZJ7ASw| zu2qhm!8+L6Rb}`2F_ch>&2%#W&nJh+z9oKUv*4pmkA8e5qIQ#h3&H9|5z-GZ^Q7Xw005jsm_A>r6Ubs0M4HfhfM6ZnPCvx*!oJ() z~i1Y zY>YvstyWM#z|pTy`fFP+%%;hK-DTU4`;jjOC6Vt!M4D4@E z1&Dqd1c7IID9NG42rkb z04U~Q=TWBH&UegXp2})zX$tQ1f-;qCI{xm7Fo<#9gT^asYEY1?X$RUR4{5$;Ro+P# zL_I9dk*m7cw2^Ep4`m;qbx^k&wQ!AJ8ESx|A>5pMIaL+qU8sx?)g`GUVst!w5Gw?1 ziTQv9i7OBdfVe^Vi#5iOs}{WYff=BZ+=}M0aUwcW}+V!p-VsU?qd2~?TE&Gn)7#P?n@TbkxOo6wBQ3G#p zw^EZ)(sfBHVGd4BC8%PxF0V{%vp{BC0mI&D?Z_jfaANxcOvQ7`<=zNk>hDqYo}7rSM;EOLKFWF93SA6@_u_WsE# zvHXsyTWMMdDNSeDY*C>xZZNE{k zzMBZCNfcWg9Y1apu?sSDlLp&990UO*Ehtxa8bRgYtzh0$3ON526rHKj80@tBLX?trBEyv#0#-SZf0ju<+gqwOBhCSbL3vl7Ie{>e8S@N}$NgvxH&7L=>%KKi?NEo)s! ztxq$~cdj2Uj4hYg!C9ZQ+gM2}%%^xh`EMNmh~?gE<@lvtaHa7|o%MfCfa+3cD!s-z zB)pu+@!$&cQqu_eN)v=py+bg>Be`uPd!D#XyxS&6Lv z9<`tR&ENLwFBc6jbpxMRv%@YcSlkxVz4h@89*Fo$J1*T*v|`}<9#u2#mP_RYznu|8 zDZAde4V9z#aZC|ySapQCOREYCr>!9A&w^Dci*f$I^on1r{b!L!>1MWnYWd2_YM2iH z$cNDJE1`(`9CxZ;IEo^U#$f|Nd;nY8o zIODg@OYZ}rwpGwFpi42g{*6vpF+%VNH!(LmADh%U1Dh1$RSro0Mn*?| z`y3Jg3L`;{{;v|vwi*5{O2?!GT57(59=)!@@E>t(1Z{`vB&8pI?8NTm<%Z?5*%C-` zoLNA?OU?Ba>M~FR=-Y-xf%}@IoGBvosQVB{8%H$karAb^3JJvjU8L(xml~%| zLfhfgqvSsX{~4jy8hzoBMb8y;&LIt$z(-{KMf|rty?4Q3p#~W04PfC|L!>?) zISGbj6k(N42oFGaJJ39xgb4cmw_NKn5PWkIuo8S`TQ(Mk_w)ihglV(a$HHI^ubK8p zDwIi){7tX)6>fLdv^Wc+!u-Ei;z!5xz$|s zipT{J>rZ}er4!x%ZjFo6Mt)Z=)pelT!Gr$6edQ*ZfXM${xH(MN-qR0-2a0Y)(PKWf zpT-*2gCOt^~WbW4mm(+J(6uAD&GHJIELS(eNC)aM>N-mJny)4#_R zStw9;t2)`H*+e>iJke6Y{^q8$QTWGJ8kaFI4W*6s&|sMOKvaUy1C0Ux*!H!9_n#Dc z+!U!!KhN@dwNG?fW-Y~!5gYv3B2)a}*QhXAwJ{D>zY`6GwU1;K!0m$wyT__ft9udb zzj}d6029FHfR?}YDmqAE%Q@Irnhf9xALt+~ri|A?wM$IpKtdZI1}pgVU)b9(N`8ipZVXdFozLAwhXdE0e>|s8!m;on;wMW7Do~Xf(BffwEMQ2oQ1q# zId@eF>$wWRvhPvKES}-%*14vcQY+3o;BGB5*L}dnw(Xe=m46nYPoLAWSHu*%pSAqy z>h8^Ma90#M$upbDu+j@La-(TBKijk$*ty(~Y^V-wpYVS7$+g>A(dVGj7q?-n#@fn* z#_U_XUE_oc+s{_R?%v97PemHvijFad!he<}26ZT7{v)@Hr_&9yHhwbgPCch=R!|Y7 z^ZJKv0c8E709-oXzbFdR4~r~arR$^zvF<>}EVleJCPKh}1~D;ci)R-?krw!~7{sx! zfAJdNY9#h~BuIhd(!Y&w3HUx@SKbPO2vO%JL6pL63liVY^Arl5R?zopJFrsP%mi)t zZ;E+thn9k`ylAN=nTMz5H_1R{M)1R=^p1(~jYp`za zQpB$}BhB34Gme*)J$6_6GyM^FG|%3TB8dMNkN>1Zy~FZIf?9-jJx`LRY- zy*xD1bE@W@uPZWZW_kUZ+^n`K75g(zg9rUqZCT8!7qV>v(?rF2!+)JhZ}9VrR=;SU z8K|0a)#pq1Q?yCxSZ+J6s2tzEU=iaNp{!iO-y=lWct)PL7V_MzjTW!I{{P8b^0El0 zH&DKQz!ru-QTw#3p=qEc>tOr1ARCG)4uE8qurK#Ow}5)z3ZKLZsi@K{Ymvf63N(TJ zlHA2wGhTP7)j#eEDK}b_fHF5UNd>USp*}fq0pwvNSlrG2!l~K(^9|eikI+lnA7^uS zp8Sh3jM^>^j;9%bzg;}^zjfNmgje*Oi7!h@ua}7{UZv+9zLxaNyg ze9E(c*Atq(v=YBe6;94Zcxd*qQkP~*vI#hS>yipg`sPc^hOFZU$5XVbp5&zxk*s ztCW}h9bKYp>+zn=tmpiU3%(B!z##y4*Y2LbT{y5T3Rp!xr+$t=q%Z9JMc z$X+st3Cbhg1Q`#6MnA@c2r{V$^bT9YWY~ow#C%3te|N)6nZm9v|NK%x`0SGcbjVc! ze#Yvc*Gi%{+vIPG#J5kl@@lwgN=UEgOloR)i=?;rNuEnn>vR&_=8C;=9@=knPxgw- z3M$QLcPo*h^TNm)y9OzXaO~By9*qoa#r41qURn7G>!sri|Ms29&s=6AX?kzc$ZM30 ze}^teOt{pViELeOYZiGt3m;?r&$g_Q2K*L)E41e!p)2h|jNyIYD!I)7X?@8`qVo+{ zZHlnJfNC-Yi8d&wky_D8;>s4l9b6F1{roi~TCx`9nolHT$Foz?A!P zv7>GP0RZ$kTWVTBhXG<$NW5I(uK|&a_l2POD%Q=-%@$@~l&D}3r!RSR!QG-^=hD#C zit%k^qt=m;^xsh4kw@yCW3F_QeNDxXKWPLMBBGO!P zLVU77TlqF|7OCi(K^Omo!8w94qix%+4Qb`#BuEFu1(S0%al&`lKrx~EG`!vSBqTUL zA*4DYJhbrm;Ij1kh@s{lmmb}=ui@SX@)w$%UVC8oX4d0A$*dEjgV$;D8Dk%oURTbW z&+J>1ph0CV{^tp+LyWSw@dJ^P}-0Kmixo! zoGSq0&<$exw}R;*{41zH;Qw+~LWG|?*vn6-x&^ywG1r7>F{;hryEdhk1L7WsvkSuC zU6yU(%gM{QYR`tX^No4r6@jq3m10aP?+@h^0e2 zx507Ic>k;F_~<@y(t%d)lY&~448LM?rK7(e#9l5Q+G0Z^;u$Uc_ zg+aP%R$a=8QC7B1ep+1msiLDY-%sI1IH$)M?#v!xa-aKs4YwUh7NLfbsJ@Jo#xzgf zs(vnR3g6#eSpZQxqxcqRh4;OYHfCH_OQMS+>F)A|E#~92eY*U4AC~uuM5Xh*;aSc` zZi-w0ty_zJ!gikLe_(BHbSUB@kpG3hOo*5hmjR0C)fn0v_rijqxr>NYg1`73`5{X$!;E6|^YJuf67FqryDm12W%JKA_l463h4A z5CUY520Opk5lZqU2V?6uvj8CCemv9;<<|m-g9PthQL`|}4lM=Yp2y@ib8lOKW1*$S zd6fCAtRgD-^-h_*TCo%&v=n^Nf%c*qG{VXRXcHfplpai*4wjdl8}@O1b`FE{&!D97 zH*Hv7;F6d0U=I=KD)`DOKAVmD)H2a}9T)_U_iALz`HYZ>-I%bvYwPtgweb>^y?~_HE>WYS!HB$9)$m zZxEs`5b*}3Kjox8;gV}cd%|pGt$Vdi6PNn()igC2ER8PP3UqUWHGhlRB7+Nvak?As z!Q~6P=O3W}2#L>u`MvW?$JCq9KGwgS^^YtbN&ynAO=m!aI9uAZ!X8sjM1tM^Nn3ON zUl+m!LD{15NA!Y3JiA&96v#_J{EF$B@Ys-YKxF2De;yYFR!SzYDA(SZxIh$?$863q z33SyZ<=xN)G2m&4s-Ne1HKSp)e{MSdPja6ZZR+O$UJcwKGq7GJ_!m+d=}mI^^T5;L zg@9%}IFar7(0Ln9h`^Sl{S+p$ngY{!+VSm8FVBoY$Y?{ZRp-Pq=$FI9pC_exdY!y) zKM+JBtGar3vL&JuM(lt6b`nl!N7hERi!Judq%iQb-dQYK;E06VhcbHVGO)}J3Q z9yM8Lso8L71t>s;5A+?E<#gT?Au^ef0*#EyeK#t*AVZ_~ru?tqimH#t8W3^NQahOp zF8;;w`Wspe&}AA+NeB&=l(o2Wz105Yy5cYa({M5=vT`TjPFPPB+nvJ)DyR(-nBh=V zvEn##{uw_)$L*!|MFQH&x5MQN>nmHbly>ys>mO`b>M(EcW|77HkZvZ=o#fkjSGuq7 zYvoKj^ku&vtzz62*X)CJVO0*b6TRjt3ti484njlLTM^N{I+BIZkF14T>9?A!qO z7ZCSNKL7KSqbpMND2vTDc#ote_FNGYeP(zM7#Bjzm_Kl$xJxK|VYz)sb_3U?!g>fr z`GThnzzA_=24w!I$hV^eFJ`A;h(-;#T8_2Si8fT17Y+$-^pGPeSf~C zmGvTOxJ4RQ2rhPvLHCp*TYKWAOCs`^NR)6E>M(u2arTIuAK%yOzT&z{&1 z%y;KW2!6dr7aoD)S&mYgg|Z8Xi-1k^l{As)m@U`^;+;y(q2?vz{BG@! zPf0mEpaS=Pc|uYQKp7Da1Oqtw?g3DarJWmj9W;DrYNYGikO`B2;U+r%R8jM`*wr{! z#WMWDZVSU*aPOBpWmo5h{lk_E5l)IPGJ^4bIC5LEBu7!;=gK(=VUh{dlTB{EAp&k?vzH z%9wGwFH{i1Sly&zSXuh#N|us=zooU08$ti-62A1{-47BwE;1)bR9lpDWTms!!C^)r z?_A-jP?mzk7LoLQj?kJ*U5er|h$hKo$ajpcnb*u($hsk|7RRUwSV~+%e+ zL^We77C{@-CW5OE*;&4e`qHfj>Yl%4W0hBCvITB&p6`Kaf(M9xd%Inck#r?rG{7jKG(z}ib1mmVyO%Xju70Io;Pn6 zlH7lLTwqunXF+rr@HNJvccEC?_~{{Bjt0V^NJtz`lk|Hjt8e=m*%2cGc6@L5>%TYSm@urUTlg+&NgNwVVE~4_L$5!8Ij@{Z8H& z9y2z;DchjZr4eTfsJp1DpDHu&?DqN5(%p-DhKU#4bCCgIZRlV5tk|a}#M}40ax80Z z5}3D&MKUMEvJ-N^@U8|I{~+H@MohJ^)(>? z6`6vl0WgmPit3+Q+x?pmblZmm*TPH9RZIO$?SG1&M?!(3VAOfR+zxJ0I||-%w!a3O zp(ThTO#eP+1L=lcwiWyZA!fF8cRL!%1R_jCj^fo62ff#mqcbuHxT{F&;)n0{!q*+o zM1F=_wpsXCg^QbWM+=MzuzWoo_2=sfJ>>axde9MqmhD1*qb_@q&Q}v|!mDnZc}cd_ zCuLRp#kGC8&SxqN5sp?D2J>~kWxc?5QW-vt<9$C@z{BBMzyCcP+6IAp z5ZUbBZN(lM6!8HafLeqBT4O)QwkXn6kzpZ?i>yTGgX_lBqqSpM_E%|7EO3$mx`+!Kr zA4x5gXfHlVGt>XZ3)$*TOTSN)n?;ykgMkz>H_V1#v-yWCD{oV(UKEUcB}j4rt_BbUYM$T1U{MKwhd!EgWLY2|EkJ{^JS#ErKSV)YcP1Q6H~@$* z6h!3){104oZy;3Z^tHIx06>eVpQAMuTiDYJR}b)gqDtY~@WkHp;)!$$|_tWz0hh4%9q>KIO#+jb$+;i6E@2SQ3x z^`T4A{`)T6Z=1}MkEV(CD}&s($j)#_Q(c%ro7--i9JNbj;y)JQ|HzUtlzz>+Ebjx^ z$i3wZHF53mS2&6~;}bj|w$Z2(R_^BwlTl}G3YF|6G>ggmpbu}+j~M{^6B}E?uLc25 zFw8^0Niy7Wg;I zdD-#dE3&P4hU?7=@x#Lyc(RDD7^z&_xaI2|U1Qafgo#e`}*c*ahX~5qIsJ|=k;MQTrs!6Pj|(@{QB!^Qh}Y7B@dRN-_LV55?6>L z4!)RuN2*(_>I5iMO>xVMVRN3`Bm|!ps&SANTAg+m3?CR@j6Q0BQOOJ6=wkK;wgv7Z zhXpwXAQ?Ibm5T-Sa(JlJ!jI?1I1jltWdNm@+6BKw)v^d{sS!z)UYXAi!FJ9F!IrIB zsL<>@cjTm;f}q7{BUcVE^M&Scd~?o+$8yto0SaX)2P@`4%QXFrELf0)T#d^XjN33@ zK!dGJlZTx7G5V~8QM?KJow{PnB8%U#q7EmMb*&99Af1UPJO*BtYn8H z`L1O1<8P+DJ*WX?urne7|7-+2x}Uc>Yc#oql@*IKFY0g3vd z^snN^Ng$7oWj`O<>n_Rzl5lSD;^^;`g(!8s3(`S|c<%Z#o20Pf8a+dV2+bqyAV__T z`|&eX8Q*fv_N^nF1*{EGjmC2-w(QT!!8=uvB2_`62#h2LFNEoMsHN&-#}JCySYp^O z7%6G7sa(Wld$Cndvab1s5z&7v%+_Aci&+zW0vRf+!D`+hl|1R z@O{28Fc&O2VI>)mcchR_4kn4QW3jQ37IMxOk~G;z;1G9zLU$Z+LL=|3#*)svh2TQ& zDcIPB>s)Edk~FILPx0~Ku{13)VAgmYnl(OQ9v}px%2*u%SZ%x+&^r(K;^U#J8(xZF zgKHYoT*ExIqyW>+FRG!D<^4ESR_Mds2P4ZJ;WN-I^gcLb`SkiS9++Paut+7t+CSdz z>51oIa=P+>qk;)Ho(nNn;jlI=nRhes#YBy#+1HjJGp{H1_~Hi`+~Rh_1aw|JsE&&j zA0FFgM0TOJWytXO(&&$QP%!h7%L6pZJ}w zad9#n?Hx+wkDm@A#ppV!s>_QDu(TJnu3^4EPQ}u;Xj~gE#kSU~GwZJ3pBg*p{vw-T z^s(e{%Ze)U(X;)rDj|u69kjzn0sA7awzD31r#T|T{2kSRJYGz!{qsxr$F@(`H`yG$ zoz}iM6!qu|5#HFSVs5yso&5&z{RNBb|GrIKt;<)wHyh<#)4BAe*l3F}%h9}_mO0=U zcd~zv5#!m2)mceqdq!yVbgyHor@`!uz?-O0$Bz9n!om>v1&Mf95I^x?Y-z=ob{^gA z&j>x&M_vj{>F>Lt1?(IyfltSN8^obe1b=*?D}hxpzSy$sBemMIL-R3)8`NcA-M>CU zIe64%6A3lo*{6A@#VZ`Nj!sDkex4HbuDns|vX;-(81tM1OhjIP+7{rw(BTPhBb1B@pO_la!BIKxIpxckIV{p*A488hEt{FXr?Afc`|w3jLxSfRLtNxNmlIkY zr2zKrhnGR9iVd+VO01YfetKM@=}m`Y2`U*Of_0`$Io|{Qlj+wb6ko zD#vzQ%bfA7H8#te)vI#%*hao& zOsB^`d*aDE?lI=j|5=3rjxZ*MCrW=UkJp(-zgl_u);#a=bN{Ab67T6(RGTb$t5eHe zL{fR5=&UFk#*%70lnoL48bs^kRj%?|dRm@cNur*Gy5+gmZK^{>^(IaD{Fwv0_cXUK zo~y$pUc0tOSnpJP35h;nId09gqz=dPm~qx_K8)fN&v)$>CfL9|HhDpB4epGvvMW0b z-tvxlY_=apo=}!2;tg6*y049vM@pO?m_>?tv@^#&INogH zdFNQWB{zOOvcd1qchAij@u(Q5!Cqz+;VU5#RB|3Kr3^l8q9kfZz+&e9fD$Y?!5tleXU*%4?q{r%> zXTJT6k*JX#!GO7$KeCd-F+EdP<0M*4MUnIOv8aalg=$MP%iaii&TeOCJ3jZ+vPtlqd!j`!PC&4?ah8hwOb2$r z^BO@4%mZ$%Wt$rT36)!yu3br~`@uPRM6f4OZj3M(Pt|pAOuL6wHM-e7^F!TWw;-1Y zK7J)aXn9Vy$x!#tw^fp!!r@aExzDp}+Ph^G&#`dyO^7YAQ4*XCV(!1V-qb$I$!ihH z=A7D-v$a{n+iz&_+-=Q8U&_c(OY--cR`vPE$Y|T#yT;9yG2$oAIC$T>vKg{Dhwtx> zK6B7rYBdoiQ$PF>w&hghkH674arFITuiHvZUTH@umiDDaG-^76*K>A&w}{OG}jn28PVa17QXxSEH#WA|@6ZR6oUD zLb+fQv>`6rTUT+aC ziy6+qFrU^@N7Xu;p~P+<8gZd4g5_ZlmSA}(>2D`Vu9Fr3bX6P&`HP6<=UWOYJb&)$vgorgk8(RMkKu@nDwi^19qp?S9E>V zaATg|VAZ*S3BwsD;t~t`uKZ&uVAafVYffpidy?Z(iD%9`3U8u=0h9UX38c28n}aq( z{h#!Vnn<-b;wK6V*vffzQJc#}1%8Pn&tH41O(RNlmz!H+MBbT8Sd*oojHA>X&>s&3 zzYc7|?Uni6rk|Hdqw)5z0saX3MO_{>tbCp?}!ynt*+tPVOo2B>? z6X8@uB@HPt+2ul)gCe!RO9@;E64*p0NvH04^OX zL{ERaG`2e2bM1Z0D^|}SbZg9`329;UL1~@iVxWu1k3xqno93e&<9-($6rd{TL+fI@ zp*)HP-c_tUqu&_1XSO@T&B3Gs<3fYVJK}6D43BFU0xQ}nR4*YnXz?Gv=w{Y4lI0=@ z;B`K|1#`Gt=3n<^S$kVhS=QfF-xHaaU9;!-;w*w7V_Mn#IEwR$aYT3v0~?ZL?emh> z-3Ao~E?Q5F@t(~QmSy4d>#T-bIeJpIJA$c<)K&kGHsZrLMe5bAFa;F7ZMVPutm>0O z+>GB*do-(AVadTyp2$b;Ryz@T8LKLn3b8pkbg#W5QboVYQII$Nc3OWm>>~rJb11?_ zz4P^*fF|~IWaQ}wULz5ASD$R-D~39|D+50~E;-v4YX@8|q<9um|MoU}?VqB+f?{D> z&hRa1l)6LfT6y#L`3**$y;01j*o&|`ocw!N>uSGzGC&H9aNJP6CnZh26uc$(XX{cbQ%kM&T$RKAT? zcn*~SJ}E>a7ZelAkka5xOKLpPExR`Q^S49kXu02b;_-TYQj7iW>CuMM$QNcEa#B*K ztvMUn8^*+2KficyAj4L>#@ds8Yo-j(?tg^89sZD)GG1<#SNZT%1BUzYUh%@0f}+K? z4xLu>h>iNA!jh1d1U-LUDgzqty$!)99b8_XMs{5kobI!Q^MAI&?q|eCY$OaA6b$dN zke1hNR1O%>pr7fZSM2;E-S;-8A_;|lYa5nTmHXo|G=0*uY~J#bDf1MTZWP;UCO=RL z&e8Zh6CYmnJer9VtWo>-1L1_ZtK=C?&CIf`Ul$7Kwqg!`e=#a?KP}#zv{7g+{t?!k zqVsG+V|!!#i-QJ__gJla(|g$h&$$P6#>2}>Z^B0AB4Sj`PCn6{9&1ov5<=^1b;NP0 zJV~fqxMW{%I$L{3eG~`jGdUV(_>}jGchD1u`^y*wZw~u%o0V$qxXQhrhg@9VZYQxZ z(}ho*bi-rISr;d(?VDDM%jPay)}S-%_ZCpFgJxx2#87n?`>KFiweyNdCs9N_(t>W1 z6Xin4j@fzpNhdFPtD?pGy6;I3XS&3PnF6Ekej0Y&gg*=UAMmTE7fEIe9EQJGF3$|| zxE(Z@bS%5At23eOYoU6?Lim4gb!Y znBwWg3L!6X`YY=sByvlB4s>0;MZ34+#v_mXH5z2Etmht6a!*@77$ubt%zRXtO8vZ3Z%7?~ed@-7gCWk)J0)Ihm>u3G0w@#VvJwdx8sEY~@-F=J?&JI83JIb)% zL^xNQE?%#;@8xjRY4gRQ?8F_Tl&ikQ(SA4mcuaYOg)(whfufKk%?V5Eq|RS$&QIx4)Jsxc&M+g&&{SupRJ=2M-`?4~DY(el+Z#OicT6Unbv> zg-JNdcQAM~JX5e!@F1j51$ml&ASYWYChjtgJtyDmOsIg9n?7SS(p`V7eb0)9QkvLU z`9%D#Rpp+7Ofbk)IN9CpHGg(Q~<5wy&;d3|d_^x3S%zKeYi zohOHM0!r*3i4?{)gYIjHHL>^ahMctN^b@Un)7H8Ra34?hDG$iiX}^0kNaRrIzt@Ck z_?*iPOAa>#du0p#Vt;$)^!75`x}dT5`OaYmvFLLjNB{7k-68gw=WG~be+2cUzS(5A z>T9_Ku^&q#icaOh{<=7rsOV?$JO_dQ(6Dt=ze2t=6Q4vXQsFO6%l1`Zt#mnxb{#(J zRSCOVf5OHbjZNpF-iZE%`O&Ju(b^utifN&$Pg=PWn=Z@{qdK59%fjdLp>4L*KK~9u zzzt6R>Dl|eOTlNwDfwR$?B7*KU4iGnM&j()b9bj-k8l)+`PgPWUM3Q-Ee0d`bk5Q; z4^&CDuhsF;r`+yr4~q*P)+g3sc!#A;J;e-%>kIO~n7=C40*l&Y(lJtYI&=Qax^DQe zRQs5Ia#C&M;P8^QYSR6x`8U{{g%$USs>={pQ2W;y2r+Q`wjJeSz6TsMh)1OB?~&a7Sd?bX4aNYYK*XGxFraly=)!-D4*F(RDNdO>UCLI=trCzNFSQGI(q9 zmpsRwKe@wm5E@}U#g|lOXuy)?fN=nT4T3Z}_ zVL}SPjA0AG-g^Gb7MOy{$;1AXQ_UY)quBzu@%HWuogco-+R!}P?h-Qc`o-)$_VqHx zj{j?c?xWt}60AD|V*MEv00?BweicW$HKtAH83 zQlP~{JW`!@{Yh&>(^%GS)@m;L+Ih4?P}%Fg3B_9!jis%Oa|!iznIdw5Mqbj2cdVeNJf|bDIBt)&C`(L_vrYqqQf|kjia{W$9F59!}so zHiwc?e-2})Y}lwr9>}f-5*8#;#&YO+3>kR(9*s>YPpX7s7GXShY0pX$y|SSb-J4p8 zW<0r4p3S@lM03KmM{H|^a@&#!keGP9Rx{T$o|`{N#j(yTwQpL>7+fl zw@gOz7%%lw%Px`K0ga8i{YezXheQc~@mK;<*ul&jt}Mrk6V1_w_^|HfMAvR9I)c;U zM&&PjZQIx(72liDCc*+7|3=AiZ0s*v-0)hS-bCLj>aSSvDW@LRnj>iU2IkxixVyZiHix6yxxuGt1{ zPHjztK0B}h*ZXzn5A4A+ZgGP~7z@#B72(~kVE4_qIVi$+*o8K~0_{+(O@6Nz^Ts0a z-l$0LDfn9;x-##`<*(i3XrOGl(Aoltz)I{CC-&`yyb81$%X!jRb7ie2n<=#f#!(OV ztR35&e_j3H)J(X$b*??SM7j3ivPh1efDg-CvCK;w_cPi5NM_ZK9Pa!IB5ot`c%%S6 zm(9WIG@c0@GZ9tYjMLP{80dq93G=pN9ipQan|sf-&oR>*%Qq45$3Co>qWIFEBHtv3 z{jaDjTCjl6{w%@m-RJ295{@NjCa-rdp!LijZzQrC%-5-OG&m)kNs5)Qy~Fu?(hrKY zf6AT<6IQplbPU)|lApT*u!d~7>Xh|qtdD`I5aA!;9{m5k{Qo*8GRpqv9uB`Xz&ZT7 zVi68ttss;G`fDId1|SxWK2SoF;(_~e?4L2b@27)6mV&>3gY{TeS*Gxzap3<0zpvRd literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png new file mode 100644 index 0000000000000000000000000000000000000000..abb8d4e382f1358e1abf91c3d9bd24fd41615ea0 GIT binary patch literal 15967 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 HSib!)y1XL1 literal 0 HcmV?d00001 From c67b7edee898561adc1ad95837986d956f4a6203 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 11:27:30 -0500 Subject: [PATCH 41/43] refactor: enhance documentation and interaction descriptions in EELS and ROI inspector scripts --- Examples/Interactive/plot_eels_explorer.py | 25 ++- Examples/Interactive/plot_particle_picker.py | 21 ++- Examples/Interactive/plot_roi_inspector.py | 176 +++++++++++------- .../Interactive/plot_threshold_explorer.py | 25 ++- 4 files changed, 164 insertions(+), 83 deletions(-) diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py index 0759062e..8c96fce7 100644 --- a/Examples/Interactive/plot_eels_explorer.py +++ b/Examples/Interactive/plot_eels_explorer.py @@ -1,11 +1,22 @@ """ EELS multi-spectrum explorer. - -Click a spectrum line to select it (full opacity; others dim). Dwell (250 ms) -to inspect the eV position and intensity; nearby known edges are annotated. -Double-click to place a permanent edge marker. Delete/Backspace removes the -most recently placed marker on the active spectrum. Tab / Shift+Tab cycles -the selection forward / backward. +============================== + +Five synthetic EELS spectra (Carbon-rich, Nitride, Oxide, Silicide, +Mixed) stacked vertically on a single axis, each with known +characteristic edges and a power-law background. + +**Interaction** + +* **Click** a spectrum line — selects it (full opacity; others dim to + 25 %). +* **Dwell 250 ms** — shows eV position and intensity; nearby known + edges (C K, N K, O K, Ti L) are annotated. +* **Double-click** — places a permanent vertical edge marker on the + active spectrum. +* **Delete / Backspace** — removes the most recent marker on the + active spectrum. +* **Tab / Shift+Tab** — cycles the selection forward / backward. """ import numpy as np import anyplotlib as apl @@ -196,3 +207,5 @@ def _on_key(event) -> None: "Delete / Backspace: remove last marker\n" "Tab / Shift+Tab: cycle selection" ) + +fig # interactive diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py index 31328dde..72a1ee33 100644 --- a/Examples/Interactive/plot_particle_picker.py +++ b/Examples/Interactive/plot_particle_picker.py @@ -1,11 +1,20 @@ """ HAADF STEM nanoparticle picker. +================================= -Dwell over a candidate peak (300 ms) to inspect its sub-pixel centroid, -peak intensity, and estimated FWHM. Double-click to confirm a pick (green -ring). Shift+double-click marks it as uncertain (orange ring). -Delete/Backspace removes the confirmed pick nearest the cursor. ``c`` clears -all picks. +Synthetic HAADF-STEM image with 18 Gaussian nanoparticles on a Poisson +noise background. Candidate peaks are detected automatically using a +7×7 local-maximum filter and marked with small grey circles. + +**Interaction** + +* **Dwell 300 ms** over a candidate — shows the sub-pixel centroid, + peak intensity, and estimated FWHM in a floating label. +* **Double-click** — confirms the pick (green ring). +* **Shift+double-click** — marks the pick as uncertain (orange ring). +* **Delete / Backspace** — removes the confirmed pick nearest the + cursor. +* **c** — clears all picks. """ import numpy as np import anyplotlib as apl @@ -196,3 +205,5 @@ def _on_key(event) -> None: "Delete / Backspace: remove nearest pick\n" "c: clear all picks" ) + +fig # interactive diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index 2fd29644..fd89b49d 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -1,11 +1,19 @@ """ ROI-to-spectrum inspector for a multi-phase STEM image. +======================================================== -Four rectangular ROIs are drawn on the image. Entering the image panel -activates a pixel inspector in the status label. Hovering over an ROI for -350 ms computes the mean EDS-like spectrum for that region and updates the bar -chart. Dragging an ROI pauses spectrum recomputation to avoid backlog; -releasing triggers one final recompute. +Four rectangular ROIs overlay a synthetic 512×512 STEM image. Moving +the cursor inside any ROI recomputes the average EDS-like spectrum for +that region and refreshes both the continuous spectrum line plot and the +per-element bar chart in real time. Integration windows are shown as +coloured spans on the spectrum. + +**Interaction** + +* **Move cursor inside an ROI** — updates the EDS spectrum and bar + chart live as the cursor crosses ROI boundaries. +* **Drag an ROI rectangle** — repositions the ROI on the image. +* **Release drag** — recomputes the spectrum for the new position. """ import numpy as np import anyplotlib as apl @@ -16,48 +24,77 @@ def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: img = rng.normal(30, 6, (512, 512)).astype(np.float32) - # Precipitate A (bright) for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: ys, xs = np.ogrid[:512, :512] mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 img[mask] = rng.normal(160, 12, mask.sum()) - # Precipitate B (medium) for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: ys, xs = np.ogrid[:512, :512] mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 img[mask] = rng.normal(110, 10, mask.sum()) - # Grain boundary (thin horizontal band, rows 240-270) img[240:270, :] = rng.normal(70, 8, (30, 512)) return np.clip(img, 0, 255).astype(np.float32) -def _mean_eds(img_patch: np.ndarray) -> np.ndarray: - """4-channel EDS intensity proportional to local image value + noise.""" - mean_val = float(img_patch.mean()) - rng_local = np.random.default_rng(int(mean_val * 1000) % (2**31)) - weights = np.array([0.40, 0.25, 0.20, 0.15]) - spectrum = weights * mean_val + rng_local.normal(0, 2, 4) - return np.clip(spectrum / 255.0, 0, 1) - - rng = np.random.default_rng(99) image = _make_multiphase_image(rng) + +# ── EDS energy axis and element definitions ──────────────────────────────────── + +EDS_ENERGY = np.linspace(0.1, 3.0, 600) # keV +EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] +_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV +_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] +_EDS_SIGMA = 0.028 # peak width (keV) +_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] + +_PEAKS = np.array([ + np.exp(-0.5 * ((EDS_ENERGY - ev) / _EDS_SIGMA) ** 2) + for ev in _EDS_EV +]) + ROIS: dict[str, tuple[int, int, int, int]] = { - "Matrix": (50, 200, 50, 200), - "Precipitate A": (50, 200, 310, 460), - "Precipitate B": (310, 460, 50, 200), - "Grain Boundary":(240, 270, 50, 460), + "Matrix": (50, 200, 50, 200), + "Precipitate A": (50, 200, 310, 460), + "Precipitate B": (310, 460, 50, 200), + "Grain Boundary": (240, 270, 50, 460), +} +_ROI_WEIGHTS: dict[str, np.ndarray] = { + "Matrix": np.array([0.10, 0.05, 0.65, 0.20]), + "Precipitate A": np.array([0.05, 0.08, 0.12, 0.75]), + "Precipitate B": np.array([0.12, 0.60, 0.18, 0.10]), + "Grain Boundary": np.array([0.62, 0.12, 0.18, 0.08]), +} +_ROI_COLORS: dict[str, str] = { + "Matrix": "#4fc3f7", + "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", + "Grain Boundary": "#ba68c8", } -EDS_ELEMENTS = ["Al", "Si", "Fe", "O"] -_PLACEHOLDER = np.array([0.0, 0.0, 0.0, 0.0]) +_NOISE_RNG = np.random.default_rng(7) -# ── helpers ──────────────────────────────────────────────────────────────────── +def _eds_spectrum(roi_name: str) -> np.ndarray: + r0, r1, c0, c1 = ROIS[roi_name] + mean_val = float(image[r0:r1, c0:c1].mean()) / 255.0 + weights = _ROI_WEIGHTS[roi_name] + spectrum = (_PEAKS * weights[:, None]).sum(axis=0) * mean_val + spectrum += _NOISE_RNG.normal(0, 0.002, len(EDS_ENERGY)) + return np.clip(spectrum, 0, None) + + +def _eds_bars(spectrum: np.ndarray) -> np.ndarray: + bars = np.array([ + spectrum[(EDS_ENERGY >= lo) & (EDS_ENERGY <= hi)].mean() + for lo, hi in _EDS_WIN + ]) + return bars / (bars.max() or 1.0) + def _roi_at(x: float, y: float) -> str | None: for name, (r0, r1, c0, c1) in ROIS.items(): @@ -66,18 +103,27 @@ def _roi_at(x: float, y: float) -> str | None: return None -# ── figure ───────────────────────────────────────────────────────────────────── +# ── layout ───────────────────────────────────────────────────────────────────── -fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(1000, 520)) +fig = apl.Figure(figsize=(1100, 560)) +gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) -img_plot = ax_img.imshow(image, cmap="gray") -spec_plot = ax_spec.bar(EDS_ELEMENTS, _PLACEHOLDER) +ax_img = fig.add_subplot(gs[:, 0]) # image — left column, full height +ax_spec = fig.add_subplot(gs[0, 1]) # EDS spectrum — top right +ax_bar = fig.add_subplot(gs[1, 1]) # bar chart — bottom right -# ROI rectangle widgets -_roi_widgets: dict[str, object] = {} -_ROI_COLORS = {"Matrix": "#4fc3f7", "Precipitate A": "#aed581", - "Precipitate B": "#ff8a65", "Grain Boundary": "#ba68c8"} +img_plot = ax_img.imshow(image, cmap="gray") +_init_spec = _eds_spectrum("Matrix") +spec_plot = ax_spec.plot(_init_spec, axes=[EDS_ENERGY], color="#4fc3f7", linewidth=1.5) + +_init_bars = _eds_bars(_init_spec) +bar_plot = ax_bar.bar(EDS_ELEMENTS, _init_bars.tolist()) + + +# ── ROI rectangle overlays ───────────────────────────────────────────────────── + +_roi_widgets: dict[str, object] = {} for roi_name, (r0, r1, c0, c1) in ROIS.items(): w = img_plot.add_widget( "rectangle", @@ -88,59 +134,57 @@ def _roi_at(x: float, y: float) -> str | None: _roi_widgets[roi_name] = w status_label = img_plot.add_widget( - "label", x=10, y=498, text="Move cursor over image to inspect", + "label", x=10, y=498, text="Move cursor into an ROI", color="#ffffff", fontsize=10, ) +# Coloured integration-window spans on the spectrum (permanent) +for i, (lo, hi) in enumerate(_EDS_WIN): + spec_plot.add_span(lo, hi, axis="x", color=f"{_EDS_COLORS[i]}44") + +_current_roi: list[str | None] = [None] _roi_dragging = False -# ── spectrum update ───────────────────────────────────────────────────────────── +# ── update helpers ───────────────────────────────────────────────────────────── -def _update_spectrum(roi_name: str) -> None: +def _update_for_roi(roi_name: str) -> None: + _current_roi[0] = roi_name + spectrum = _eds_spectrum(roi_name) + bars = _eds_bars(spectrum) + spec_plot.set_data(spectrum, x_axis=EDS_ENERGY) + spec_plot.set_color(_ROI_COLORS[roi_name]) + bar_plot.set_data(bars.tolist()) r0, r1, c0, c1 = ROIS[roi_name] - patch = image[r0:r1, c0:c1] - eds = _mean_eds(patch) - spec_plot.set_data(eds) - print(f"ROI '{roi_name}': Al={eds[0]:.3f} Si={eds[1]:.3f} Fe={eds[2]:.3f} O={eds[3]:.3f}") + mean_val = float(image[r0:r1, c0:c1].mean()) + status_label.set(text=f"ROI: {roi_name} mean={mean_val:.0f}") # ── event handlers ───────────────────────────────────────────────────────────── -def _on_enter(event) -> None: - status_label.set(text="Pixel: — Intensity: —") - - -def _on_leave(event) -> None: - status_label.set(text="Move cursor over image to inspect") - - def _on_move(event) -> None: - if event.xdata is None or event.ydata is None: - return - x = int(np.clip(round(event.xdata), 0, 511)) - y = int(np.clip(round(event.ydata), 0, 511)) - intensity = float(image[y, x]) - status_label.set(text=f"Pixel: ({x}, {y}) Intensity: {intensity:.0f}") - - -def _on_settled(event) -> None: if _roi_dragging or event.xdata is None or event.ydata is None: return roi_name = _roi_at(event.xdata, event.ydata) if roi_name is None: - status_label.set(text="No ROI at cursor position") return - with img_plot.hold_events("pointer_settled"): - _update_spectrum(roi_name) + if roi_name != _current_roi[0]: + _update_for_roi(roi_name) + +def _on_enter(event) -> None: + status_label.set(text="Move cursor into an ROI") + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + _current_roi[0] = None + + +img_plot.add_event_handler(_on_move, "pointer_move") img_plot.add_event_handler(_on_enter, "pointer_enter") img_plot.add_event_handler(_on_leave, "pointer_leave") -img_plot.add_event_handler(_on_move, "pointer_move") -img_plot.add_event_handler(_on_settled, "pointer_settled", ms=350) -# ROI widget drag handlers for roi_name, widget in _roi_widgets.items(): def _make_drag_handler(): def _on_drag(event) -> None: @@ -154,16 +198,16 @@ def _on_release(event) -> None: _roi_dragging = False x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) - _update_spectrum(name) + _update_for_roi(name) return _on_release widget.add_event_handler(_make_drag_handler(), "pointer_move") widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") fig.set_help( - "Move cursor over image: inspect pixel\n" - "Dwell 350 ms inside ROI: compute EDS spectrum\n" + "Move cursor inside an ROI: live spectrum + bar update\n" "Drag ROI rectangle: repositions ROI\n" "Release drag: recomputes spectrum" ) -fig \ No newline at end of file + +fig # Interactive diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py index 10b83450..bb45ff4d 100644 --- a/Examples/Interactive/plot_threshold_explorer.py +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -1,11 +1,20 @@ """ Live intensity thresholding on a multi-phase STEM image. +========================================================= -Scroll the mouse wheel over the image to adjust the threshold (2 counts per -tick). Click a histogram bar to jump the threshold to that bin's upper edge. -Dwell (400 ms) over the image to inspect pixel intensity. The threshold mask -is shown as a red overlay; the histogram always has a vertical line at the -current threshold. +A side-by-side view: the left panel shows a synthetic 512×512 STEM +image with a red overlay marking pixels above the threshold; the right +panel shows a 32-bin intensity histogram with a yellow vertical line at +the current threshold value. + +**Interaction** + +* **Shift+Scroll** over the image — adjusts the threshold by ±2 per + wheel tick (plain scroll pans/zooms the image as normal). +* **Click** a histogram bar — jumps the threshold to that bin's upper + edge. +* **Dwell 400 ms** over the image — shows pixel coordinates and + intensity in the bottom-left label. """ import numpy as np import anyplotlib as apl @@ -93,6 +102,8 @@ def _update_display(thresh: float) -> None: # ── event handlers ───────────────────────────────────────────────────────────── def _on_wheel(event) -> None: + if "shift" not in event.modifiers: + return delta = -2.0 * np.sign(event.dy) if event.dy != 0 else 0.0 _update_display(threshold + delta) @@ -119,7 +130,9 @@ def _on_settled(event) -> None: hist_plot.add_event_handler(_on_bar_click, "pointer_down") fig.set_help( - "Scroll over image: adjust threshold ±2\n" + "Shift+Scroll over image: adjust threshold ±2\n" "Click histogram bar: jump to bin upper edge\n" "Dwell 400 ms over image: inspect pixel intensity" ) + +fig # Interactive From 0a5b826558d69c00ce66b7984eaac48a6dd58fa3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 12:29:57 -0500 Subject: [PATCH 42/43] Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hyperspectral datasets --- Examples/Interactive/plot_roi_inspector.py | 213 -------------- .../Interactive/plot_spectra_roi_inspector.py | 265 ++++++++++++++++++ 2 files changed, 265 insertions(+), 213 deletions(-) delete mode 100644 Examples/Interactive/plot_roi_inspector.py create mode 100644 Examples/Interactive/plot_spectra_roi_inspector.py diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py deleted file mode 100644 index fd89b49d..00000000 --- a/Examples/Interactive/plot_roi_inspector.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -ROI-to-spectrum inspector for a multi-phase STEM image. -======================================================== - -Four rectangular ROIs overlay a synthetic 512×512 STEM image. Moving -the cursor inside any ROI recomputes the average EDS-like spectrum for -that region and refreshes both the continuous spectrum line plot and the -per-element bar chart in real time. Integration windows are shown as -coloured spans on the spectrum. - -**Interaction** - -* **Move cursor inside an ROI** — updates the EDS spectrum and bar - chart live as the cursor crosses ROI boundaries. -* **Drag an ROI rectangle** — repositions the ROI on the image. -* **Release drag** — recomputes the spectrum for the new position. -""" -import numpy as np -import anyplotlib as apl - - -# ── synthetic data ───────────────────────────────────────────────────────────── - -def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: - img = rng.normal(30, 6, (512, 512)).astype(np.float32) - - for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: - ys, xs = np.ogrid[:512, :512] - mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 - img[mask] = rng.normal(160, 12, mask.sum()) - - for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: - ys, xs = np.ogrid[:512, :512] - mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 - img[mask] = rng.normal(110, 10, mask.sum()) - - img[240:270, :] = rng.normal(70, 8, (30, 512)) - - return np.clip(img, 0, 255).astype(np.float32) - - -rng = np.random.default_rng(99) -image = _make_multiphase_image(rng) - - -# ── EDS energy axis and element definitions ──────────────────────────────────── - -EDS_ENERGY = np.linspace(0.1, 3.0, 600) # keV -EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] -_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV -_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] -_EDS_SIGMA = 0.028 # peak width (keV) -_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] - -_PEAKS = np.array([ - np.exp(-0.5 * ((EDS_ENERGY - ev) / _EDS_SIGMA) ** 2) - for ev in _EDS_EV -]) - -ROIS: dict[str, tuple[int, int, int, int]] = { - "Matrix": (50, 200, 50, 200), - "Precipitate A": (50, 200, 310, 460), - "Precipitate B": (310, 460, 50, 200), - "Grain Boundary": (240, 270, 50, 460), -} -_ROI_WEIGHTS: dict[str, np.ndarray] = { - "Matrix": np.array([0.10, 0.05, 0.65, 0.20]), - "Precipitate A": np.array([0.05, 0.08, 0.12, 0.75]), - "Precipitate B": np.array([0.12, 0.60, 0.18, 0.10]), - "Grain Boundary": np.array([0.62, 0.12, 0.18, 0.08]), -} -_ROI_COLORS: dict[str, str] = { - "Matrix": "#4fc3f7", - "Precipitate A": "#aed581", - "Precipitate B": "#ff8a65", - "Grain Boundary": "#ba68c8", -} - -_NOISE_RNG = np.random.default_rng(7) - - -def _eds_spectrum(roi_name: str) -> np.ndarray: - r0, r1, c0, c1 = ROIS[roi_name] - mean_val = float(image[r0:r1, c0:c1].mean()) / 255.0 - weights = _ROI_WEIGHTS[roi_name] - spectrum = (_PEAKS * weights[:, None]).sum(axis=0) * mean_val - spectrum += _NOISE_RNG.normal(0, 0.002, len(EDS_ENERGY)) - return np.clip(spectrum, 0, None) - - -def _eds_bars(spectrum: np.ndarray) -> np.ndarray: - bars = np.array([ - spectrum[(EDS_ENERGY >= lo) & (EDS_ENERGY <= hi)].mean() - for lo, hi in _EDS_WIN - ]) - return bars / (bars.max() or 1.0) - - -def _roi_at(x: float, y: float) -> str | None: - for name, (r0, r1, c0, c1) in ROIS.items(): - if c0 <= x <= c1 and r0 <= y <= r1: - return name - return None - - -# ── layout ───────────────────────────────────────────────────────────────────── - -fig = apl.Figure(figsize=(1100, 560)) -gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) - -ax_img = fig.add_subplot(gs[:, 0]) # image — left column, full height -ax_spec = fig.add_subplot(gs[0, 1]) # EDS spectrum — top right -ax_bar = fig.add_subplot(gs[1, 1]) # bar chart — bottom right - -img_plot = ax_img.imshow(image, cmap="gray") - -_init_spec = _eds_spectrum("Matrix") -spec_plot = ax_spec.plot(_init_spec, axes=[EDS_ENERGY], color="#4fc3f7", linewidth=1.5) - -_init_bars = _eds_bars(_init_spec) -bar_plot = ax_bar.bar(EDS_ELEMENTS, _init_bars.tolist()) - - -# ── ROI rectangle overlays ───────────────────────────────────────────────────── - -_roi_widgets: dict[str, object] = {} -for roi_name, (r0, r1, c0, c1) in ROIS.items(): - w = img_plot.add_widget( - "rectangle", - x=float(c0), y=float(r0), - w=float(c1 - c0), h=float(r1 - r0), - color=_ROI_COLORS[roi_name], - ) - _roi_widgets[roi_name] = w - -status_label = img_plot.add_widget( - "label", x=10, y=498, text="Move cursor into an ROI", - color="#ffffff", fontsize=10, -) - -# Coloured integration-window spans on the spectrum (permanent) -for i, (lo, hi) in enumerate(_EDS_WIN): - spec_plot.add_span(lo, hi, axis="x", color=f"{_EDS_COLORS[i]}44") - -_current_roi: list[str | None] = [None] -_roi_dragging = False - - -# ── update helpers ───────────────────────────────────────────────────────────── - -def _update_for_roi(roi_name: str) -> None: - _current_roi[0] = roi_name - spectrum = _eds_spectrum(roi_name) - bars = _eds_bars(spectrum) - spec_plot.set_data(spectrum, x_axis=EDS_ENERGY) - spec_plot.set_color(_ROI_COLORS[roi_name]) - bar_plot.set_data(bars.tolist()) - r0, r1, c0, c1 = ROIS[roi_name] - mean_val = float(image[r0:r1, c0:c1].mean()) - status_label.set(text=f"ROI: {roi_name} mean={mean_val:.0f}") - - -# ── event handlers ───────────────────────────────────────────────────────────── - -def _on_move(event) -> None: - if _roi_dragging or event.xdata is None or event.ydata is None: - return - roi_name = _roi_at(event.xdata, event.ydata) - if roi_name is None: - return - if roi_name != _current_roi[0]: - _update_for_roi(roi_name) - - -def _on_enter(event) -> None: - status_label.set(text="Move cursor into an ROI") - - -def _on_leave(event) -> None: - status_label.set(text="Move cursor over image to inspect") - _current_roi[0] = None - - -img_plot.add_event_handler(_on_move, "pointer_move") -img_plot.add_event_handler(_on_enter, "pointer_enter") -img_plot.add_event_handler(_on_leave, "pointer_leave") - -for roi_name, widget in _roi_widgets.items(): - def _make_drag_handler(): - def _on_drag(event) -> None: - global _roi_dragging - _roi_dragging = True - return _on_drag - - def _make_release_handler(name, wgt): - def _on_release(event) -> None: - global _roi_dragging - _roi_dragging = False - x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h - ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) - _update_for_roi(name) - return _on_release - - widget.add_event_handler(_make_drag_handler(), "pointer_move") - widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") - -fig.set_help( - "Move cursor inside an ROI: live spectrum + bar update\n" - "Drag ROI rectangle: repositions ROI\n" - "Release drag: recomputes spectrum" -) - -fig # Interactive diff --git a/Examples/Interactive/plot_spectra_roi_inspector.py b/Examples/Interactive/plot_spectra_roi_inspector.py new file mode 100644 index 00000000..eef36b9c --- /dev/null +++ b/Examples/Interactive/plot_spectra_roi_inspector.py @@ -0,0 +1,265 @@ +""" +ROI-to-spectrum inspector for a 3-D EDS hyperspectral dataset. +============================================================== + +A synthetic ``(256, 256, 300)`` EDS datacube — one 300-channel +spectrum per scan position. Four rectangular ROIs overlay the +total-counts image (HAADF proxy). Entering an ROI **sums all spectra +within the rectangle** (spatial sum over every scan position in the +box) and displays the result in the top-right panel. Draggable +coloured range widgets on the spectrum define the integration window +for each element; each bar height is the **channel sum of the ROI +spectrum within that window**. + +**Interaction** + +* **Move cursor inside an ROI** — spatially sums the spectra of all + scan positions inside the box; updates the line plot and bars live. +* **Drag an ROI rectangle** — repositions the ROI on the image. +* **Release drag** — recomputes the spatial sum spectrum for the new + position. +* **Drag a coloured range widget** on the spectrum — adjusts the + integration window for that element; bar heights update on every + drag frame. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic 3-D hyperspectral datacube ────────────────────────────────────── +# Shape: (NY, NX, NC). dataset[y, x, :] is the 300-channel EDS spectrum at +# scan position (x, y). Each pixel is an independent Poisson draw from the +# expected spectrum for its phase. + +NY, NX, NC = 256, 256, 300 +ENERGY = np.linspace(0.1, 3.0, NC) # keV + +EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] +_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV +_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] +_EDS_SIGMA = 0.025 +_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] + +_PEAKS = np.array([ + np.exp(-0.5 * ((ENERGY - ev) / _EDS_SIGMA) ** 2) + for ev in _EDS_EV +]) # shape (4, NC) + +# Per-phase element weight vectors [O, Fe, Al, Si] and expected total +# counts per pixel (determines peak-to-background ratio and brightness). +_PHASE_DEFS = [ + dict(weights=[0.10, 0.05, 0.65, 0.20], counts=80), # 0 Matrix + dict(weights=[0.05, 0.08, 0.12, 0.75], counts=200), # 1 Precipitate A + dict(weights=[0.12, 0.60, 0.18, 0.10], counts=150), # 2 Precipitate B + dict(weights=[0.62, 0.12, 0.18, 0.08], counts=110), # 3 Grain Boundary +] + + +def _expected_spectrum(phase_idx: int) -> np.ndarray: + p = _PHASE_DEFS[phase_idx] + bkg = 3.0 * np.exp(-ENERGY / 0.8) + spec = bkg + (_PEAKS * np.array(p["weights"])[:, None]).sum(axis=0) * p["counts"] + return np.clip(spec, 0, None).astype(np.float64) + + +def _make_dataset(rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: + phases = np.zeros((NY, NX), dtype=np.int8) # 0 = Matrix + + # Precipitate A (Si-rich) — cluster in top-left quadrant + for cx, cy, r in [(60, 60, 30), (75, 50, 22), (45, 75, 20)]: + ys, xs = np.ogrid[:NY, :NX] + phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 1 + + # Precipitate B (Fe-rich) — cluster in bottom-right quadrant + for cx, cy, r in [(195, 195, 27), (180, 210, 20), (210, 180, 17)]: + ys, xs = np.ogrid[:NY, :NX] + phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 2 + + # Grain boundary — thin horizontal band + phases[120:135, :] = 3 + + dataset = np.empty((NY, NX, NC), dtype=np.float32) + flat = dataset.reshape(-1, NC) + phases_flat = phases.ravel() + for pidx, pdef in enumerate(_PHASE_DEFS): + sel = phases_flat == pidx + n = int(sel.sum()) + if n == 0: + continue + lam = _expected_spectrum(pidx) + flat[sel] = rng.poisson(lam, size=(n, NC)).astype(np.float32) + + return dataset, phases + + +rng = np.random.default_rng(99) +dataset, _phase_map = _make_dataset(rng) + +# Total-counts image used as the HAADF-proxy display image +_display_img = dataset.sum(axis=2) + + +# ── ROI definitions (r0, r1, c0, c1) in scan-pixel coordinates ──────────────── + +ROIS: dict[str, tuple[int, int, int, int]] = { + "Matrix": ( 25, 100, 155, 230), + "Precipitate A": ( 25, 100, 25, 100), + "Precipitate B": (155, 230, 155, 230), + "Grain Boundary": (115, 140, 25, 230), +} +_ROI_COLORS: dict[str, str] = { + "Matrix": "#4fc3f7", + "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", + "Grain Boundary": "#ba68c8", +} + + +def _sum_spectrum(r0: int, r1: int, c0: int, c1: int) -> np.ndarray: + """Spatial sum of all spectra within the ROI box.""" + r0 = max(0, min(NY - 1, r0)); r1 = max(1, min(NY, r1)) + c0 = max(0, min(NX - 1, c0)); c1 = max(1, min(NX, c1)) + return dataset[r0:r1, c0:c1, :].sum(axis=(0, 1)) + + +def _roi_at(x: float, y: float) -> str | None: + for name, (r0, r1, c0, c1) in ROIS.items(): + if c0 <= x <= c1 and r0 <= y <= r1: + return name + return None + + +# ── layout ───────────────────────────────────────────────────────────────────── + +fig = apl.Figure(figsize=(1100, 560)) +gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) + +ax_img = fig.add_subplot(gs[:, 0]) # total-counts image — left column +ax_spec = fig.add_subplot(gs[0, 1]) # ROI sum spectrum — top right +ax_bar = fig.add_subplot(gs[1, 1]) # element bar chart — bottom right + +img_plot = ax_img.imshow(_display_img, cmap="gray") + +_init_spec = _sum_spectrum(*ROIS["Matrix"]).astype(np.float32) +spec_plot = ax_spec.plot(_init_spec, axes=[ENERGY], + color=_ROI_COLORS["Matrix"], linewidth=1.5, + units="keV", y_units="counts") +bar_plot = ax_bar.bar(EDS_ELEMENTS, [0.0] * 4) + + +# ── ROI rectangle overlays on the image ─────────────────────────────────────── + +_roi_widgets: dict[str, object] = {} +for roi_name, (r0, r1, c0, c1) in ROIS.items(): + w = img_plot.add_widget( + "rectangle", + x=float(c0), y=float(r0), + w=float(c1 - c0), h=float(r1 - r0), + color=_ROI_COLORS[roi_name], + ) + _roi_widgets[roi_name] = w + +status_label = img_plot.add_widget( + "label", x=4, y=248, text="Move cursor into an ROI", + color="#ffffff", fontsize=10, +) + + +# ── adjustable range widgets on the spectrum ─────────────────────────────────── + +range_widgets: dict[str, object] = {} +for elem, (lo, hi), color in zip(EDS_ELEMENTS, _EDS_WIN, _EDS_COLORS): + range_widgets[elem] = spec_plot.add_range_widget(lo, hi, color=color) + +_current_spectrum: list[np.ndarray] = [_init_spec.copy()] + + +def _channel_sum(x0: float, x1: float) -> float: + """Sum of ROI spectrum counts within the energy window [x0, x1].""" + mask = (ENERGY >= x0) & (ENERGY <= x1) + return float(_current_spectrum[0][mask].sum()) if mask.any() else 0.0 + + +def _update_bars() -> None: + heights = np.array([ + _channel_sum(range_widgets[e].x0, range_widgets[e].x1) + for e in EDS_ELEMENTS + ]) + max_h = heights.max() or 1.0 + bar_plot.set_data((heights / max_h).tolist()) + + +for _rw in range_widgets.values(): + _rw.add_event_handler(lambda event: _update_bars(), "pointer_move") + _rw.add_event_handler(lambda event: _update_bars(), "pointer_up") + +_update_bars() + + +# ── update helper ────────────────────────────────────────────────────────────── + +_current_roi: list[str | None] = [None] +_roi_dragging = False + + +def _update_for_roi(roi_name: str) -> None: + _current_roi[0] = roi_name + r0, r1, c0, c1 = ROIS[roi_name] + _current_spectrum[0] = _sum_spectrum(r0, r1, c0, c1).astype(np.float32) + spec_plot.set_data(_current_spectrum[0], x_axis=ENERGY) + spec_plot.set_color(_ROI_COLORS[roi_name]) + _update_bars() + n_pixels = (r1 - r0) * (c1 - c0) + status_label.set(text=f"ROI: {roi_name} ({n_pixels} px)") + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_move(event) -> None: + if _roi_dragging or event.xdata is None or event.ydata is None: + return + roi_name = _roi_at(event.xdata, event.ydata) + if roi_name is None or roi_name == _current_roi[0]: + return + _update_for_roi(roi_name) + + +def _on_enter(event) -> None: + status_label.set(text="Move cursor into an ROI") + + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + _current_roi[0] = None + + +img_plot.add_event_handler(_on_move, "pointer_move") +img_plot.add_event_handler(_on_enter, "pointer_enter") +img_plot.add_event_handler(_on_leave, "pointer_leave") + +for roi_name, widget in _roi_widgets.items(): + def _make_drag_handler(): + def _on_drag(event) -> None: + global _roi_dragging + _roi_dragging = True + return _on_drag + + def _make_release_handler(name, wgt): + def _on_release(event) -> None: + global _roi_dragging + _roi_dragging = False + x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h + ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) + _update_for_roi(name) + return _on_release + + widget.add_event_handler(_make_drag_handler(), "pointer_move") + widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") + +fig.set_help( + "Move cursor inside an ROI: spatial sum spectrum + bars\n" + "Drag ROI rectangle: repositions ROI; release recomputes\n" + "Drag a coloured range widget: adjust element integration window" +) + +fig # Interactive From 189a4eab2100247e512c2df1bc5ea650e2932a79 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 15:03:27 -0500 Subject: [PATCH 43/43] Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hyperspectral datasets --- anyplotlib/tests/test_examples/test_interactive_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index cd644c87..4531882b 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -10,7 +10,7 @@ "plot_particle_picker.py", "plot_eels_explorer.py", "plot_threshold_explorer.py", - "plot_roi_inspector.py", + "plot_spectra_roi_inspector.py", ]