Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1115f8b
callbacks: accept bound methods as event handlers
CSSFrancis Oct 21, 2026
eb85cb0
widgets: add public notify keyword to Widget.set
CSSFrancis Oct 21, 2026
f4027db
markers: broadcast length-1 sequences like scalars
CSSFrancis Oct 21, 2026
281b272
plot2d: add ImageTransform for data <-> image-pixel coordinates
CSSFrancis Oct 21, 2026
3f3e9ed
widgets: add public Widget.plot and Widget.kind
CSSFrancis Oct 21, 2026
e0d7556
widgets: opt-in data-coordinate geometry (coords="data")
CSSFrancis Oct 21, 2026
2d2a620
markers: opt-in data-coordinate geometry (coords="data")
CSSFrancis Oct 21, 2026
ec4fff7
axes: add public figure / primary_plot / plots / image_plot
CSSFrancis Oct 21, 2026
1d381a4
axes: label setters that work before a plot is attached
CSSFrancis Oct 21, 2026
a618892
figure: public close hook and display/closed lifecycle flags
CSSFrancis Oct 21, 2026
89ca7bc
docs: document close/lifecycle, bound-method handlers and notify=False
CSSFrancis Oct 21, 2026
9e9d5c1
docs: show calibrated-coordinate widgets and markers in getting started
CSSFrancis Oct 21, 2026
4349625
markers: public MarkerGroup.marker_type / name / get()
CSSFrancis Oct 21, 2026
1c4f60f
plot2d: calibrate images from matplotlib-style edge extents
CSSFrancis Oct 21, 2026
f006568
figure: get_axes lists empty axes too
CSSFrancis Oct 21, 2026
fa7a303
markers: allow empty groups in data coordinates
CSSFrancis Oct 21, 2026
ebb2c96
widgets: convert data-coordinate rectangles as spans
CSSFrancis Oct 21, 2026
a3e96fb
figure: close runs figure and panel handlers independently
CSSFrancis Oct 21, 2026
5678189
widgets: repr reports geometry in the widget's own frame
CSSFrancis Oct 21, 2026
937371f
axes: keep Axes-level labels across plot replacement
CSSFrancis Oct 21, 2026
c2850d8
docs: note widget vs marker behaviour on re-calibration
CSSFrancis Oct 21, 2026
fafb6bf
figure: release the ipywidgets widget on close
CSSFrancis Oct 21, 2026
25ca54e
docs: describe close() error handling and widget release
CSSFrancis Oct 21, 2026
d8309ab
docs: fix add_widget docstring literal block breaking the docs build
CSSFrancis Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions anyplotlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from anyplotlib.axes import Axes, InsetAxes
from anyplotlib.plot1d import Plot1D, PlotBar
from anyplotlib.plot1d._plot1d import Line1D
from anyplotlib.plot2d import Plot2D, PlotMesh
from anyplotlib.plot2d import Plot2D, PlotMesh, ImageTransform
from anyplotlib.plot3d import Patch3D, Plot3D
from anyplotlib.plotxy import PlotXY
from anyplotlib.callbacks import CallbackRegistry, Event
Expand Down Expand Up @@ -41,7 +41,7 @@ def get_color_cycle() -> list[str]:

__all__ = [
"Figure", "GridSpec", "SubplotSpec", "subplots",
"Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "Patch3D", "PlotBar",
"Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "ImageTransform", "Plot3D", "Patch3D", "PlotBar",
"Line1D",
"CallbackRegistry", "Event",
"MarkerRegistry", "MarkerGroup", "KeyOverlay",
Expand Down
6 changes: 4 additions & 2 deletions anyplotlib/_base_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ class _PanelMixin:
def _push(self) -> None:
if self._fig is None:
return
self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
self._state["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()]
self._fig._push(self._id)

# ── geometry / coordinate conversion ──────────────────────────────────
Expand Down Expand Up @@ -632,7 +632,7 @@ def remove_widget(self, wid) -> None:
wid = wid.id
if wid not in self._widgets:
raise KeyError(wid)
del self._widgets[wid]
self._widgets.pop(wid)._plot = None # Widget.plot -> None once detached
self._push()

def list_widgets(self) -> list:
Expand All @@ -641,6 +641,8 @@ def list_widgets(self) -> list:

def clear_widgets(self) -> None:
"""Remove all interactive overlay widgets from this panel."""
for w in self._widgets.values():
w._plot = None
self._widgets.clear()
self._push()

Expand Down
133 changes: 132 additions & 1 deletion anyplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,123 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821
self._fig = fig
self._spec = spec
self._plot: "Plot1D | Plot2D | None" = None
# Labels set through Axes.set_xlabel / set_ylabel / set_title:
# {setter name: (label, fontsize)}. Kept on the axes (not only on the
# plot) so they are applied to every plot attached later -- the first
# one, and any that replaces it via a second factory call.
self._labels: dict = {}

# ------------------------------------------------------------------
# Labels (work before a plot is attached)
# ------------------------------------------------------------------
def set_xlabel(self, label: str, fontsize: float | None = None) -> None:
"""Set the x-axis label of this axes' plot.

Works before a plot is attached: the label is stored on the axes and
applied when :meth:`imshow`, :meth:`plot`, … attaches one, so layout
code can label an axes before deciding what to draw in it. With a
plot attached it also sets ``ax.primary_plot.set_xlabel(...)``.

The axes keeps the label, so a plot that *replaces* the current one
(a second ``imshow``/``plot``/… call on this axes) gets it too.
Labels set directly on the plot object (``plot.set_xlabel``) belong
to that plot only and are not carried over.

Parameters
----------
label : str
Label text (mini-TeX allowed, see :meth:`Plot2D.set_xlabel
<anyplotlib.Plot2D.set_xlabel>`).
fontsize : float, optional
Font size in CSS pixels.

Raises
------
AttributeError
If the attached plot type has no x label.
"""
self._set_label("set_xlabel", label, fontsize)

def set_ylabel(self, label: str, fontsize: float | None = None) -> None:
"""Set the y-axis label. Same deferral rules as :meth:`set_xlabel`."""
self._set_label("set_ylabel", label, fontsize)

def set_title(self, label: str, fontsize: float | None = None) -> None:
"""Set the panel title. Same deferral rules as :meth:`set_xlabel`."""
self._set_label("set_title", label, fontsize)

def _set_label(self, method: str, label: str, fontsize) -> None:
if self._plot is not None:
fn = getattr(self._plot, method, None)
if fn is None:
raise AttributeError(
f"{type(self._plot).__name__} does not support {method}()")
fn(label, fontsize=fontsize)
self._labels[method] = (label, fontsize)

def _apply_axes_labels(self, plot) -> None:
"""Apply the labels set through this axes to a newly attached *plot*."""
for method, (label, fontsize) in self._labels.items():
fn = getattr(plot, method, None)
if fn is not None: # e.g. a 3-D plot has no title setter
fn(label, fontsize=fontsize)

# ------------------------------------------------------------------
# Identity
# ------------------------------------------------------------------
@property
def figure(self) -> "Figure":
"""The :class:`~anyplotlib.Figure` this axes belongs to (read-only).

Assigning the same figure again is allowed (a no-op) so code written
for matplotlib's settable ``ax.figure`` keeps working; assigning a
different one raises ``AttributeError``.
"""
return self._fig

@figure.setter
def figure(self, fig) -> None:
if fig is not self._fig:
raise AttributeError(
"Axes.figure is read-only; an Axes cannot move to another figure")

@property
def primary_plot(self):
"""The plot attached by the last factory call, or ``None`` if empty.

This is the object :meth:`imshow`, :meth:`plot`, :meth:`pcolormesh`,
… returned (``Plot2D``, ``Plot1D``, ``PlotMesh``, ``Plot3D``,
``PlotBar``, ``PlotXY``). Overlays drawn *on* it — extra lines from
:meth:`Plot1D.add_line <anyplotlib.Plot1D.add_line>`, markers,
widgets — belong to that plot and never replace it here. Calling a
factory again replaces the plot.
"""
return self._plot

@property
def plots(self) -> tuple:
"""All plot panels attached to this axes, primary first.

An axes cell holds at most one plot panel today, so this is
``(primary_plot,)`` or ``()``; iterate it rather than indexing ``[0]``
so code keeps working if overlay panels are added later.
"""
return () if self._plot is None else (self._plot,)

@property
def image_plot(self):
"""The primary plot if it is a 2-D image (``Plot2D`` / ``PlotMesh``),
else ``None``.

Handy for code that places image-space widgets or markers and must
not mistake a line or bar panel for an image.
"""
return self._plot if isinstance(self._plot, Plot2D) else None

@property
def has_plot(self) -> bool:
"""``True`` once a plot has been attached."""
return self._plot is not None

# ------------------------------------------------------------------
def imshow(self, data,
Expand All @@ -45,7 +162,8 @@ def imshow(self, data,
integration_method: str = "mean",
overview_method: str = "mean",
tile_backend=None,
probe_exact: bool = True) -> "Plot2D":
probe_exact: bool = True,
extent=None) -> "Plot2D":
"""Attach a 2-D image to this axes cell.

Parameters
Expand Down Expand Up @@ -77,18 +195,30 @@ def imshow(self, data,
cursor dwells on a pixel (default ``True``). Without it the value
comes from the 8-bit codes the renderer holds, which quantises wide
dtypes to ``range/255``. See :meth:`~anyplotlib.Plot2D.set_value_probe`.
extent : (left, right, bottom, top), optional
Calibrate from the image's outer edges, as matplotlib's
``imshow(extent=...)`` does, instead of per-pixel ``axes``.
``top`` is the y coordinate of the top of the displayed image
whatever the ``origin``. Mutually exclusive with ``axes``. See
:meth:`~anyplotlib.Plot2D.set_extent_edges`.

Returns
-------
Plot2D
"""
if extent is not None and axes is not None:
raise ValueError("pass either axes or extent, not both")
x_axis = axes[0] if axes and len(axes) > 0 else None
y_axis = axes[1] if axes and len(axes) > 1 else None
plot = Plot2D(data, x_axis=x_axis, y_axis=y_axis, units=units,
cmap=cmap, vmin=vmin, vmax=vmax, origin=origin, gpu=gpu,
tile=tile, integration_method=integration_method,
overview_method=overview_method,
tile_backend=tile_backend, probe_exact=probe_exact)
if extent is not None:
left, right, bottom, top = (float(v) for v in extent)
# Not attached yet, so this only updates state (no push).
plot.set_extent_edges(left, right, bottom, top)
self._attach(plot)
return plot

Expand Down Expand Up @@ -538,6 +668,7 @@ def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None
plot._fig = self._fig
self._plot = plot
self._fig._register_panel(self, plot)
self._apply_axes_labels(plot)

def __repr__(self) -> str:
kind = type(self._plot).__name__ if self._plot else "empty"
Expand Down
1 change: 1 addition & 0 deletions anyplotlib/axes/_inset_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ def _attach(self, plot) -> None:
plot._fig = self._fig
self._plot = plot
self._fig._register_inset(self, plot)
self._apply_axes_labels(plot)

def __repr__(self) -> str:
kind = _plot_kind(self._plot) if self._plot else "empty"
Expand Down
42 changes: 36 additions & 6 deletions anyplotlib/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ def __repr__(self) -> str:
return "Event(" + ", ".join(parts) + ")"


def _fn_key(fn: Callable):
"""Return a stable identity key for a handler.

A bound method is a *new* object on every attribute access
(``obj.method is obj.method`` is ``False``), so ``id(fn)`` cannot identify
it: ``remove_handler(obj.method)`` would never find the handler registered
with ``add_event_handler(obj.method, ...)``. Key bound methods by their
``(instance, function)`` pair instead, which is what makes two accesses
compare equal.
"""
self_ = getattr(fn, "__self__", None)
func = getattr(fn, "__func__", None)
if self_ is not None and func is not None:
return (id(self_), id(func))
return id(fn)


class CallbackRegistry:
"""Per-object handler store.

Expand All @@ -163,8 +180,8 @@ def __init__(self) -> None:
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)
# {_fn_key(fn): set[int]} — which cids this fn owns
self._fn_map: dict = defaultdict(set)
# pause/hold state (populated in Task 3)
self._pause_counts: dict[str, int] = {}
self._hold_counts: dict[str, int] = {}
Expand All @@ -184,7 +201,7 @@ def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int:
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)
self._fn_map[_fn_key(fn)].add(cid)
return cid

def disconnect(self, cid: int) -> None:
Expand All @@ -199,7 +216,7 @@ def disconnect(self, cid: int) -> None:

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())):
for cid in list(self._fn_map.get(_fn_key(fn), set())):
cid_types = self._cid_map.get(cid, set())
if not types or cid_types & set(types):
self.disconnect(cid)
Expand Down Expand Up @@ -300,10 +317,17 @@ def handler(event): ...
@plot.add_event_handler("pointer_settled", ms=400, delta=5)
def on_settle(event): ...

Any callable works as a handler, including bound methods::

plot.add_event_handler(self.on_move, "pointer_move")
plot.remove_handler(self.on_move) # finds the same registration

Parameters
----------
fn_or_type : callable or str
Handler function (direct call) or first event type string (decorator).
Handler (direct call) or first event type string (decorator).
Plain functions, lambdas, bound methods and other callables are
all accepted.
*args : str
Remaining event type strings.
order : float
Expand Down Expand Up @@ -338,7 +362,13 @@ def _register(
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)
try:
fn._event_types = getattr(fn, "_event_types", set()) | set(types)
except AttributeError:
# Bound methods (and builtins / functools.partial with __slots__)
# refuse new attributes. The stamp is informational only, so a
# handler that cannot carry it is still registered.
pass
return fn

def remove_handler(self, cid_or_fn, *types: str) -> None:
Expand Down
Loading
Loading