"""
callbacks.py
============
Event system used by all plot objects and widgets.
:class:`Event`
Flat dataclass carrying all event fields as typed top-level attributes.
:class:`CallbackRegistry`
Per-object handler store. (Full implementation added in Tasks 2-3.)
: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 = frozenset({
"pointer_down", "pointer_up", "pointer_move", "pointer_settled",
"pointer_enter", "pointer_leave", "double_click", "wheel",
"key_down", "key_up", "close", "view_changed",
# Figure-level inset drag / resize (JS → Python), so a host can persist the
# inset's new geometry. Fired through the figure's CallbackRegistry.
"inset_geometry_change", "*",
})
[docs]
@dataclass
class Event:
"""A single interactive event with all payload fields as typed attributes.
Universal fields (every event):
event_type, source, time_stamp, modifiers
Pointer fields (pointer_* and double_click events):
x, y — 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
target — double_click only: hit chrome element, or None for plot-area (title/x_label/x_ticks/y_label/y_ticks/colorbar_label/legend)
PlotBar extra fields (pointer_down only):
bar_index, value, x_label, group_index
Wheel fields:
dx, dy — scroll deltas
Key fields:
key — key name e.g. "q", "Enter", "ArrowLeft"
last_widget_id — id of the last widget the user clicked, or None
Panel-swap fields (figure-level panel_swap events):
source_panel_id, target_panel_id — the two panel ids dragged between
Inset drag/resize fields (figure-level inset_geometry_change events):
inset_id — the inset panel id
anchor — new top-left position [fx, fy] in figure fractions
w_frac, h_frac — new size in figure fractions
Propagation:
stop_propagation — set True inside a handler to halt remaining handlers
"""
event_type: str
source: Any = None
time_stamp: float = field(default_factory=time.perf_counter)
modifiers: list[str] = field(default_factory=list)
# Pointer
x: 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
# View (view_changed events — the current zoom/pan viewport)
zoom: float | None = None
center_x: float | None = None
center_y: float | None = None
image_width: int | None = None
image_height: int | None = None
display_width: int | None = None # panel device px (JS) → tile output resolution
display_height: int | None = None
# Camera (Plot3D orbit) — the angles the drag just produced, in degrees.
# The JS has always sent these alongside `zoom`; without them a handler
# cannot react to an orbit, because a JS-side drag does not sync back into
# Plot3D._state.
azimuth: float | None = None
elevation: float | None = None
# Key
key: str | None = None
last_widget_id: str | None = None
# Panel drag-swap (figure-level panel_swap events): the two panel dispatch
# ids the user dragged between. Set only on panel_swap; None otherwise.
source_panel_id: str | None = None
target_panel_id: str | None = None
# Inset drag / resize (figure-level inset_geometry_change events): the
# inset panel id plus its new geometry in figure fractions. Set only on
# inset_geometry_change; None otherwise.
inset_id: str | None = None
anchor: list | None = None
w_frac: float | None = None
h_frac: float | None = None
# Tagged double-click hit-target (double_click events): identifies WHICH
# text/chrome element was double-clicked — one of 'title', 'x_label',
# 'x_ticks', 'y_label', 'y_ticks', 'colorbar_label', 'legend'. None for a
# plain plot-area double_click (back-compatible) and every other event.
target: str | None = None
# Propagation (not repr'd)
stop_propagation: bool = field(default=False, repr=False)
def __repr__(self) -> str:
src = type(self.source).__name__ if self.source is not None else "None"
parts = [f"event_type={self.event_type!r}", f"source={src}"]
for fname in ("x", "y", "xdata", "ydata", "button", "key",
"line_id", "bar_index", "dwell_ms", "target"):
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) + ")"
[docs]
class CallbackRegistry:
"""Per-object handler store.
Supports:
- Priority ordering (``order`` kwarg — lower fires first)
- Wildcard ``"*"`` type receives every dispatched event
- ``stop_propagation`` on the event halts remaining handlers
- ``disconnect_fn(fn, *types)`` removes by callback reference
- ``pause_events`` / ``hold_events`` context managers (added in Task 3)
"""
def __init__(self) -> None:
# {event_type: [(order, cid, fn), ...]} — sorted by order
self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list)
self._next_cid: int = 1
# {cid: set[str]} — which types this cid is registered under
self._cid_map: dict[int, set[str]] = {}
# {id(fn): set[int]} — which cids this fn owns
self._fn_map: dict[int, set[int]] = defaultdict(set)
# pause/hold state (populated in Task 3)
self._pause_counts: dict[str, int] = {}
self._hold_counts: dict[str, int] = {}
self._held: deque[Event] = deque()
# ── registration ─────────────────────────────────────────────────────
[docs]
def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int:
"""Register fn for event_type. Returns integer CID."""
if event_type not in VALID_EVENT_TYPES:
raise ValueError(
f"Invalid event_type {event_type!r}. "
f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'"
)
cid = self._next_cid
self._next_cid += 1
self._handlers[event_type].append((order, cid, fn))
self._handlers[event_type].sort(key=lambda t: t[0])
self._cid_map.setdefault(cid, set()).add(event_type)
self._fn_map[id(fn)].add(cid)
return cid
[docs]
def disconnect(self, cid: int) -> None:
"""Remove handler by CID. Silent if not found."""
types = self._cid_map.pop(cid, set())
for et in types:
self._handlers[et] = [
(o, c, f) for o, c, f in self._handlers[et] if c != cid
]
for fn_cids in self._fn_map.values():
fn_cids.discard(cid)
[docs]
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 ─────────────────────────────────────────────────────────
[docs]
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())
[docs]
@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]
[docs]
@contextmanager
def hold_events(self, *types: str):
"""Buffer events of the given types; flush when the outermost hold exits.
All types are held when called with no arguments."""
target = types if types else ("*",)
for t in target:
self._hold_counts[t] = self._hold_counts.get(t, 0) + 1
try:
yield
finally:
for t in target:
self._hold_counts[t] -= 1
if self._hold_counts[t] == 0:
del self._hold_counts[t]
if not self._hold_counts:
self._flush()
def __bool__(self) -> bool:
return any(bool(v) for v in self._handlers.values())
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