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
92 changes: 92 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
import matplotlib.artist as _artist
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 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):
"""
Expand Down Expand Up @@ -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()
10 changes: 10 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,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: ...
3 changes: 3 additions & 0 deletions lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
59 changes: 59 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,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()
Loading