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
2 changes: 2 additions & 0 deletions doc/api/artist_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
Artist.draw
Artist.set_animated
Artist.get_animated
Artist.set_in_overlay
Artist.get_in_overlay

Artist.set_alpha
Artist.get_alpha
Expand Down Expand Up @@ -200,4 +202,4 @@
getp
setp
kwdoc
ArtistInspector

Check warning on line 205 in doc/api/artist_api.rst

View workflow job for this annotation

GitHub Actions / sphinx

[sphinx] doc/api/artist_api.rst#L205

py:class reference target not found: matplotlib.axes._base._AxesBase [ref.class]
Raw output
doc/api/artist_api.rst:205: WARNING: py:class reference target not found: matplotlib.axes._base._AxesBase [ref.class]

Check warning on line 205 in doc/api/artist_api.rst

View workflow job for this annotation

GitHub Actions / sphinx

[sphinx] doc/api/artist_api.rst#L205

py:class reference target not found: matplotlib.image._ImageBase [ref.class]
Raw output
doc/api/artist_api.rst:205: WARNING: py:class reference target not found: matplotlib.image._ImageBase [ref.class]

Check warning on line 205 in doc/api/artist_api.rst

View workflow job for this annotation

GitHub Actions / sphinx

[sphinx] doc/api/artist_api.rst#L205

py:class reference target not found: matplotlib.text._AnnotationBase [ref.class]
Raw output
doc/api/artist_api.rst:205: WARNING: py:class reference target not found: matplotlib.text._AnnotationBase [ref.class]
32 changes: 32 additions & 0 deletions lib/matplotlib/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/artist.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand All @@ -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: ...
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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()
8 changes: 8 additions & 0 deletions lib/matplotlib/backend_bases.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ...
7 changes: 6 additions & 1 deletion lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
171 changes: 171 additions & 0 deletions lib/matplotlib/tests/test_backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading