diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py index 82a17e09..484478c9 100644 --- a/Examples/Interactive/plot_3d_spectral_viewer.py +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -121,25 +121,25 @@ 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)) + 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) 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 _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. @@ -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,10 +210,10 @@ 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])) + 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_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py new file mode 100644 index 00000000..8c96fce7 --- /dev/null +++ b/Examples/Interactive/plot_eels_explorer.py @@ -0,0 +1,211 @@ +""" +EELS multi-spectrum explorer. +============================== + +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 + + +# ── 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: + 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}" + 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" +) + +fig # interactive diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 5d9f464a..fd556a63 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,13 +157,13 @@ 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) - 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 8acd7b69..02916a5d 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -125,14 +125,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 = 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) @@ -142,13 +142,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 = 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) @@ -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..47ad388a 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** @@ -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, @@ -61,10 +61,12 @@ # ── 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.""" - cx, cy = event.img_x, event.img_y + if event.key != 'q': + return + cx, cy = event.xdata, event.ydata half_w, half_h = N * 0.08, N * 0.08 plot.add_widget( "rectangle", @@ -74,23 +76,27 @@ 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, + cx=event.xdata, cy=event.ydata, r=N * 0.07, color="#80cbc4", ) -@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, + cx=event.xdata, cy=event.ydata, r_outer=N * 0.12, r_inner=N * 0.06, color="#ce93d8", @@ -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}" + 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}") fig # Interactive diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py new file mode 100644 index 00000000..72a1ee33 --- /dev/null +++ b/Examples/Interactive/plot_particle_picker.py @@ -0,0 +1,209 @@ +""" +HAADF STEM nanoparticle picker. +================================= + +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 + + +# ── 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: + 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="") + 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: + if event.xdata is None or event.ydata is None: + return + 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" +) + +fig # interactive diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 90823230..6a2fbe56 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,17 +92,17 @@ 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") + print(f" dragging x={event.source.x:.4f} y={event.source.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} ") - _draw_tangent(event.x) + print(f" released x={event.source.x:.4f} ") + _draw_tangent(event.source.x) fig # Interactive diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index e195dccc..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 @@ -160,12 +162,12 @@ 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) - 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)) @@ -180,40 +182,46 @@ 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.""" - cx = float(event.img_x) - cy = float(event.img_y) # img_y = row + if event.key not in ('Delete', 'Backspace'): + return + cx = float(event.xdata) + cy = float(event.ydata) # ydata = row best_dist = float("inf") best_list = None 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 diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py new file mode 100644 index 00000000..bb45ff4d --- /dev/null +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -0,0 +1,138 @@ +""" +Live intensity thresholding on a multi-phase STEM image. +========================================================= + +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 + + +# ── 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: + 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) + + +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: + 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]) + 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( + "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 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/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index bdf9703a..c568aff6 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -88,16 +88,16 @@ 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) + v.set_clim(vmin=event.source.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) + v.set_clim(vmax=event.source.x) fig # Interactive diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 66ec37af..6330c1cf 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -2,121 +2,327 @@ 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. - :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`` + Universal fields (every event): + event_type, source, time_stamp, modifiers + Pointer fields (pointer_* and double_click events): + 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) + ray — Plot3D only: {"origin": [...], "direction": [...]} + line_id — Plot1D only: set when pointer is over a line + dwell_ms — pointer_settled only: actual dwell time - For ``on_line_hover`` and ``on_line_click`` events the data dict - contains: + PlotBar extra fields (pointer_down only): + bar_index, value, x_label, group_index - * ``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. + Wheel fields: + dx, dy — scroll deltas + + 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 """ 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: float | None = None + y: float | 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 + last_widget_id: 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.""" + """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]] = {} + # {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) -> int: - """Register fn for event_type. Returns integer CID.""" - if event_type not in _VALID_EVENT_TYPES: + 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"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 - 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: - """Remove handler for cid. Silent if not found.""" - 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: + """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()) + + @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] - def fire(self, event) -> None: - """Dispatch event to all handlers matching event.event_type.""" - for _cid, (et, fn) in list(self._entries.items()): - if et == event.event_type: - fn(event) + @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 bool(self._entries) + 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. + """ + 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 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: + """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/figure/_figure.py b/anyplotlib/figure/_figure.py index 167ec4c3..7d832dda 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 @@ -179,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) @@ -361,21 +376,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 +401,33 @@ 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"), + last_widget_id=msg.get("last_widget_id"), + ) plot.callbacks.fire(event) def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0e77375d..a77874a6 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 ────────────────────────────────────────────────── @@ -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 { @@ -1714,7 +1733,7 @@ function render({ model, el }) { dragStart = { mx: _d3mx, my: _d3my, az: p.state.azimuth, el: p.state.elevation }; overlayCanvas.style.cursor = 'grabbing'; - e.preventDefault(); + // Do NOT call e.preventDefault() — suppresses click → dblclick cascade. }); document.addEventListener('mousemove', (e) => { if (!dragStart) return; @@ -1725,17 +1744,20 @@ 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', () => { + document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; 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), button: e.button }); _scheduleCommit(); }); @@ -1744,8 +1766,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 }); @@ -1753,21 +1782,44 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + const _now = performance.now(); + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, + modifiers: _settledMods, + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: _now - _settledStartTs, + }); + } + }, _settledMs); + } }); // 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 ?? 0, y: p.mouseY ?? 0, + }); if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; draw3d(p); @@ -1779,7 +1831,26 @@ 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) => { + clearTimeout(_settledTimer); _settledTimer = null; + _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), button: e.button, x: mx, y: my}); + }); } // ── 1D drawing ─────────────────────────────────────────────────────────── @@ -2418,6 +2489,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);}); @@ -2467,13 +2540,16 @@ 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){ _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; @@ -2493,13 +2569,14 @@ 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),button:e.button}); return; } if(!p.isPanning) return; @@ -2518,17 +2595,18 @@ 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; 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), + button:e.button, }); // _emitEvent already calls model.save_changes() — no duplicate needed. return; @@ -2538,7 +2616,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),button:e.button}); model.save_changes(); }); @@ -2584,39 +2662,95 @@ 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);} } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + const _now = performance.now(); + const st2 = p.state; if (!st2) return; + const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); + const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); + const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); + const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; + const _siw = st2.image_width || 1, _sih = st2.image_height || 1; + const sPhysX = sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX; + const sPhysY = sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY; + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, + modifiers: _settledMods, + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + img_x: sImgX, + img_y: sImgY, + xdata: sPhysX, + ydata: sPhysY, + dwell_ms: _now - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + 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);} }); + 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); + 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,{ + 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 - // to Python via on_key and suppresses the matching built-in. + // 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 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 ?? 0, y:p.mouseY ?? 0, + 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; @@ -2637,12 +2771,25 @@ 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}); + }); } 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);}); @@ -2680,13 +2827,14 @@ 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){ _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; @@ -2702,6 +2850,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){ @@ -2710,12 +2859,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),button:e.button}); } 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),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 @@ -2726,35 +2875,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),button:e.button}); } } 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 ?? 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();} }); + 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); @@ -2763,6 +2920,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||[])); @@ -2793,13 +2951,61 @@ 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}); + if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); + } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + const _now = performance.now(); + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, + modifiers: _settledMods, + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: _now - _settledStartTs, + }); + } + }, _settledMs); } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + 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);} 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); + 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,{ + 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 ──────────────────────────────────── @@ -3730,6 +3936,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(); }); @@ -3751,10 +3959,11 @@ 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] || {}; @@ -3762,7 +3971,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), button: e.button}); _scheduleCommit(); }); @@ -3804,23 +4013,60 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + const _now = performance.now(); + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, + modifiers: _settledMods, + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: _now - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave', () => { + 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'; }); - 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); - _emitEvent(p.id, 'on_click', null, { + _emitEvent(p.id, 'pointer_down', null, { bar_index: idx, group_index: gi, value: val, @@ -3828,25 +4074,47 @@ function render({ model, el }) { x_center: (st.x_centers||[])[idx] ?? idx, x_label: (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : null, + ..._baseFields, }); }); - // 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 ?? 0, y: p.mouseY ?? 0, + }); + }); + 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), button: e.button, x: mx, y: my, xdata: null}); + }); + 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/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index a16cb0fa..259e4327 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, @@ -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,27 +65,37 @@ 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. - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks on *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 +147,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 +285,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 +294,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 +745,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 # ------------------------------------------------------------------ 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 00000000..3f9d1bc4 Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png differ 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 00000000..87f60054 Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png differ 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 00000000..b65992f6 Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png differ 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 00000000..abb8d4e3 Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png differ diff --git a/anyplotlib/tests/baselines/gridspec_side_by_side_1d.png b/anyplotlib/tests/baselines/gridspec_side_by_side_1d.png new file mode 100644 index 00000000..05e04a4a Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_side_by_side_1d.png differ diff --git a/anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png b/anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png new file mode 100644 index 00000000..8c133920 Binary files /dev/null and b/anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png differ 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..4531882b --- /dev/null +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -0,0 +1,27 @@ +"""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_spectra_roi_inspector.py", +] + + +def _exec_script(name: str) -> None: + path = EXAMPLES_DIR / name + 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) + + +@pytest.mark.parametrize("script", SCRIPTS) +def test_example_executes(script: str) -> None: + _exec_script(script) 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_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index b38fae34..7aff6784 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -1,682 +1,494 @@ -""" -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 numpy as np 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 -# ───────────────────────────────────────────────────────────────────────────── +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin -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))) - - -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 -# ───────────────────────────────────────────────────────────────────────────── + def test_required_fields(self): + e = Event(event_type="pointer_down", source=None) + assert e.event_type == "pointer_down" + assert e.source is None + + def test_time_stamp_auto_set(self): + before = time.perf_counter() + e = Event(event_type="pointer_down") + after = time.perf_counter() + assert before <= e.time_stamp <= after + + def test_modifiers_default_empty_list(self): + e = Event(event_type="pointer_move") + assert e.modifiers == [] + assert isinstance(e.modifiers, list) + + def test_pointer_fields_default_none(self): + e = Event(event_type="pointer_move") + assert e.x is None + assert e.y is None + assert e.button is None + assert e.buttons == 0 + assert e.xdata is None + assert e.ydata is None + assert e.ray is None + assert e.line_id is None + assert e.dwell_ms is None + + def test_wheel_fields_default_none(self): + e = Event(event_type="wheel") + assert e.dx is None + assert e.dy is None + + def test_key_field_default_none(self): + e = Event(event_type="key_down") + assert e.key is None + + def test_bar_fields_default_none(self): + e = Event(event_type="pointer_down") + assert e.bar_index is None + assert e.value is None + assert e.x_label is None + assert e.group_index is None + + def test_stop_propagation_default_false(self): + e = Event(event_type="pointer_down") + assert e.stop_propagation is False + + def test_all_fields_settable(self): + e = Event( + event_type="pointer_down", + source="plot", + modifiers=["ctrl", "shift"], + x=100, y=200, + button=0, buttons=1, + xdata=3.14, ydata=2.71, + line_id="abc12345", + bar_index=2, value=99.5, x_label="Jan", group_index=1, + dx=10.0, dy=-5.0, + key="q", + ) + assert e.modifiers == ["ctrl", "shift"] + assert e.x == 100 + assert e.xdata == 3.14 + assert e.line_id == "abc12345" + assert e.bar_index == 2 + assert e.key == "q" + assert e.dx == 10.0 + assert e.dy == -5.0 + + def test_no_data_dict_attribute(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") + + def test_repr_includes_event_type(self): + e = Event(event_type="pointer_down", x=10, y=20) + assert "pointer_down" in repr(e) + + def test_stop_propagation_not_in_repr(self): + e = Event(event_type="pointer_down", stop_propagation=True) + assert "stop_propagation" not in repr(e) -class TestCallbackRegistry: +class TestCallbackRegistry: def test_connect_returns_int_cid(self): reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) + cid = reg.connect("pointer_down", lambda e: None) assert isinstance(cid, int) - def test_connect_cids_increment(self): + def test_fire_calls_handler(self): reg = CallbackRegistry() - c1 = reg.connect("on_changed", lambda e: None) - c2 = reg.connect("on_release", lambda e: None) - assert c2 > c1 + calls = [] + reg.connect("pointer_down", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + assert calls == ["pointer_down"] - def test_invalid_event_type_raises(self): + def test_fire_only_matching_type(self): reg = CallbackRegistry() - with pytest.raises(ValueError, match="event_type must be one of"): - reg.connect("change", lambda e: None) # old name + 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_fire_on_changed(self): + def test_disconnect_by_cid(self): reg = CallbackRegistry() - fired = [] - reg.connect("on_changed", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert len(fired) == 1 + calls = [] + cid = reg.connect("pointer_down", lambda e: calls.append(1)) + reg.disconnect(cid) + reg.fire(Event("pointer_down")) + assert calls == [] - def test_fire_does_not_cross_types(self): + def test_disconnect_silent_if_not_found(self): reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert fired == [] + reg.disconnect(999) # should not raise - def test_fire_on_release(self): + def test_wildcard_receives_all_types(self): reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_release", None, {})) - assert len(fired) == 1 + 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_fire_on_click(self): + def test_same_priority_fires_in_registration_order(self): reg = CallbackRegistry() - fired = [] - reg.connect("on_click", lambda e: fired.append(e)) - reg.fire(Event("on_click", None, {})) - assert len(fired) == 1 + 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_three_types_independent(self): + def test_stop_propagation(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): + 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() - fired = [] - cid = reg.connect("on_release", lambda e: fired.append(e)) - reg.disconnect(cid) - reg.fire(Event("on_release", None, {})) - assert fired == [] + 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_disconnect_unknown_cid_is_silent(self): + def test_invalid_event_type_raises(self): reg = CallbackRegistry() - reg.disconnect(9999) + with pytest.raises(ValueError, match="Invalid event_type"): + reg.connect("on_click", lambda e: None) - def test_disconnect_twice_is_silent(self): + def test_connect_same_fn_multiple_types(self): reg = CallbackRegistry() - cid = reg.connect("on_release", lambda e: None) - reg.disconnect(cid) - reg.disconnect(cid) + 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"] - def test_bool_false_when_empty(self): - assert not CallbackRegistry() - def test_bool_true_when_connected(self): +class TestPauseHold: + def test_pause_drops_events(self): reg = CallbackRegistry() - reg.connect("on_changed", lambda e: None) - assert reg + 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_bool_false_after_all_disconnected(self): + def test_pause_handlers_intact_after_exit(self): reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) - reg.disconnect(cid) - assert not reg - - def test_multiple_handlers_all_called(self): + 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() - 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): + 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() - 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.""" + calls = [] + reg.connect("pointer_move", lambda e: calls.append("move")) + reg.connect("pointer_down", lambda e: calls.append("down")) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_pause_nested_same_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) # still paused — outer not exited + reg.fire(Event("pointer_move")) # now fires + assert calls == [1] + + def test_hold_buffers_and_flushes_on_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_settled")) + reg.fire(Event("pointer_settled")) + assert calls == [] # buffered, not fired yet + assert calls == [1, 1] # flushed on exit + + def test_hold_fires_non_held_types_immediately(self): + reg = CallbackRegistry() + move_calls = [] + settled_calls = [] + reg.connect("pointer_move", lambda e: move_calls.append(1)) + reg.connect("pointer_settled", lambda e: settled_calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_move")) # not held → immediate + reg.fire(Event("pointer_settled")) # held → buffered + assert move_calls == [1] + assert settled_calls == [1] # flushed on exit + + def test_hold_events_in_order(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(e.x)) + with reg.hold_events(): + reg.fire(Event("pointer_settled", x=1)) + reg.fire(Event("pointer_settled", x=2)) + reg.fire(Event("pointer_settled", x=3)) + assert calls == [1, 2, 3] + + def test_pause_wins_over_hold(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.hold_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] # dropped, not buffered then flushed + + +class _FakePlot(_EventMixin): + """Minimal plot stub for testing _EventMixin.""" + def __init__(self): + self.callbacks = CallbackRegistry() + self._settled_config = (0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._settled_config = (ms, delta) + + +class TestEventMixin: + def test_functional_form_single_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_functional_form_multi_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + def test_decorator_form_single_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_move") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_move")) + assert calls == ["pointer_move"] + + def test_decorator_form_multi_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_down", "key_down") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("key_down")) + assert calls == ["pointer_down", "key_down"] + + def test_wildcard_decorator(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("*") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("wheel")) + assert calls == ["pointer_down", "wheel"] + + def test_remove_handler_by_fn(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_remove_handler_by_fn_specific_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.remove_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_remove_handler_by_cid(self): + plot = _FakePlot() + calls = [] + cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) + plot.remove_handler(cid) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_pointer_settled_configures_on_connect(self): + plot = _FakePlot() + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._settled_config == (400, 5) + + def test_pointer_settled_clears_on_last_disconnect(self): + plot = _FakePlot() + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._settled_config == (0, 0) + + def test_ms_delta_without_settled_raises(self): + plot = _FakePlot() + with pytest.raises(ValueError, match="ms/delta"): + plot.add_event_handler(lambda e: None, "pointer_down", ms=400) + + def test_pause_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_move") + with plot.pause_events("pointer_move"): + plot.callbacks.fire(Event("pointer_move")) + assert calls == [] + + def test_hold_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") + with plot.hold_events("pointer_settled"): + plot.callbacks.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1] + + +# ── regression: old API is gone ────────────────────────────────────────────── + + +class TestRegressionOldAPIGone: + """Confirm old decorator methods no longer exist on plots and widgets.""" + + def test_plot1d_no_on_click(self): fig, ax = apl.subplots(1, 1) - 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) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_click") - _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): + def test_plot1d_no_on_changed(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 == [] + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_changed") - def test_dispatch_empty_json_ignored(self): + def test_plot1d_no_on_release(self): fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "{}"}) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_release") - def test_dispatch_invalid_json_ignored(self): + def test_plot1d_no_on_key(self): fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "not-json"}) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_key") - def test_source_python_not_dispatched(self): + def test_plot1d_no_disconnect(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 == [] + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "disconnect") - 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): + def test_plot2d_no_on_click(self): fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((16, 16))) - fired = [] + plot = ax.imshow(np.zeros((32, 32))) + assert not hasattr(plot, "on_click") - @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): + def test_widget_no_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 - + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_changed") -# ───────────────────────────────────────────────────────────────────────────── -# 8. Practical patterns -# ───────────────────────────────────────────────────────────────────────────── - -class TestPracticalPatterns: - - def test_readout_update_on_drag(self): + def test_widget_no_on_release(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): + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_release") + + def test_event_no_phys_x(self): + from anyplotlib.callbacks import Event + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_plot3d_no_on_click(self): + import numpy as np + x = np.linspace(-2, 2, 10) + XX, YY = np.meshgrid(x, x) fig, ax = apl.subplots(1, 1) - v = ax.plot(np.zeros(64)) - wid = v.add_vline_widget(x=284.0) - calls = {"cheap": 0, "expensive": 0} + plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) + assert not hasattr(plot, "on_click") - @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): + def test_plotbar_no_on_click(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) + plot = ax.bar(["A", "B"], [1.0, 2.0]) + assert not hasattr(plot, "on_click") - @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): + def test_line1d_no_on_hover(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) + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_hover") - def test_widget_x_readback_after_js_event(self): + def test_line1d_no_on_click(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) - + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_click") 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..2a21e70d --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_pause_hold.py @@ -0,0 +1,214 @@ +""" +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 +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + GRID_PAD, +) + +FIG_W, FIG_H = 400, 300 + + +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)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 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..b95a0e44 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -0,0 +1,295 @@ +""" +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 +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, +) + +FIG_W, FIG_H = 400, 300 + + +# ── 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" + assert events[0].get("button") == 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 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_no_xdata(self, interact_page): + """3D panels do not emit pointer_down events (no click detection in 3D).""" + page, plot = _make_3d_page(interact_page) + _collect_events(page) + 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(300) + + events = _get_events(page, "pointer_down") + 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.""" + 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..8655af6f --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -0,0 +1,185 @@ +""" +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 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, +) + +FIG_W, FIG_H = 400, 300 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 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 + 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.""" + 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() + + def _settled_count(): + return "() => window._aplAllEvents.filter(e => e.event_type === 'pointer_settled').length" + + # 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 for a second dwell period + page.mouse.move(px + 30, py + 30) + page.wait_for_timeout(50) # ensure the move is processed before re-entering + page.mouse.move(px, py) + page.wait_for_function(f"{_settled_count()} >= 2", timeout=2000) + + second_count = len(_get_events(page, "pointer_settled")) + assert second_count >= 2, ( + f"Expected at least 2 pointer_settled events, got {second_count}" + ) diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index 218727ca..17c6dd23 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,44 +431,44 @@ 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 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.on_click(lambda event: results.append(event.line_id)) + 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.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 pointer_down 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), "pointer_down") - # No line_id in payload → event.data.get("line_id") is None → matches primary - _simulate_js_event(fig, v, "on_line_click") + # No line_id in payload → event.line_id is None → matches primary + _simulate_js_event(fig, v, "pointer_down") 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), "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): @@ -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,16 +1200,16 @@ 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("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, })}) @@ -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("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1227,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, })}) @@ -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("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1250,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", })}) @@ -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("pointer_down") def _clicked(event, c=ctrl): c.toggle() return result @@ -1276,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 @@ -1285,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 @@ -1297,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): @@ -1309,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 @@ -1327,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]) @@ -1349,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 @@ -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( @@ -1371,9 +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 - - - 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_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" 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}" ) 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) + 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 700b64fa..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): @@ -94,7 +99,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. @@ -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,43 +148,58 @@ 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() # ── 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", + } 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 ────────────────────────────────────────────────────────── 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