Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
182 changes: 182 additions & 0 deletions anyplotlib/_base_plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
_base_plot.py
=============
Shared base classes and mixins for all plot panel types.
"""

from __future__ import annotations

from contextlib import contextmanager

from anyplotlib.callbacks import _EventMixin


class _BasePlot(_EventMixin):
"""Universal base for Plot1D, Plot2D, PlotBar, and Plot3D.

Contains methods identical across all four panel types and helper
utilities used by view-setter and widget-adder methods.

Subclasses must define:
_state : dict — the panel state dict
_push() -> None — serialize state and write to parent Figure
"""

def configure_pointer_settled(self, ms: int, delta: float = 4) -> None:
"""Configure the pointer-settled event threshold (ms and pixel delta)."""
self._state["pointer_settled_ms"] = ms
self._state["pointer_settled_delta"] = delta
self._push()

_configure_pointer_settled = configure_pointer_settled

def set_title(self, label: str) -> None:
self._state["title"] = str(label)
self._push()

def set_axis_off(self) -> None:
self._state["axis_visible"] = False
self._push()

def set_axis_on(self) -> None:
self._state["axis_visible"] = True
self._push()

@contextmanager
def _python_view_push(self):
"""Context manager for view setters that must signal _view_from_python.

Sets the flag on entry, yields for state mutations, then pushes
and clears the flag on exit.
"""
self._state["_view_from_python"] = True
try:
yield
finally:
self._push()
self._state["_view_from_python"] = False

def _make_widget_push_fn(self, widget):
"""Return a targeted-push closure for a widget.

Replaces the repeated _tp / _targeted_push closures in every
add_*_widget method.
"""
plot_ref, wid_id = self, widget._id
def _push():
if plot_ref._fig is not None:
fields = {k: v for k, v in widget._data.items()
if k not in ("id", "type")}
plot_ref._fig._push_widget(plot_ref._id, wid_id, fields)
return _push


class _PanelMixin:
"""Mixin for panels that support interactive widgets and tick control.

Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget
serialization), widget management, and tick visibility control.

Subclasses must define:
_state : dict
_fig : object
_id : str
_widgets : dict[str, Widget]
"""

def _push(self) -> None:
if self._fig is None:
return
self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
self._fig._push(self._id)

def set_ticks_visible(self, visible: bool, *, x: bool | None = None,
y: bool | None = None) -> None:
if x is None and y is None:
self._state["x_ticks_visible"] = bool(visible)
self._state["y_ticks_visible"] = bool(visible)
else:
if x is not None:
self._state["x_ticks_visible"] = bool(x)
if y is not None:
self._state["y_ticks_visible"] = bool(y)
self._push()

def get_widget(self, wid):
"""Return the Widget object by ID string or Widget instance."""
from anyplotlib.widgets import Widget
if isinstance(wid, Widget):
wid = wid.id
try:
return self._widgets[wid]
except KeyError:
raise KeyError(wid)

def remove_widget(self, wid) -> None:
"""Remove a widget by ID string or Widget instance."""
from anyplotlib.widgets import Widget
if isinstance(wid, Widget):
wid = wid.id
if wid not in self._widgets:
raise KeyError(wid)
del self._widgets[wid]
self._push()

def list_widgets(self) -> list:
"""Return a list of all active widget objects on this panel."""
return list(self._widgets.values())

def clear_widgets(self) -> None:
"""Remove all interactive overlay widgets from this panel."""
self._widgets.clear()
self._push()


class _MarkerMixin:
"""Mixin for panels that support static marker collections.

Shared by Plot1D and Plot2D.

Subclasses must define:
_state : dict
markers : MarkerRegistry
_push() -> None
"""

def _push_markers(self) -> None:
self._state["markers"] = self.markers.to_wire_list()
self._push()

def _add_marker(self, mtype: str, name, **kwargs):
return self.markers.add(mtype, name, **kwargs)

def remove_marker(self, marker_type: str, name: str) -> None:
"""Remove a named marker collection by type and name.

Parameters
----------
marker_type : str
Collection type, e.g. ``"points"``, ``"vlines"``.
name : str
The name used when the collection was created.
"""
self.markers.remove(marker_type, name)

def clear_markers(self) -> None:
"""Remove all marker collections from this panel."""
self.markers.clear()

def list_markers(self) -> list:
"""Return a summary list of all marker collections on this panel.

Returns
-------
list of dict
Each dict has keys ``"type"``, ``"name"``, and ``"n"``
(number of markers in the collection).
"""
out = []
for mtype, td in self.markers._types.items():
for name, g in td.items():
out.append({"type": mtype, "name": name, "n": g._count()})
return out
26 changes: 26 additions & 0 deletions anyplotlib/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
anyplotlib/conftest.py
======================

Package-level pytest fixtures shared by ALL test subdirectories:
- anyplotlib/tests/
- anyplotlib/sphinx_anywidget/tests/

Putting ``_pw_browser`` here (rather than in either subdirectory's conftest)
means both test trees share the same Chromium session — only one
``sync_playwright()`` context is ever active per pytest run.
"""
from __future__ import annotations

import pytest


@pytest.fixture(scope="session")
def _pw_browser():
"""Yield a headless Chromium browser for the whole test session."""
from playwright.sync_api import sync_playwright

with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
yield browser
browser.close()
Loading
Loading