From 3edc6c7ef69f005351589781f6a7b1db0aede2c8 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Sun, 21 Sep 2025 01:41:36 +0200 Subject: [PATCH 1/2] FIX: Make widget blitting compatible with swapped canvas --- lib/matplotlib/backend_bases.py | 50 ++++++++++++++++++++++++++++ lib/matplotlib/widgets.py | 58 +++++++++++++++++++++++---------- lib/matplotlib/widgets.pyi | 1 + 3 files changed, 92 insertions(+), 17 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index fc7d651a6eb4..3c4495766913 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1740,6 +1740,10 @@ class FigureCanvasBase: filetypes = _default_filetypes + # global counter to assign unique ids to blit backgrounds + # see _get_blit_background_id() + _last_blit_background_id = 0 + @_api.classproperty def supports_blit(cls): """If this Canvas sub-class supports blitting.""" @@ -1765,6 +1769,7 @@ def __init__(self, figure=None): # We don't want to scale up the figure DPI more than once. figure._original_dpi = figure.dpi self._device_pixel_ratio = 1 + self._blit_backgrounds = {} super().__init__() # Typically the GUI widget init (if any). callbacks = property(lambda self: self.figure._canvas_callbacks) @@ -1840,6 +1845,51 @@ def is_saving(self): def blit(self, bbox=None): """Blit the canvas in bbox (default entire canvas).""" + @classmethod + def _get_blit_background_id(cls): + """ + Get a globally unique id that can be used to store a blit background. + + Blitting support is canvas-dependent, so blitting mechanisms should + store their backgrounds in the canvas, more precisely in + ``canvas._blit_backgrounds[id]``. The id must be obtained via this + function to ensure it is globally unique. + + The content of ``canvas._blit_backgrounds[id]`` is not specified. + We leave this freedom to the blitting mechanism. + + Blitting mechanisms must not expect that a background that they + have stored is still there at a later time. The canvas may have + been switched out, or we may add other mechanisms later that + invalidate blit backgrounds (e.g. dpi changes). + Therefore, always query as `_blit_backgrounds.get(id)` and be + prepared for a None return value. + + Note: The blit background API is still experimental and may change + in the future without warning. + """ + cls._last_blit_background_id += 1 + return cls._last_blit_background_id + + def _release_blit_background_id(self, bb_id): + """ + Release a blit background id that is no longer needed. + + This removes the respective entry from the internal storage, i.e. + the ``canvas._blit_backgrounds`` dict, and thus allows to free the + associated memory. + + After releasing the id you must not use it anymore. + + It is safe to release an id that has not been used with the canvas + or that has already been released. + + Note: The blit background API is still experimental and may change + in the future without warning. + """ + if bb_id in self._blit_backgrounds: + del self._blit_backgrounds[bb_id] + def inaxes(self, xy): """ Return the topmost visible `~.axes.Axes` containing the point *xy*. diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 0410c4f03092..e5a60c1585ff 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -117,6 +117,11 @@ class AxesWidget(Widget): def __init__(self, ax): self.ax = ax self._cids = [] + self._blit_background_id = None + + def __del__(self): + if self._blit_background_id is not None: + self.canvas._release_blit_background_id(self._blit_background_id) canvas = property( lambda self: getattr(self.ax.get_figure(root=True), 'canvas', None) @@ -155,6 +160,26 @@ def _set_cursor(self, cursor): """Update the canvas cursor.""" self.ax.get_figure(root=True).canvas.set_cursor(cursor) + def _save_blit_background(self, background): + """ + Save a blit background. + + The background is stored on the canvas in a uniquely identifiable way. + It should be read back via `._load_blit_background`. Be prepared that + some events may invalidate the background, in which case + `._load_blit_background` will return None. + + This currently allows at most one background per widget, which is + good enough for all existing widgets. + """ + if self._blit_background_id is None: + self._blit_background_id = self.canvas._get_blit_background_id() + self.canvas._blit_backgrounds[self._blit_background_id] = background + + def _load_blit_background(self): + """Load a blit background; may be None at any time.""" + return self.canvas._blit_backgrounds.get(self._blit_background_id) + class Button(AxesWidget): """ @@ -1063,7 +1088,6 @@ def __init__(self, ax, labels, actives=None, *, useblit=True, actives = [False] * len(labels) self._useblit = useblit and self.canvas.supports_blit - self._background = None ys = np.linspace(1, 0, len(labels)+2)[1:-1] @@ -1110,7 +1134,7 @@ def _clear(self, event): """Internal event handler to clear the buttons.""" if self.ignore(event) or self.canvas.is_saving(): return - self._background = self.canvas.copy_from_bbox(self.ax.bbox) + self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox)) self.ax.draw_artist(self._checks) def _clicked(self, event): @@ -1215,8 +1239,9 @@ def set_active(self, index, state=None): if self.drawon: if self._useblit: - if self._background is not None: - self.canvas.restore_region(self._background) + background = self._load_blit_background() + if background is not None: + self.canvas.restore_region(background) self.ax.draw_artist(self._checks) self.canvas.blit(self.ax.bbox) else: @@ -1650,7 +1675,6 @@ def __init__(self, ax, labels, active=0, activecolor=None, *, ys = np.linspace(1, 0, len(labels) + 2)[1:-1] self._useblit = useblit and self.canvas.supports_blit - self._background = None label_props = _expand_text_props(label_props) self.labels = [ @@ -1692,7 +1716,7 @@ def _clear(self, event): """Internal event handler to clear the buttons.""" if self.ignore(event) or self.canvas.is_saving(): return - self._background = self.canvas.copy_from_bbox(self.ax.bbox) + self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox)) self.ax.draw_artist(self._buttons) def _clicked(self, event): @@ -1785,8 +1809,9 @@ def set_active(self, index): if self.drawon: if self._useblit: - if self._background is not None: - self.canvas.restore_region(self._background) + background = self._load_blit_background() + if background is not None: + self.canvas.restore_region(background) self.ax.draw_artist(self._buttons) self.canvas.blit(self.ax.bbox) else: @@ -1942,7 +1967,6 @@ def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False, self.lineh = ax.axhline(ax.get_ybound()[0], visible=False, **lineprops) self.linev = ax.axvline(ax.get_xbound()[0], visible=False, **lineprops) - self.background = None self.needclear = False def clear(self, event): @@ -1950,7 +1974,7 @@ def clear(self, event): if self.ignore(event) or self.canvas.is_saving(): return if self.useblit: - self.background = self.canvas.copy_from_bbox(self.ax.bbox) + self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox)) def onmove(self, event): """Internal event handler to draw the cursor when the mouse moves.""" @@ -1975,8 +1999,9 @@ def onmove(self, event): return # Redraw. if self.useblit: - if self.background is not None: - self.canvas.restore_region(self.background) + background = self._load_blit_background() + if background is not None: + self.canvas.restore_region(background) self.ax.draw_artist(self.linev) self.ax.draw_artist(self.lineh) self.canvas.blit(self.ax.bbox) @@ -2137,8 +2162,6 @@ def __init__(self, ax, onselect=None, useblit=False, button=None, self._state_modifier_keys.update(state_modifier_keys or {}) self._use_data_coordinates = use_data_coordinates - self.background = None - if isinstance(button, Integral): self.validButtons = [button] else: @@ -2194,7 +2217,7 @@ def update_background(self, event): for artist in artists: stack.enter_context(artist._cm_set(visible=False)) self.canvas.draw() - self.background = self.canvas.copy_from_bbox(self.ax.bbox) + self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox)) if needs_redraw: for artist in artists: self.ax.draw_artist(artist) @@ -2241,8 +2264,9 @@ def update(self): self.ax.get_figure(root=True)._get_renderer() is None): return if self.useblit: - if self.background is not None: - self.canvas.restore_region(self.background) + background = self._load_blit_background() + if background is not None: + self.canvas.restore_region(background) else: self.update_background(None) # We need to draw all artists, which are not included in the diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index f74b9c7f32bf..c936cbaf0d10 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -35,6 +35,7 @@ class Widget: class AxesWidget(Widget): ax: Axes def __init__(self, ax: Axes) -> None: ... + def __del__(self) -> None: ... @property def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... From bec8b2b0775459c5661bf6356de437c15bc89698 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:40:03 +0200 Subject: [PATCH 2/2] Support blitting with changing canvas in some widgets Approach depends a bit on the widget, but generally: - `self._useblit` only stores the flag passed to __init__ - canvas.supports_blit is checked dynamically when needed - we sometimes have the property `sef.useblit`, which is the effective value of flag and capability - if animated artists are needed that state is set during __init__ based on the flag. This relies on the fact that the flag cannot be changed later, and that the value of "animated" does not play a role for drawing canvases without blit support. Supported: - Button - _SelectorWidget - SpanSelector - ToolLineHandles - ToolHandles - RectangleSelector - LassoSelector - PolygonSelector Not supported yet: - CheckButtons - RadioButtons - Cursor - MultiCursor - Lasso --- lib/matplotlib/widgets.py | 47 ++++++++++++++++++++++++-------------- lib/matplotlib/widgets.pyi | 4 +++- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index e5a60c1585ff..6a859e3df225 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -231,7 +231,7 @@ def __init__(self, ax, label, image=None, horizontalalignment='center', transform=ax.transAxes) - self._useblit = useblit and self.canvas.supports_blit + self._useblit = useblit self._observers = cbook.CallbackRegistry(signals=["clicked"]) @@ -265,7 +265,7 @@ def _motion(self, event): if not colors.same_color(c, self.ax.get_facecolor()): self.ax.set_facecolor(c) if self.drawon: - if self._useblit: + if self._useblit and self.canvas.supports_blit: self.ax.draw_artist(self.ax) self.canvas.blit(self.ax.bbox) else: @@ -1087,7 +1087,7 @@ def __init__(self, ax, labels, actives=None, *, useblit=True, if actives is None: actives = [False] * len(labels) - self._useblit = useblit and self.canvas.supports_blit + self._useblit = useblit and self.canvas.supports_blit # TODO: make dynamic ys = np.linspace(1, 0, len(labels)+2)[1:-1] @@ -1674,7 +1674,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *, ys = np.linspace(1, 0, len(labels) + 2)[1:-1] - self._useblit = useblit and self.canvas.supports_blit + self._useblit = useblit and self.canvas.supports_blit # TODO: make dynamic label_props = _expand_text_props(label_props) self.labels = [ @@ -1960,7 +1960,7 @@ def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False, self.visible = True self.horizOn = horizOn self.vertOn = vertOn - self.useblit = useblit and self.canvas.supports_blit + self.useblit = useblit and self.canvas.supports_blit # TODO: make dynamic if self.useblit: lineprops['animated'] = True @@ -2069,6 +2069,7 @@ def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True, self.useblit = ( useblit and all(canvas.supports_blit for canvas in self._canvas_infos)) + # TODO: make dynamic if self.useblit: lineprops['animated'] = True @@ -2153,7 +2154,7 @@ def __init__(self, ax, onselect=None, useblit=False, button=None, self.onselect = lambda *args: None else: self.onselect = onselect - self.useblit = useblit and self.canvas.supports_blit + self._useblit = useblit self.connect_default_events() self._state_modifier_keys = dict(move=' ', clear='escape', @@ -2177,6 +2178,11 @@ def __init__(self, ax, onselect=None, useblit=False, button=None, self._prev_event = None self._state = set() + @property + def useblit(self): + """Return whether blitting is used (requested and supported by canvas).""" + return self._useblit and self.canvas.supports_blit + def set_active(self, active): super().set_active(active) if active: @@ -2599,7 +2605,14 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False, if props is None: props = dict(facecolor='red', alpha=0.5) - props['animated'] = self.useblit + # Note: We set this based on the user setting during ínitialization, + # not on the actual capability of blitting. But the value is + # irrelevant if the backend does not support blitting, so that + # we don't have to dynamically update this on the backend. + # This relies on the current behavior that the request for + # useblit is fixed during initialization and cannot be changed + # afterwards. + props['animated'] = self._useblit self.direction = direction self._extents_on_press = None @@ -2665,7 +2678,7 @@ def _setup_edge_handles(self, props): self._edge_handles = ToolLineHandles(self.ax, positions, direction=self.direction, line_props=props, - useblit=self.useblit) + useblit=self._useblit) @property def _handles_artists(self): @@ -3239,7 +3252,7 @@ def __init__(self, ax, onselect=None, *, minspanx=0, if props is None: props = dict(facecolor='red', edgecolor='black', alpha=0.2, fill=True) - props = {**props, 'animated': self.useblit} + props = {**props, 'animated': self._useblit} self._visible = props.pop('visible', self._visible) to_draw = self._init_shape(**props) self.ax.add_patch(to_draw) @@ -3264,18 +3277,18 @@ def __init__(self, ax, onselect=None, *, minspanx=0, xc, yc = self.corners self._corner_handles = ToolHandles(self.ax, xc, yc, marker_props=self._handle_props, - useblit=self.useblit) + useblit=self._useblit) self._edge_order = ['W', 'S', 'E', 'N'] xe, ye = self.edge_centers self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s', marker_props=self._handle_props, - useblit=self.useblit) + useblit=self._useblit) xc, yc = self.center self._center_handle = ToolHandles(self.ax, [xc], [yc], marker='s', marker_props=self._handle_props, - useblit=self.useblit) + useblit=self._useblit) self._active_handle = None @@ -3782,7 +3795,7 @@ def __init__(self, ax, onselect=None, *, useblit=True, props=None, button=None): **(props if props is not None else {}), # Note that self.useblit may be != useblit, if the canvas doesn't # support blitting. - 'animated': self.useblit, 'visible': False, + 'animated': self._useblit, 'visible': False, } line = Line2D([], [], **props) self.ax.add_line(line) @@ -3906,7 +3919,7 @@ def __init__(self, ax, onselect=None, *, useblit=False, if props is None: props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5) - props = {**props, 'animated': self.useblit} + props = {**props, 'animated': self._useblit} self._selection_artist = line = Line2D([], [], **props) self.ax.add_line(line) @@ -3915,7 +3928,7 @@ def __init__(self, ax, onselect=None, *, useblit=False, markerfacecolor=props.get('color', 'k')) self._handle_props = handle_props self._polygon_handles = ToolHandles(self.ax, [], [], - useblit=self.useblit, + useblit=self._useblit, marker_props=self._handle_props) self._active_handle_idx = -1 @@ -3935,7 +3948,7 @@ def _get_bbox(self): def _add_box(self): self._box = RectangleSelector(self.ax, - useblit=self.useblit, + useblit=self._useblit, grab_range=self.grab_range, handle_props=self._box_handle_props, props=self._box_props, @@ -4215,7 +4228,7 @@ class Lasso(AxesWidget): def __init__(self, ax, xy, callback, *, useblit=True, props=None): super().__init__(ax) - self.useblit = useblit and self.canvas.supports_blit + self.useblit = useblit and self.canvas.supports_blit # TODO: Make dynamic if self.useblit: self.background = self.canvas.copy_from_bbox(self.ax.bbox) diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index c936cbaf0d10..2f34255d625c 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -273,7 +273,7 @@ class MultiCursor(Widget): class _SelectorWidget(AxesWidget): onselect: Callable[[float, float], Any] - useblit: bool + _useblit: bool background: Any validButtons: list[MouseButton] def __init__( @@ -285,6 +285,8 @@ class _SelectorWidget(AxesWidget): state_modifier_keys: dict[str, str] | None = ..., use_data_coordinates: bool = ..., ) -> None: ... + @property + def useblit(self) -> bool: ... def update_background(self, event: Event) -> None: ... def connect_default_events(self) -> None: ... def ignore(self, event: Event) -> bool: ...