From 78631b8d67d23a6de236ff309366c8134122661a Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 5 Jul 2026 21:10:08 +0530 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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() - From 89b4d0cfb3dbb188ceb20182443b135ebd7c4700 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 7 Jul 2026 15:57:19 +0530 Subject: [PATCH 05/12] TEST: Add tests for OverlayManager routing and draw skipping --- lib/matplotlib/tests/test_backend_bases.py | 57 +++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 15ecd6e9c9f8..b2fc2c93f1d2 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -618,7 +618,7 @@ def test_overlay_manager_fallback_draw(): @pytest.mark.xfail(reason="Phase 2 native compositing not yet implemented") -def test_overlay_manager_native_compositing(): +def test_overlay_manager_native_routing(): from matplotlib.lines import Line2D from unittest.mock import patch @@ -640,3 +640,58 @@ class MockNativeCanvas(FigureCanvasBase): with patch.object(canvas, 'draw_idle') as mock_draw_idle: canvas._overlay_manager.update() mock_draw_idle.assert_not_called() + + +def test_overlay_manager_draw_skipping(): + from matplotlib.lines import Line2D + from unittest.mock import patch + from matplotlib.backends.backend_agg import FigureCanvasAgg + import matplotlib.pyplot as plt + + class MockNativeCanvas(FigureCanvasAgg): + supports_overlay = True + + fig, ax = plt.subplots() + canvas = MockNativeCanvas(fig) + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + renderer = canvas.get_renderer() + + # 1. Standard draw - artist should be drawn normally + with patch.object(line, 'draw') as mock_draw: + fig.draw(renderer) + mock_draw.assert_called_once() + + # 2. Add to overlay - artist should now be skipped in standard draw + canvas._overlay_manager.add_artist(line) + with patch.object(line, 'draw') as mock_draw: + fig.draw(renderer) + mock_draw.assert_not_called() + + +@pytest.mark.xfail(reason="Native backend draw loops not yet implemented for overlays") +def test_overlay_manager_native_draw_execution(): + from matplotlib.lines import Line2D + from unittest.mock import patch + from matplotlib.backends.backend_agg import FigureCanvasAgg + import matplotlib.pyplot as plt + + class MockNativeCanvas(FigureCanvasAgg): + supports_overlay = True + + def draw_overlay(self): + # not implemented to actually draw the artists + pass + + fig, ax = plt.subplots() + canvas = MockNativeCanvas(fig) + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + canvas._overlay_manager.add_artist(line) + + # 3. Draw in overlay - artist should be drawn + with patch.object(line, 'draw') as mock_draw: + canvas.draw_overlay() + mock_draw.assert_called_once() From 6f66ce4ce4948b753986b3958f353b55e3280b6c Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sun, 12 Jul 2026 21:52:49 +0530 Subject: [PATCH 06/12] Introduce in_overlay artist property and basic draw filtering --- lib/matplotlib/artist.py | 27 ++++++ lib/matplotlib/axes/_base.py | 4 +- lib/matplotlib/backend_bases.py | 66 ++----------- lib/matplotlib/backend_bases.pyi | 4 +- lib/matplotlib/figure.py | 5 +- lib/matplotlib/tests/test_backend_bases.py | 108 ++++++--------------- 6 files changed, 71 insertions(+), 143 deletions(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 88e38634b5b1..41f5e54a3dc2 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -200,6 +200,7 @@ def __init__(self): self._transformSet = False self._visible = True self._animated = False + self._in_overlay = False self._alpha = None self.clipbox = None self._clippath = None @@ -331,6 +332,11 @@ def stale(self, val): if self._animated: return + # overlay artists also opt out — overlay redraws are handled + # by OverlayManager / draw_overlay(), not by the main draw loop + if self.get_in_overlay(): + return + if val and self.stale_callback is not None: self.stale_callback(self, val) @@ -1135,6 +1141,27 @@ def set_animated(self, b): self._animated = b self.pchanged() + def get_in_overlay(self): + """ + Return whether the artist is intended to be drawn in the overlay layer. + """ + return self._in_overlay + + def set_in_overlay(self, in_overlay): + """ + Set whether the artist is intended to be drawn in the overlay layer. + + If True, the artist is excluded from regular drawing of the figure. + You have to call `canvas.draw_overlay()` to redraw the overlay layer. + + Parameters + ---------- + in_overlay : bool + """ + if self._in_overlay != in_overlay: + self._in_overlay = in_overlay + self.pchanged() + def set_in_layout(self, in_layout): """ Set if artist is to be included in layout calculations, diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1a175b084316..4b096ba61ed0 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -3330,7 +3330,9 @@ def draw(self, renderer): if not self.get_figure(root=True).canvas.is_saving(): artists = [ a for a in artists - if not a.get_animated()] + if not a.get_animated() + and not (a.get_in_overlay() + and self.get_figure(root=True).canvas.supports_overlay)] artists = sorted(artists, key=attrgetter('zorder')) # rasterize artists with negative zorder diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2ba410deff94..f6fb896437b0 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -46,7 +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) @@ -3757,73 +3757,19 @@ def __call__(self, block=None): 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. + Manages the overlay redraw trigger for high-frequency interactive overlays. + + Artists declare themselves as overlay members via set_in_overlay(True). + This manager's sole job is to trigger the canvas to redraw the overlay + layer on demand via update(). """ 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 aced70eebe21..633ef1c8044d 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -531,7 +531,5 @@ 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 3045a912dbe0..71e1ee2e0ded 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -159,7 +159,8 @@ def _get_draw_artists(self, renderer): artists.remove(self.patch) artists = sorted( - (artist for artist in artists if not artist.get_animated()), + (artist for artist in artists if not artist.get_animated() + and not (artist.get_in_overlay() and self.figure.canvas.supports_overlay)), key=lambda artist: artist.get_zorder()) for ax in self._localaxes: locator = ax.get_axes_locator() @@ -3259,8 +3260,6 @@ 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 diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index b2fc2c93f1d2..6f86c4a001dc 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -583,115 +583,71 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): assert ax_b.get_xlim() == pytest.approx(ax_b_twin.get_xlim(), abs=0.15) -def test_overlay_manager_registration(): +def test_overlay_setup_and_api(): from matplotlib.lines import Line2D + from unittest.mock import patch + fig, ax = plt.subplots() canvas = fig.canvas assert hasattr(canvas, "_overlay_manager") line = Line2D([0, 1], [0, 1]) - ax.add_line(line) + assert line.get_in_overlay() is False - canvas._overlay_manager.add_artist(line) + # Can set via method + line.set_in_overlay(True) + assert line.get_in_overlay() is True - live_artists = canvas._overlay_manager._get_live_artists() - assert line not in live_artists - - assert line.get_animated() is False + # Manager.update() calls canvas.draw_overlay() + with patch.object(canvas, 'draw_overlay') as mock_draw_overlay: + canvas._overlay_manager.update() + mock_draw_overlay.assert_called_once() -def test_overlay_manager_fallback_draw(): +def test_overlay_draw_filtering_and_stale(): 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) + # Enable overlay support on the canvas + canvas.supports_overlay = True - # Mock draw_idle - with patch.object(canvas, 'draw_idle') as mock_draw_idle: - canvas._overlay_manager.update() - mock_draw_idle.assert_called_once() + fig.draw(canvas.get_renderer()) -@pytest.mark.xfail(reason="Phase 2 native compositing not yet implemented") -def test_overlay_manager_native_routing(): - from matplotlib.lines import Line2D - from unittest.mock import patch + # Mark as overlay + line.set_in_overlay(True) - 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 + # 1. Overlay artist is skipped in standard draw + with patch.object(line, 'draw') as mock_draw: + fig.draw(canvas.get_renderer()) + mock_draw.assert_not_called() - # Mock draw_idle + # 2. Stale flag does not propagate to canvas with patch.object(canvas, 'draw_idle') as mock_draw_idle: - canvas._overlay_manager.update() + line.set_color('red') + assert line.stale is True mock_draw_idle.assert_not_called() -def test_overlay_manager_draw_skipping(): +def test_overlay_artist_included_in_save(): from matplotlib.lines import Line2D from unittest.mock import patch - from matplotlib.backends.backend_agg import FigureCanvasAgg - import matplotlib.pyplot as plt - - class MockNativeCanvas(FigureCanvasAgg): - supports_overlay = True + import io fig, ax = plt.subplots() - canvas = MockNativeCanvas(fig) - line = Line2D([0, 1], [0, 1]) - ax.add_line(line) - - renderer = canvas.get_renderer() - - # 1. Standard draw - artist should be drawn normally - with patch.object(line, 'draw') as mock_draw: - fig.draw(renderer) - mock_draw.assert_called_once() - - # 2. Add to overlay - artist should now be skipped in standard draw - canvas._overlay_manager.add_artist(line) - with patch.object(line, 'draw') as mock_draw: - fig.draw(renderer) - mock_draw.assert_not_called() - - -@pytest.mark.xfail(reason="Native backend draw loops not yet implemented for overlays") -def test_overlay_manager_native_draw_execution(): - from matplotlib.lines import Line2D - from unittest.mock import patch - from matplotlib.backends.backend_agg import FigureCanvasAgg - import matplotlib.pyplot as plt - - class MockNativeCanvas(FigureCanvasAgg): - supports_overlay = True - - def draw_overlay(self): - # not implemented to actually draw the artists - pass + canvas = fig.canvas + canvas.supports_overlay = True - fig, ax = plt.subplots() - canvas = MockNativeCanvas(fig) - line = Line2D([0, 1], [0, 1]) + line = Line2D([0, 1], [0, 1], in_overlay=True) ax.add_line(line) - canvas._overlay_manager.add_artist(line) - - # 3. Draw in overlay - artist should be drawn + # savefig should draw it with patch.object(line, 'draw') as mock_draw: - canvas.draw_overlay() + fig.savefig(io.BytesIO()) mock_draw.assert_called_once() From edb94fcd9bfdec337b98b0a1e0076d22a1feb846 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 13 Jul 2026 05:38:28 +0530 Subject: [PATCH 07/12] Add in_overlay typing stubs to artist.pyi --- lib/matplotlib/artist.pyi | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/artist.pyi b/lib/matplotlib/artist.pyi index 803b17dc36c6..d0e2b121b0a8 100644 --- a/lib/matplotlib/artist.pyi +++ b/lib/matplotlib/artist.pyi @@ -100,6 +100,7 @@ class Artist: def get_alpha(self) -> float | None: ... def get_visible(self) -> bool: ... def get_animated(self) -> bool: ... + def get_in_overlay(self) -> bool: ... def get_in_layout(self) -> bool: ... def get_clip_on(self) -> bool: ... def get_clip_box(self) -> Bbox | None: ... @@ -120,6 +121,7 @@ class Artist: def set_alpha(self, alpha: float | None) -> None: ... def set_visible(self, b: bool) -> None: ... def set_animated(self, b: bool) -> None: ... + def set_in_overlay(self, in_overlay: bool) -> None: ... def set_in_layout(self, in_layout: bool) -> None: ... def get_label(self) -> object: ... def set_label(self, s: object) -> None: ... From 157fe64f8b9c75df1ba514d346cccb9e86f4559b Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 13 Jul 2026 06:09:08 +0530 Subject: [PATCH 08/12] Fix docstring formatting for in_overlay --- lib/matplotlib/artist.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 41f5e54a3dc2..76ef6bba45cf 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -1144,6 +1144,10 @@ def set_animated(self, b): def get_in_overlay(self): """ Return whether the artist is intended to be drawn in the overlay layer. + + Returns + ------- + bool """ return self._in_overlay @@ -1157,6 +1161,7 @@ def set_in_overlay(self, in_overlay): Parameters ---------- in_overlay : bool + Whether this artist should be drawn in the overlay layer. """ if self._in_overlay != in_overlay: self._in_overlay = in_overlay From f7b2bad8b6d5002dda6761dea20b7fdf663b16a8 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 13 Jul 2026 06:41:46 +0530 Subject: [PATCH 09/12] DOC: Document in_overlay properties in Artist API to fix Sphinx build --- doc/api/artist_api.rst | 2 ++ lib/matplotlib/artist.py | 8 +------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/doc/api/artist_api.rst b/doc/api/artist_api.rst index f256d2b7164e..38270b1bcc9a 100644 --- a/doc/api/artist_api.rst +++ b/doc/api/artist_api.rst @@ -84,6 +84,8 @@ Drawing Artist.draw Artist.set_animated Artist.get_animated + Artist.set_in_overlay + Artist.get_in_overlay Artist.set_alpha Artist.get_alpha diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 76ef6bba45cf..02b48d933d2a 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -1142,13 +1142,7 @@ def set_animated(self, b): self.pchanged() def get_in_overlay(self): - """ - Return whether the artist is intended to be drawn in the overlay layer. - - Returns - ------- - bool - """ + """Return whether the artist is intended to be drawn in the overlay layer.""" return self._in_overlay def set_in_overlay(self, in_overlay): From 8456aa22e27658b0dae5c9b26a3328cd0cfcdfde Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 14 Jul 2026 13:45:57 +0530 Subject: [PATCH 10/12] Fix overlay staleness bug, fix savefig for figure artists --- lib/matplotlib/artist.py | 8 +++- lib/matplotlib/figure.py | 5 ++- lib/matplotlib/tests/test_backend_bases.py | 43 +++++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 02b48d933d2a..426b8fbe0872 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -335,7 +335,10 @@ def stale(self, val): # overlay artists also opt out — overlay redraws are handled # by OverlayManager / draw_overlay(), not by the main draw loop if self.get_in_overlay(): - return + fig = self.get_figure(root=False) + if fig is not None and getattr(fig.canvas, 'supports_overlay', False): + return # overlay path will redraw; skip propagation + # else: backend can't honor overlay, fall through — let normal stale work if val and self.stale_callback is not None: self.stale_callback(self, val) @@ -1150,7 +1153,7 @@ def set_in_overlay(self, in_overlay): Set whether the artist is intended to be drawn in the overlay layer. If True, the artist is excluded from regular drawing of the figure. - You have to call `canvas.draw_overlay()` to redraw the overlay layer. + You have to call ``canvas.draw_overlay()`` to redraw the overlay layer. Parameters ---------- @@ -1160,6 +1163,7 @@ def set_in_overlay(self, in_overlay): if self._in_overlay != in_overlay: self._in_overlay = in_overlay self.pchanged() + self.stale = True def set_in_layout(self, in_layout): """ diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 71e1ee2e0ded..2e198c7e4216 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -158,9 +158,12 @@ def _get_draw_artists(self, renderer): artists = self.get_children() artists.remove(self.patch) + is_saving = self.figure.canvas.is_saving() artists = sorted( (artist for artist in artists if not artist.get_animated() - and not (artist.get_in_overlay() and self.figure.canvas.supports_overlay)), + and not (not is_saving + and artist.get_in_overlay() + and self.figure.canvas.supports_overlay)), key=lambda artist: artist.get_zorder()) for ax in self._localaxes: locator = ax.get_axes_locator() diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 6f86c4a001dc..15b38200df06 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -645,9 +645,50 @@ def test_overlay_artist_included_in_save(): canvas.supports_overlay = True line = Line2D([0, 1], [0, 1], in_overlay=True) - ax.add_line(line) + fig.add_artist(line) # savefig should draw it with patch.object(line, 'draw') as mock_draw: fig.savefig(io.BytesIO()) mock_draw.assert_called_once() + + +def test_overlay_fallback_stale_propagates(): + """When backend doesn't support overlay, in_overlay=True falls back to + normal draw AND stale propagates normally. + """ + from matplotlib.lines import Line2D + fig, ax = plt.subplots() + line = Line2D([0, 1], [0, 1]) + ax.add_line(line) + + # Default: supports_overlay = False (no overlay support) + # So in_overlay=True falls back to normal behavior + line.set_in_overlay(True) + + fig.draw(fig.canvas.get_renderer()) # clears fig.stale, ax.stale, line.stale + + line.set_color('red') + # When supports_overlay=False + # the stale setter — stale propagates: line -> axes -> figure + assert line.stale is True + assert ax.stale is True + assert fig.stale is True + + +def test_overlay_and_animated_together(): + """animated=True takes precedence — stale never propagates, blit draws""" + from matplotlib.lines import Line2D + from unittest.mock import patch + fig, ax = plt.subplots() + canvas = fig.canvas + canvas.supports_overlay = True + + line = Line2D([0, 1], [0, 1], animated=True, in_overlay=True) + ax.add_line(line) + + fig.draw(fig.canvas.get_renderer()) # clear stale + + with patch.object(fig.canvas, 'draw_idle') as mock_draw_idle: + line.set_color('red') + mock_draw_idle.assert_not_called() # animated blocks first From 5bff8b04672fb4bd1b29649162170aec3dbe8c63 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 14 Jul 2026 20:58:51 +0530 Subject: [PATCH 11/12] Prove complete independence of animated and overlay code paths --- lib/matplotlib/artist.py | 3 +- lib/matplotlib/backend_bases.py | 8 +-- lib/matplotlib/tests/test_backend_bases.py | 73 +++++++++++++++++++--- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 426b8fbe0872..d46b8ac8b4d4 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -337,7 +337,8 @@ def stale(self, val): if self.get_in_overlay(): fig = self.get_figure(root=False) if fig is not None and getattr(fig.canvas, 'supports_overlay', False): - return # overlay path will redraw; skip propagation + fig.canvas.draw_overlay() + return # overlay path redraws; skip propagation # else: backend can't honor overlay, fall through — let normal stale work if val and self.stale_callback is not None: diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index f6fb896437b0..97644f49d3a4 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1998,11 +1998,11 @@ 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. + By default, this is a no-op. GUI backends that support native overlay + compositing should override this method to redraw only the overlay + layer. """ - self.draw_idle() + pass @property def device_pixel_ratio(self): diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 15b38200df06..dcadc7d0e304 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -676,19 +676,78 @@ def test_overlay_fallback_stale_propagates(): assert fig.stale is True -def test_overlay_and_animated_together(): - """animated=True takes precedence — stale never propagates, blit draws""" + + +def test_animated_and_overlay_independence(): + """ + Proves animated=True and in_overlay=True are completely independent code + paths by isolating each flag and verifying it works on its own. + """ from matplotlib.lines import Line2D from unittest.mock import patch + fig, ax = plt.subplots() canvas = fig.canvas canvas.supports_overlay = True - line = Line2D([0, 1], [0, 1], animated=True, in_overlay=True) + line = Line2D([0, 1], [0, 1]) ax.add_line(line) + fig.draw(canvas.get_renderer()) + + # --- Phase A: Only animated --- overlay is off, animated blocks stale alone + line.set_animated(True) + line.set_in_overlay(False) + fig.draw(canvas.get_renderer()) - fig.draw(fig.canvas.get_renderer()) # clear stale + line.set_color('red') + assert ax.stale is False # animated alone blocks stale propagation - with patch.object(fig.canvas, 'draw_idle') as mock_draw_idle: - line.set_color('red') - mock_draw_idle.assert_not_called() # animated blocks first + with patch.object(line, 'draw') as mock: + fig.draw(canvas.get_renderer()) + mock.assert_not_called() # animated alone excludes from draw + + # --- Phase B: Only overlay --- animated is off, overlay blocks stale alone + line.set_animated(False) + line.set_in_overlay(True) + fig.draw(canvas.get_renderer()) + + with patch.object(canvas, 'draw_overlay') as mock_overlay: + line.set_color('blue') + mock_overlay.assert_called() # overlay path IS active + assert ax.stale is False # overlay blocked stale propagation + + with patch.object(line, 'draw') as mock: + fig.draw(canvas.get_renderer()) + mock.assert_not_called() # overlay alone excludes from draw + + # --- Phase C: Both flags on --- animated catches BEFORE overlay + line.set_animated(True) + line.set_in_overlay(True) + fig.draw(canvas.get_renderer()) + + with patch.object(canvas, 'draw_overlay') as mock_overlay: + line.set_color('green') + mock_overlay.assert_not_called() # animated returned first, overlay unreached + assert ax.stale is False + + with patch.object(line, 'draw') as mock: + fig.draw(canvas.get_renderer()) + mock.assert_not_called() # excluded from normal draw + + # But blitting (the animation path) still works — it bypasses all filters + with patch.object(line, 'draw') as mock: + ax.draw_artist(line) # this is what FuncAnimation(blit=True) calls + mock.assert_called_once() # animation successfully draws the artist + + # --- Phase D: Both flags off --- normal behavior fully restored + line.set_animated(False) + line.set_in_overlay(False) + fig.draw(canvas.get_renderer()) + + line.set_color('orange') + assert ax.stale is True # stale propagates (nothing blocks it) + assert fig.stale is True + + with patch.object(line, 'draw') as mock: + fig.draw(canvas.get_renderer()) + mock.assert_called() # included in normal draw From eade9b2db1c4e9c43940eaff09fd4c86ed42779d Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 14 Jul 2026 22:05:09 +0530 Subject: [PATCH 12/12] change test case --- lib/matplotlib/artist.py | 3 ++- lib/matplotlib/tests/test_backend_bases.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index d46b8ac8b4d4..b8f51831e9f5 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -337,7 +337,8 @@ def stale(self, val): if self.get_in_overlay(): fig = self.get_figure(root=False) if fig is not None and getattr(fig.canvas, 'supports_overlay', False): - fig.canvas.draw_overlay() + if val: + fig.canvas.draw_overlay() return # overlay path redraws; skip propagation # else: backend can't honor overlay, fall through — let normal stale work diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index dcadc7d0e304..cd5b03535dc5 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -626,6 +626,7 @@ def test_overlay_draw_filtering_and_stale(): # 1. Overlay artist is skipped in standard draw with patch.object(line, 'draw') as mock_draw: fig.draw(canvas.get_renderer()) + assert line.stale is False mock_draw.assert_not_called() # 2. Stale flag does not propagate to canvas