From 78631b8d67d23a6de236ff309366c8134122661a Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 5 Jul 2026 21:10:08 +0530 Subject: [PATCH 1/4] ENH: Introduce OverlayManager for Phase 1 fallback rendering --- lib/matplotlib/backend_bases.py | 80 ++++++++++++++++++++++ lib/matplotlib/figure.py | 3 + lib/matplotlib/tests/test_backend_bases.py | 58 ++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 633ce987269d..c81e398aff78 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1758,6 +1758,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 +1776,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 +1993,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 +3750,68 @@ class ShowBase(_Backend): def __call__(self, block=None): return self.show(block=block) + + +from typing import List +import matplotlib.artist as _artist + +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/figure.py b/lib/matplotlib/figure.py index ad0206e0db5c..fbdaebbed0c9 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3258,6 +3258,9 @@ def clear(self, keep_observers=False): toolbar = self.canvas.toolbar if toolbar is not None: toolbar.update() + + if hasattr(self.canvas, '_overlay_manager'): + self.canvas._overlay_manager.clear() @_finalize_rasterization @allow_rasterization diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0205eac42fb3..09624540a0fd 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -581,3 +581,61 @@ 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() + From 78b9de22dd7755b572ff2fb0fc6444a8598ef728 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 5 Jul 2026 22:19:36 +0530 Subject: [PATCH 2/4] FIX: Resolve ruff linting errors and add missing pyi stubs --- lib/matplotlib/backend_bases.py | 5 ++++- lib/matplotlib/backend_bases.pyi | 6 ++++++ lib/matplotlib/tests/test_backend_bases.py | 22 +++++++++++----------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index c81e398aff78..1b73e24a3013 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3779,7 +3779,10 @@ def _get_live_artists(self) -> List[_artist.Artist]: 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): + 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) diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index fe492f0dde66..ef09cb6d5001 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -314,6 +314,9 @@ class FigureCanvasBase: @_api.classproperty def supports_blit(cls) -> bool: ... + + supports_overlay: bool + def draw_overlay(self) -> None: ... figure: Figure manager: None | FigureManagerBase @@ -525,3 +528,6 @@ class _Backend: class ShowBase(_Backend): def __call__(self, block: bool | None = ...) -> None: ... + +class OverlayManager: + def __init__(self, canvas: FigureCanvasBase) -> None: ... diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 09624540a0fd..db1c8efe245c 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -587,17 +587,17 @@ 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(): @@ -607,9 +607,9 @@ def test_overlay_manager_fallback_draw(): 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() @@ -619,7 +619,7 @@ def test_overlay_manager_fallback_draw(): def test_overlay_manager_native_compositing(): from matplotlib.lines import Line2D from unittest.mock import patch - + class MockNativeCanvas(FigureCanvasBase): supports_overlay = True @@ -628,12 +628,12 @@ class MockNativeCanvas(FigureCanvasBase): 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() From bdd54f56500e23bcd6cc9f26beac238b1006256d Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 5 Jul 2026 22:28:39 +0530 Subject: [PATCH 3/4] style: fix remaining ruff spacing and missing pyi functions --- lib/matplotlib/backend_bases.pyi | 6 +++++- lib/matplotlib/figure.py | 2 +- lib/matplotlib/tests/test_backend_bases.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index ef09cb6d5001..aced70eebe21 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -314,7 +314,7 @@ class FigureCanvasBase: @_api.classproperty def supports_blit(cls) -> bool: ... - + supports_overlay: bool def draw_overlay(self) -> None: ... @@ -531,3 +531,7 @@ class ShowBase(_Backend): 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 fbdaebbed0c9..3045a912dbe0 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3258,7 +3258,7 @@ def clear(self, keep_observers=False): toolbar = self.canvas.toolbar if toolbar is not None: toolbar.update() - + if hasattr(self.canvas, '_overlay_manager'): self.canvas._overlay_manager.clear() diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index db1c8efe245c..e8c9ae4b355c 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -600,6 +600,7 @@ def test_overlay_manager_registration(): assert line.get_animated() is False + def test_overlay_manager_fallback_draw(): from matplotlib.lines import Line2D from unittest.mock import patch @@ -615,6 +616,7 @@ def test_overlay_manager_fallback_draw(): 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 9e940207f59fa56830f65378476bddabc16691c9 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 5 Jul 2026 22:54:32 +0530 Subject: [PATCH 4/4] style: fix remaining ruff line length errors and imports --- lib/matplotlib/backend_bases.py | 43 +++++++++++++--------- lib/matplotlib/tests/test_backend_bases.py | 1 - 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 1b73e24a3013..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) @@ -1778,7 +1779,7 @@ def __init__(self, figure=None): 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 @@ -3752,8 +3753,7 @@ def __call__(self, block=None): return self.show(block=block) -from typing import List -import matplotlib.artist as _artist + class OverlayManager: """ @@ -3764,13 +3764,13 @@ class OverlayManager: 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]: + def _get_live_artists(self) -> list['_artist.Artist']: """ Returns a clean list of live artists sorted by z-order. """ @@ -3778,37 +3778,47 @@ def _get_live_artists(self) -> List[_artist.Artist]: live_artists = [] for ref in self._artists: art = ref() - # Strict verification: art must exist in memory AND be attached to a layout tree + # 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): + 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(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) + artist.set_animated(False) - def remove_artist(self, artist: _artist.Artist): + 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] + 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.""" @@ -3817,4 +3827,3 @@ def clear(self): def update(self): """Trigger the canvas to redraw the overlay layer.""" self._canvas.draw_overlay() - diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index e8c9ae4b355c..15ecd6e9c9f8 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -640,4 +640,3 @@ class MockNativeCanvas(FigureCanvasBase): with patch.object(canvas, 'draw_idle') as mock_draw_idle: canvas._overlay_manager.update() mock_draw_idle.assert_not_called() -