diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 633ce987269d..2ba410deff94 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -46,6 +46,7 @@ import numpy as np import matplotlib as mpl +import matplotlib.artist as _artist from matplotlib import ( _api, backend_tools as tools, cbook, colors, _docstring, text, _tight_bbox, transforms, widgets, is_interactive, rcParams) @@ -1758,6 +1759,8 @@ def supports_blit(cls): return (hasattr(cls, "copy_from_bbox") and hasattr(cls, "restore_region")) + supports_overlay = False + def __init__(self, figure=None): from matplotlib.figure import Figure self._fix_ipython_backend2gui() @@ -1774,6 +1777,9 @@ def __init__(self, figure=None): self.mouse_grabber = None # the Axes currently grabbing mouse self.toolbar = None # NavigationToolbar2 will set me self._is_idle_drawing = False + # Initialize the overlay manager for this canvas + OverlayManager(self) + # We don't want to scale up the figure DPI more than once. figure._original_dpi = getattr(figure, '_original_dpi', figure.dpi) self._device_pixel_ratio = 1 @@ -1988,6 +1994,16 @@ def draw_idle(self, *args, **kwargs): with self._idle_draw_cntx(): self.draw(*args, **kwargs) + def draw_overlay(self): + """ + Draw the overlay layer without triggering a full figure redraw. + + By default, this safely falls back to `draw_idle()`. GUI backends that + support native overlay compositing should + override this method for native speed. + """ + self.draw_idle() + @property def device_pixel_ratio(self): """ @@ -3735,3 +3751,79 @@ class ShowBase(_Backend): def __call__(self, block=None): return self.show(block=block) + + + + +class OverlayManager: + """ + Opt-in manager for high-frequency interactive overlays. + Maintains a dedicated registry for overlay artists to isolate them + from the main draw tree for faster rendering in supported backends. + """ + def __init__(self, canvas): + # Manager holds a weak reference to the canvas + self._canvas = weakref.proxy(canvas) + + self._artists = [] + + # Canvas holds a strong reference to the manager + canvas._overlay_manager = self + + def _get_live_artists(self) -> list['_artist.Artist']: + """ + Returns a clean list of live artists sorted by z-order. + """ + live_refs = [] + live_artists = [] + for ref in self._artists: + art = ref() + # Strict verification: art must exist in memory AND + # be attached to a layout tree + if art is not None and ( + getattr(art, 'axes', None) is not None + or getattr(art, 'figure', None) is not None + ): + live_refs.append(ref) + live_artists.append(art) + + # update the internal registry to only keep valid references + self._artists = live_refs + + # Sort by z-order for Phase 2 rendering + return sorted(live_artists, key=lambda a: a.get_zorder()) + + def add_artist(self, artist: '_artist.Artist'): + """Add a standard Artist to the overlay layer.""" + if ( + getattr(artist, 'axes', None) is None + and getattr(artist, 'figure', None) is None + ): + raise ValueError( + "Artist must be added to an Axes or Figure before adding " + "to OverlayManager." + ) + + if getattr(self._canvas, 'supports_overlay', False): + artist.set_animated(True) + # Store as a weak reference + self._artists.append(weakref.ref(artist)) + else: + # fallback is to draw_idle() + artist.set_animated(False) + + def remove_artist(self, artist: '_artist.Artist'): + """Remove a standard Artist from the overlay layer.""" + # Cleanly rebuild the list, dropping dead references and the specified artist + self._artists = [ + ref for ref in self._artists + if ref() is not None and ref() is not artist + ] + + def clear(self): + """Wipe all overlay artists.""" + self._artists.clear() + + def update(self): + """Trigger the canvas to redraw the overlay layer.""" + self._canvas.draw_overlay() diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index fe492f0dde66..aced70eebe21 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -315,6 +315,9 @@ class FigureCanvasBase: @_api.classproperty def supports_blit(cls) -> bool: ... + supports_overlay: bool + def draw_overlay(self) -> None: ... + figure: Figure manager: None | FigureManagerBase widgetlock: widgets.LockDraw @@ -525,3 +528,10 @@ class _Backend: class ShowBase(_Backend): def __call__(self, block: bool | None = ...) -> None: ... + +class OverlayManager: + def __init__(self, canvas: FigureCanvasBase) -> None: ... + def add_artist(self, artist: Artist) -> None: ... + def remove_artist(self, artist: Artist) -> None: ... + def clear(self) -> None: ... + def update(self) -> None: ... diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index ad0206e0db5c..3045a912dbe0 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3259,6 +3259,9 @@ def clear(self, keep_observers=False): if toolbar is not None: toolbar.update() + if hasattr(self.canvas, '_overlay_manager'): + self.canvas._overlay_manager.clear() + @_finalize_rasterization @allow_rasterization def draw(self, renderer): diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0205eac42fb3..15ecd6e9c9f8 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -581,3 +581,62 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): # Check if twin-axes are properly triggered assert ax_t.get_xlim() == pytest.approx(ax_t_twin.get_xlim(), abs=0.15) assert ax_b.get_xlim() == pytest.approx(ax_b_twin.get_xlim(), abs=0.15) + + +def test_overlay_manager_registration(): + from matplotlib.lines import Line2D + fig, ax = plt.subplots() + canvas = fig.canvas + + assert hasattr(canvas, "_overlay_manager") + + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + canvas._overlay_manager.add_artist(line) + + live_artists = canvas._overlay_manager._get_live_artists() + assert line not in live_artists + + assert line.get_animated() is False + + +def test_overlay_manager_fallback_draw(): + from matplotlib.lines import Line2D + from unittest.mock import patch + fig, ax = plt.subplots() + canvas = fig.canvas + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + canvas._overlay_manager.add_artist(line) + + # Mock draw_idle + with patch.object(canvas, 'draw_idle') as mock_draw_idle: + canvas._overlay_manager.update() + mock_draw_idle.assert_called_once() + + +@pytest.mark.xfail(reason="Phase 2 native compositing not yet implemented") +def test_overlay_manager_native_compositing(): + from matplotlib.lines import Line2D + from unittest.mock import patch + + class MockNativeCanvas(FigureCanvasBase): + supports_overlay = True + + fig = Figure() + canvas = MockNativeCanvas(fig) + ax = fig.subplots() + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + canvas._overlay_manager.add_artist(line) + + # Because supports_overlay is True, the artist should be set to animated + assert line.get_animated() is True + + # Mock draw_idle + with patch.object(canvas, 'draw_idle') as mock_draw_idle: + canvas._overlay_manager.update() + mock_draw_idle.assert_not_called()