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 88e38634b5b1..b8f51831e9f5 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,16 @@ 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(): + fig = self.get_figure(root=False) + if fig is not None and getattr(fig.canvas, 'supports_overlay', False): + if val: + 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: self.stale_callback(self, val) @@ -1135,6 +1146,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 + Whether this artist should be drawn in the overlay layer. + """ + if self._in_overlay != in_overlay: + self._in_overlay = in_overlay + self.pchanged() + self.stale = True + def set_in_layout(self, in_layout): """ Set if artist is to be included in layout calculations, 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: ... 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 633ce987269d..97644f49d3a4 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 + 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 is a no-op. GUI backends that support native overlay + compositing should override this method to redraw only the overlay + layer. + """ + pass + @property def device_pixel_ratio(self): """ @@ -3735,3 +3751,25 @@ class ShowBase(_Backend): def __call__(self, block=None): return self.show(block=block) + + + + +class OverlayManager: + """ + 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) + + # Canvas holds a strong reference to the manager + canvas._overlay_manager = self + + 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..633ef1c8044d 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,8 @@ class _Backend: class ShowBase(_Backend): def __call__(self, block: bool | None = ...) -> None: ... + +class OverlayManager: + def __init__(self, canvas: FigureCanvasBase) -> None: ... + + def update(self) -> None: ... diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index ad0206e0db5c..2e198c7e4216 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -158,8 +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()), + (artist for artist in artists if not artist.get_animated() + 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() @@ -3259,6 +3263,7 @@ def clear(self, keep_observers=False): if toolbar is not None: toolbar.update() + @_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..cd5b03535dc5 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -581,3 +581,174 @@ 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_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]) + assert line.get_in_overlay() is False + + # Can set via method + line.set_in_overlay(True) + assert line.get_in_overlay() is True + + # 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_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) + + # Enable overlay support on the canvas + canvas.supports_overlay = True + + + fig.draw(canvas.get_renderer()) + + # Mark as overlay + line.set_in_overlay(True) + + # 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 + with patch.object(canvas, 'draw_idle') as mock_draw_idle: + line.set_color('red') + assert line.stale is True + mock_draw_idle.assert_not_called() + + +def test_overlay_artist_included_in_save(): + from matplotlib.lines import Line2D + from unittest.mock import patch + import io + + fig, ax = plt.subplots() + canvas = fig.canvas + canvas.supports_overlay = True + + line = Line2D([0, 1], [0, 1], in_overlay=True) + 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_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]) + 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()) + + line.set_color('red') + assert ax.stale is False # animated alone blocks stale propagation + + 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