diff --git a/docs/configuration.rst b/docs/configuration.rst index 4137f357e..848346b6e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -93,6 +93,10 @@ Here's a broad overview of the new settings: * The ``subplots`` category includes settings that control the default subplot layout and padding. +* The ``navigation`` category controls temporary lower-detail previews during + interactive panning and 3D rotation. Set :rcraw:`navigation.preview` to + ``False`` when exact fidelity is preferred during mouse gestures. This setting + does not affect ordinary draws, animations, or saved figures. * The ``geo`` category contains settings related to geographic plotting, including the geographic backend, gridline label settings, and map bound settings. * The ``abc``, ``title``, and ``label`` categories control a-b-c labels, axes diff --git a/ultraplot/_animation.py b/ultraplot/_animation.py new file mode 100644 index 000000000..bf1754041 --- /dev/null +++ b/ultraplot/_animation.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python3 +""" +Helpers for responsive interactive and animated UltraPlot figures. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from contextlib import contextmanager +from weakref import WeakSet + +import matplotlib.artist as martist +import matplotlib.axis as maxis +import matplotlib.collections as mcollections +import matplotlib.image as mimage +import matplotlib.lines as mlines +import matplotlib.text as mtext +import matplotlib.transforms as mtransforms +import numpy as np +from matplotlib.backend_bases import DrawEvent + +from ._interaction import _NavigationInteractionManager + + +class _SelectiveDrawManager: + """ + Retain safe draw layers and bypass unchanged Matplotlib traversal. + + Multi-axes figures retain each complete axes as one layer. Single Cartesian + axes retain the stable draw-order prefix below their first clipped numeric + line, then redraw that line and every later artist as an exact z-order suffix. + Unknown stale artists, geometry changes, overlapping layers, unsupported + artist orders, and export draws fall back to a complete draw. The first display + is always untouched; a later complete draw primes the retained layers. + """ + + _data_artist_types = (mlines.Line2D, mcollections.Collection, mimage.AxesImage) + + def __init__(self, canvas, figure=None): + self.canvas = canvas + self.figure = figure if figure is not None else canvas.figure + self._backgrounds = {} + self._regions = {} + self._signatures = {} + self._view_signatures = {} + self._axes = () + self._suffixes = {} + self._pending_suffixes = {} + self._pending_regions = {} + self._cache_mode = None + self._capture_mode = None + self._capturing = False + self._selecting = False + self._has_drawn = False + self._suspended = False + self._closed = False + self._cache_safe = False + self._canvas_signature = None + self._drawn_view_signature = None + self._selective_draw_count = 0 + self._full_draw_count = 0 + self._supports_blit = bool(canvas.supports_blit) + callbacks = self.figure._canvas_callbacks + self._draw_cid = callbacks.connect("draw_event", self._on_draw) + self._resize_cid = callbacks.connect("resize_event", self._on_resize) + self._navigation_preview = _NavigationInteractionManager( + canvas, self.figure, self + ) + + @staticmethod + def _bbox_signature(bbox): + return tuple(float(value) for value in bbox.bounds) + + def _axes_signature(self, ax): + return ( + tuple( + (id(child), float(child.get_zorder())) for child in ax.get_children() + ), + tuple( + ( + id(line), + id(line.get_transform()), + bool(line.get_clip_on()), + id(line.get_clip_box()), + id(line.get_clip_path()), + ) + for line in ax.lines + ), + self._bbox_signature(ax.get_position(original=False)), + bool(ax.get_visible()), + float(ax.get_zorder()), + ) + + def _axes_view_signature(self, ax): + """Return paint-only limits, scales, and projection camera state.""" + return ( + self._bbox_signature(ax.viewLim), + ax.get_xscale(), + ax.get_yscale(), + self._camera_signature(ax), + ) + + def _current_canvas_signature(self): + """Return framebuffer properties that invalidate copied pixel regions.""" + get_renderer = getattr(self.canvas, "get_renderer", None) + if get_renderer is None: + return None + try: + renderer = get_renderer() + except Exception: + return None + return ( + float(self.figure.dpi), + self._bbox_signature(self.figure.bbox), + float(renderer.width), + float(renderer.height), + float(getattr(self.canvas, "device_pixel_ratio", 1)), + ) + + def _view_signature(self, axes=None): + """Return the view state last presented by a complete canvas draw.""" + axes = self._visible_axes() if axes is None else axes + return tuple( + ( + id(ax), + self._axes_view_signature(ax), + ) + for ax in axes + ) + + @staticmethod + def _camera_signature(ax): + """Return 3D camera and limits, or ``None`` for ordinary 2D axes.""" + if getattr(ax, "_name", None) != "three": + return None + try: + return ( + float(ax.elev), + float(ax.azim), + float(ax.roll), + int(ax._vertical_axis), + tuple(float(value) for value in ax.get_xlim3d()), + tuple(float(value) for value in ax.get_ylim3d()), + tuple(float(value) for value in ax.get_zlim3d()), + float(ax._focal_length), + tuple(float(value) for value in ax.get_box_aspect()), + ) + except (AttributeError, TypeError, ValueError): + return None + + def _visible_axes(self): + return tuple(ax for ax in self.figure.axes if ax.get_visible()) + + def _has_explicit_blit_manager(self): + return bool(tuple(getattr(self.figure, "_blit_managers", ()))) + + def _has_animated_artist(self, axes): + return any(artist.get_animated() for ax in axes for artist in ax.get_children()) + + def _has_overlapping_figure_artist(self, targets): + """Return whether a figure artist overlaps retained artists or regions.""" + get_renderer = getattr(self.canvas, "get_renderer", None) + if get_renderer is None: + return True + renderer = get_renderer() + for artist in self.figure.get_children(): + if ( + artist is self.figure.patch + or artist in self.figure.axes + or not artist.get_visible() + ): + continue + if isinstance(artist, mtext.Text) and not artist.get_text(): + continue + try: + overlay_bbox = artist.get_window_extent(renderer) + except Exception: + return True + for target in targets: + if isinstance(target, martist.Artist): + if not target.get_visible(): + continue + try: + target_bbox = target.get_window_extent(renderer) + except Exception: + return True + else: + target_bbox = target + overlap = mtransforms.Bbox.intersection(overlay_bbox, target_bbox) + if overlap is not None and overlap.width > 0 and overlap.height > 0: + return True + return False + + @staticmethod + def _bbox_contains(outer, inner, tolerance=1e-6): + return ( + inner.x0 >= outer.x0 - tolerance + and inner.y0 >= outer.y0 - tolerance + and inner.x1 <= outer.x1 + tolerance + and inner.y1 <= outer.y1 + tolerance + ) + + def _suffix_fits_region(self, suffix, region, renderer): + """Return whether restoring *region* clears every suffix paint extent.""" + for artist in suffix: + if not artist.get_visible(): + continue + try: + extent = artist.get_window_extent(renderer) + except Exception: + return False + # Invisible placeholder labels commonly have zero-area extents just + # outside the axes tight bbox. They cannot damage any pixels. + if extent.width <= 0 or extent.height <= 0: + continue + if artist.get_path_effects(): + return False + if artist.get_clip_on(): + clip_box = artist.get_clip_box() + if clip_box is not None: + extent = mtransforms.Bbox.intersection(extent, clip_box) + if extent is None: + continue + if artist.get_clip_path() is not None: + # Arbitrary clip paths cannot be bounded reliably here. + return False + if not self._bbox_contains(region, extent): + return False + return True + + @staticmethod + def _has_numeric_line_data(line): + """Return whether line conversion cannot mutate categorical/date axes.""" + try: + xdata = np.asanyarray(line.get_xdata(orig=True)) + ydata = np.asanyarray(line.get_ydata(orig=True)) + except (TypeError, ValueError): + return False + return xdata.dtype.kind in "biufc" and ydata.dtype.kind in "biufc" + + def _resolve_line_suffix(self, ax): + """Return the exact draw-order suffix starting at the first data line.""" + if ( + getattr(ax, "_name", None) != "cartesian" + or not ax.axison + or not ax.get_frame_on() + or ax.get_rasterization_zorder() is not None + or any(not line.get_clip_on() for line in ax.lines) + or any(not self._has_numeric_line_data(line) for line in ax.lines) + ): + return () + artists = list(ax.get_children()) + if ax.patch not in artists or not ax.lines: + return () + artists.remove(ax.patch) + artists = sorted(artists, key=lambda artist: artist.get_zorder()) + line_ids = {id(line) for line in ax.lines} + positions = [ + index for index, artist in enumerate(artists) if id(artist) in line_ids + ] + if not positions: + return () + suffix = tuple(artists[min(positions) :]) + if any( + isinstance(artist, (maxis.Axis, mimage.AxesImage)) + or (hasattr(artist, "_axis_map") and artist is not ax) + for artist in suffix + ): + return () + if self._has_overlapping_figure_artist(suffix): + return () + return suffix + + @staticmethod + def _regions_overlap(regions): + regions = tuple(regions) + for idx, left in enumerate(regions): + for right in regions[idx + 1 :]: + overlap = mtransforms.Bbox.intersection(left, right) + if overlap is not None and overlap.width > 0 and overlap.height > 0: + return True + return False + + def _resolve_region(self, ax, renderer, cached_regions): + # UltraLayout already measured this exact region. Reconstructing it + # from cached outsets avoids a second tick/text traversal. + region = cached_regions.get(ax) + if region is None: + region = ax.get_tightbbox(renderer) + if region is None: + return None + region = region.padded(2) + return mtransforms.Bbox.intersection(region, self.figure.bbox) + + @staticmethod + def _mark_axes_clean(axes): + """Clear placeholder staleness left behind by ``Axes.draw()``.""" + for ax in axes: + for child in ax.get_children(): + child.stale = False + ax.stale = False + + def invalidate(self): + """Discard all retained axes layers.""" + self._backgrounds.clear() + self._regions.clear() + self._signatures.clear() + self._view_signatures.clear() + self._axes = () + self._suffixes = {} + self._pending_suffixes = {} + self._pending_regions = {} + self._cache_mode = None + self._capture_mode = None + self._cache_safe = False + self._canvas_signature = None + + def _on_resize(self, event): + if event is None or event.canvas is self.canvas: + self.invalidate() + + def _on_draw(self, event): + if event is not None and event.canvas is self.canvas and not self._selecting: + self._has_drawn = True + self._drawn_view_signature = self._view_signature() + if ( + self._closed + or self._suspended + or self._selecting + or not self._capturing + or event.canvas is not self.canvas + ): + return + + axes = self._visible_axes() + renderer = event.renderer + store = getattr(self.figure, "_layout_extent_store", None) + cached_regions = {} if store is None else store._get_retained_bboxes(axes) + regions = self._pending_regions or { + ax: self._resolve_region(ax, renderer, cached_regions) for ax in axes + } + valid = all(region is not None for region in regions.values()) + if valid: + backgrounds = { + ax: self.canvas.copy_from_bbox(region) for ax, region in regions.items() + } + else: + backgrounds = {} + + if self._capture_mode == "suffix": + suffixes = self._pending_suffixes + for ax in sorted(axes, key=lambda item: item.get_zorder()): + for artist in suffixes[ax]: + self.figure.draw_artist(artist) + else: + suffixes = {} + # Figure.draw() omitted these temporarily animated axes. Draw them now, + # in normal axes z-order, before the backend presents the frame. + for ax in sorted(axes, key=lambda item: item.get_zorder()): + self.figure.draw_artist(ax) + # Axes.draw() intentionally leaves some invisible placeholder text stale. + # Retained drawing needs a clean baseline so a later property mutation can + # be distinguished from that permanent state. + self._mark_axes_clean(axes) + for artist in self.figure.get_children(): + if artist not in axes: + artist.stale = False + + self._full_draw_count += 1 + self._axes = axes + self._suffixes = suffixes + self._regions = regions if valid else {} + self._backgrounds = backgrounds + self._signatures = {ax: self._axes_signature(ax) for ax in axes} + self._view_signatures = {ax: self._axes_view_signature(ax) for ax in axes} + self._canvas_signature = self._current_canvas_signature() + self._cache_mode = self._capture_mode + if self._capture_mode == "suffix": + self._cache_safe = bool( + valid + and len(suffixes) == len(axes) + and self._canvas_signature is not None + and not self._regions_overlap(regions.values()) + and all( + self._suffix_fits_region(suffixes[ax], regions[ax], renderer) + for ax in axes + ) + ) + else: + self._cache_safe = bool( + valid + and self._canvas_signature is not None + and len(axes) > 1 + and not self._has_explicit_blit_manager() + and not self._has_animated_artist(axes) + ) + for ax in axes: + ax.stale = False + + @contextmanager + def full_draw_context(self): + """Temporarily split a full draw into static and axes layers.""" + if ( + self._closed + or self._suspended + or not self._supports_blit + or self._has_explicit_blit_manager() + ): + self.invalidate() + yield + return + + axes = self._visible_axes() + if not axes: + self.invalidate() + yield + return + if not self._has_drawn: + self.invalidate() + yield + return + if any(ax.get_animated() for ax in axes) or self._has_animated_artist(axes): + self.invalidate() + yield + return + + # A changing view is normally an interactive pan. Rebuilding retained + # layers on every motion frame adds work that the next frame discards. + # Draw it normally; an unchanged later draw can prime the cache again. + view_signature = self._view_signature(axes) + if ( + self._drawn_view_signature is not None + and view_signature != self._drawn_view_signature + ): + self.invalidate() + yield + return + + suffixes = {ax: self._resolve_line_suffix(ax) for ax in axes} + if all(suffixes.values()): + renderer = self.canvas.get_renderer() + store = getattr(self.figure, "_layout_extent_store", None) + cached_regions = {} if store is None else store._get_retained_bboxes(axes) + regions = { + ax: self._resolve_region(ax, renderer, cached_regions) for ax in axes + } + if any(region is None for region in regions.values()) or ( + len(axes) > 1 and self._regions_overlap(regions.values()) + ): + self.invalidate() + yield + return + targets = tuple( + artist + for ax in sorted(axes, key=lambda item: item.get_zorder()) + for artist in suffixes[ax] + ) + self._capture_mode = "suffix" + self._pending_suffixes = suffixes + self._pending_regions = regions + elif len(axes) == 1: + self.invalidate() + yield + return + else: + renderer = self.canvas.get_renderer() + store = getattr(self.figure, "_layout_extent_store", None) + cached_regions = {} if store is None else store._get_retained_bboxes(axes) + regions = { + ax: self._resolve_region(ax, renderer, cached_regions) for ax in axes + } + if any( + region is None for region in regions.values() + ) or self._has_overlapping_figure_artist(regions.values()): + self.invalidate() + yield + return + targets = axes + self._capture_mode = "axes" + self._pending_suffixes = {} + self._pending_regions = regions + + animated = {artist: artist.get_animated() for artist in targets} + + self._capturing = True + try: + for artist in targets: + # This is a transient draw-routing flag, not a user property + # mutation. Avoid set_animated(), which marks every axes stale + # and defeats the persistent layout extent cache. + artist._animated = True + yield + finally: + for artist, state in animated.items(): + artist._animated = state + self._capturing = False + self._capture_mode = None + self._pending_suffixes = {} + self._pending_regions = {} + + def _dirty_axes(self): + axes = self._visible_axes() + if axes != self._axes: + return None + if any( + artist.stale for artist in self.figure.get_children() if artist not in axes + ): + return None + + dirty = [] + view_dirty = [] + for ax in axes: + view_changed = self._axes_view_signature(ax) != self._view_signatures.get( + ax + ) + if view_changed and self._cache_mode != "axes": + return None + if ( + view_changed + and getattr(ax, "_name", None) == "three" + and len(axes) <= 2 + ): + # Measuring a rotated 3D extent requires one preliminary axes + # draw. With at most two axes this cannot beat a complete draw. + return None + if self._axes_signature(ax) != self._signatures.get(ax): + return None + stale_children = [child for child in ax.get_children() if child.stale] + # Layout/tick cache cleanup can leave the Axes container stale even + # though every drawable child is clean. Child flags carry the useful + # paint-level signal; geometry is guarded by the signature above. + if view_changed: + dirty.append(ax) + view_dirty.append(ax) + continue + if not stale_children: + continue + if self._cache_mode == "suffix": + suffix = self._suffixes.get(ax, ()) + if not all( + isinstance(child, mlines.Line2D) + and child in suffix + and self._has_numeric_line_data(child) + for child in stale_children + ): + return None + elif not all( + isinstance(child, self._data_artist_types) for child in stale_children + ): + return None + dirty.append(ax) + return tuple(dirty), tuple(view_dirty) + + def _damage_closure(self, dirty, damage): + """Expand damage to intersecting axes and preserve figure-level order.""" + damage = damage.padded(2) + damage = mtransforms.Bbox.intersection(damage, self.figure.bbox) + if damage is not None: + damage = mtransforms.Bbox.from_extents( + np.floor(damage.x0), + np.floor(damage.y0), + np.ceil(damage.x1), + np.ceil(damage.y1), + ) + if damage is None or self._has_overlapping_figure_artist((damage,)): + return None + + redraw = set(dirty) + changed = True + while changed: + changed = False + for ax in self._axes: + if ax in redraw: + continue + # Retained regions include two safety pixels. Only propagate + # through overlap with the actual painted extent, otherwise + # adjacent subplot padding forms an unnecessary redraw chain. + painted_region = self._regions[ax].padded(-2) + overlap = mtransforms.Bbox.intersection(damage, painted_region) + if overlap is not None and overlap.width > 0 and overlap.height > 0: + redraw.add(ax) + damage = mtransforms.Bbox.union([damage, self._regions[ax]]) + changed = True + return damage, tuple( + ax + for ax in sorted(self._axes, key=lambda item: item.get_zorder()) + if ax in redraw + ) + + def _view_damage(self, dirty, renderer): + """Resolve exact damage after view changes and axes needing repaint.""" + new_regions = {} + needs_measurement_draw = any( + getattr(ax, "_name", None) == "three" for ax in dirty + ) + measurement_background = ( + self.canvas.copy_from_bbox(self.figure.bbox) + if needs_measurement_draw + else None + ) + try: + for ax in dirty: + if getattr(ax, "_name", None) == "three": + # Axis3D tick positions are updated only by draw(). Draw once + # to measure the new camera extent, then undo that probe. + self.figure.draw_artist(ax) + region = self._resolve_region(ax, renderer, {}) + if region is None: + return None + new_regions[ax] = region + finally: + if measurement_background is not None: + self.canvas.restore_region(measurement_background) + damage = mtransforms.Bbox.union( + [self._regions[ax] for ax in dirty] + [new_regions[ax] for ax in dirty] + ) + resolved = self._damage_closure(dirty, damage) + if resolved is None: + return None + damage, redraw = resolved + return damage, redraw, new_regions + + def draw_if_possible(self): + """Use retained axes layers for paint-only data changes.""" + canvas_signature = self._current_canvas_signature() + canvas_changed = ( + self._canvas_signature is not None + and canvas_signature != self._canvas_signature + ) + if ( + self._closed + or self._suspended + or not self._supports_blit + or not self._cache_safe + or self._has_explicit_blit_manager() + or getattr(self.figure, "_layout_dirty", False) + or canvas_changed + ): + if canvas_changed: + self.invalidate() + return False + + dirty_state = self._dirty_axes() + if dirty_state is None: + return False + dirty, view_dirty = dirty_state + if not dirty: + return False + + self._selecting = True + try: + renderer = self.canvas.get_renderer() + if view_dirty: + if self._cache_mode != "axes": + return False + resolved = self._view_damage(view_dirty, renderer) + if resolved is None: + return False + damage, redraw, new_regions = resolved + # Each exact region buffer has backend-correct integer bounds. + # Restoring every axes in the overlap closure clears old paint; + # pixels in newly exposed areas already contain the static frame. + for ax in redraw: + self.canvas.restore_region(self._backgrounds[ax]) + for ax in redraw: + self.figure.draw_artist(ax) + for ax, region in new_regions.items(): + self._regions[ax] = region + blit_regions = (damage,) + painted = redraw + elif self._cache_mode == "suffix": + for ax in dirty: + self.canvas.restore_region(self._backgrounds[ax]) + for ax in sorted(dirty, key=lambda item: item.get_zorder()): + for artist in self._suffixes[ax]: + self.figure.draw_artist(artist) + blit_regions = tuple(self._regions[ax] for ax in dirty) + painted = dirty + else: + damage = mtransforms.Bbox.union([self._regions[ax] for ax in dirty]) + resolved = self._damage_closure(dirty, damage) + if resolved is None: + return False + damage, redraw = resolved + for ax in redraw: + self.canvas.restore_region(self._backgrounds[ax]) + for ax in redraw: + self.figure.draw_artist(ax) + blit_regions = (damage,) + painted = redraw + self._mark_axes_clean(painted) + for ax in painted: + # Complete 3D axes draws can update collection zorders and other + # renderer-facing state. Refresh signatures from the exact frame. + self._signatures[ax] = self._axes_signature(ax) + self._view_signatures[ax] = self._axes_view_signature(ax) + self._drawn_view_signature = self._view_signature() + for region in blit_regions: + self.canvas.blit(region) + self.figure.stale = False + self._selective_draw_count += 1 + DrawEvent("draw_event", self.canvas, self.canvas.get_renderer())._process() + finally: + self._selecting = False + return True + + @contextmanager + def save_context(self): + """Suspend retained drawing while producing external output.""" + self._suspended = True + try: + with self._navigation_preview.full_quality_context(): + yield + finally: + self._suspended = False + self.invalidate() + + def close(self): + if self._closed: + return + callbacks = self.figure._canvas_callbacks + callbacks.disconnect(self._draw_cid) + callbacks.disconnect(self._resize_cid) + self._navigation_preview.close() + self.invalidate() + self._closed = True + + +class _BlitManager: + """ + Manage efficient updates of a small set of changing artists. + + The manager caches the static canvas background, restores it for each + update, redraws only the managed artists, and blits the affected region. + Backends without blitting support safely fall back to ``draw_idle()``. + + Parameters + ---------- + canvas : `~matplotlib.backend_bases.FigureCanvasBase` + Canvas containing the artists. + artists : iterable of `~matplotlib.artist.Artist`, optional + Artists that will change between updates. + bbox : `~matplotlib.transforms.Bbox` or object with a ``bbox`` attribute, optional + Region to cache and blit. By default, the union of the managed artists' + axes bounding boxes is used. Figure-level artists fall back to the full + figure bounding box. + + Notes + ----- + Managed artists are drawn above the cached static background, matching + Matplotlib's standard blitting behavior. + """ + + def __init__(self, canvas, artists: Iterable[martist.Artist] = (), bbox=None): + if canvas.figure is None: + raise RuntimeError("Cannot manage blitting for a canvas without a figure.") + self.canvas = canvas + self.figure = canvas.figure + self._artists = [] + self._animated = {} + self._bbox = bbox + self._background = None + self._closed = False + self._suspended = False + self._supports_blit = bool(canvas.supports_blit) + self._draw_cid = canvas.mpl_connect("draw_event", self._on_draw) + self._resize_cid = canvas.mpl_connect("resize_event", self._on_resize) + managers = getattr(self.figure, "_blit_managers", None) + if managers is None: + managers = self.figure._blit_managers = WeakSet() + managers.add(self) + for artist in artists: + self.add_artist(artist) + + @property + def artists(self): + """Managed artists as an immutable tuple.""" + return tuple(self._artists) + + @property + def supports_blit(self): + """Whether the associated canvas supports blitting.""" + return self._supports_blit + + def _resolve_bbox(self): + bbox = self._bbox + if bbox is not None: + return getattr(bbox, "bbox", bbox) + + axes = [] + for artist in self._artists: + axis = getattr(artist, "axes", None) + if axis is None: + return self.figure.bbox + if axis not in axes: + axes.append(axis) + if not axes: + return self.figure.bbox + return mtransforms.Bbox.union([axis.bbox for axis in axes]) + + def _draw_artists(self): + for artist in sorted(self._artists, key=lambda item: item.get_zorder()): + self.figure.draw_artist(artist) + + def _on_draw(self, event): + if self._closed or self._suspended or not self._supports_blit: + return + if event is not None and event.canvas is not self.canvas: + return + self._background = self.canvas.copy_from_bbox(self._resolve_bbox()) + self._draw_artists() + + def _on_resize(self, event): + if event is None or event.canvas is self.canvas: + self.invalidate() + + def add_artist(self, artist: martist.Artist): + """ + Add an artist to the managed update set. + + Returns + ------- + _BlitManager + This manager, to permit chained calls. + """ + if self._closed: + raise RuntimeError("Cannot add artists to a closed _BlitManager.") + if not isinstance(artist, martist.Artist): + raise TypeError( + f"Expected a matplotlib Artist, got {type(artist).__name__}." + ) + if artist.figure is not self.figure: + raise RuntimeError("The artist must belong to the manager's figure.") + if artist in self._artists: + return self + self._artists.append(artist) + self._animated[artist] = artist.get_animated() + if self._supports_blit: + artist.set_animated(True) + self.invalidate() + return self + + def remove_artist(self, artist: martist.Artist): + """ + Stop managing an artist and restore its original animated state. + + Returns + ------- + _BlitManager + This manager, to permit chained calls. + """ + if artist not in self._artists: + return self + self._artists.remove(artist) + artist.set_animated(self._animated.pop(artist)) + self.invalidate() + return self + + def invalidate(self): + """Discard the cached background before the next update.""" + self._background = None + + @contextmanager + def _save_context(self): + """Temporarily restore original artist states for a complete export.""" + if self._closed: + yield + return + self._suspended = True + current = {artist: artist.get_animated() for artist in self._artists} + try: + for artist in self._artists: + artist.set_animated(self._animated[artist]) + yield + finally: + for artist, animated in current.items(): + artist.set_animated(animated) + self._suspended = False + self.invalidate() + + def update(self, *, flush=False): + """ + Redraw the managed artists. + + Parameters + ---------- + flush : bool, default: False + Whether to immediately process pending GUI events after blitting. + + Returns + ------- + bool + ``True`` when the blitting fast path was used, otherwise ``False``. + """ + if self._closed: + raise RuntimeError("Cannot update a closed _BlitManager.") + if not self._supports_blit: + self.canvas.draw_idle() + if flush: + self.canvas.flush_events() + return False + + if self._background is None: + # The draw_event callback captures the static background and draws + # the animated artists before the backend presents the frame. + self.canvas.draw() + else: + bbox = self._resolve_bbox() + self.canvas.restore_region(self._background) + self._draw_artists() + self.canvas.blit(bbox) + if flush: + self.canvas.flush_events() + return True + + def close(self, *, redraw=True): + """ + Disconnect callbacks and restore the artists' animated states. + + Parameters + ---------- + redraw : bool, default: True + Whether to schedule a normal full redraw after restoring the artists. + """ + if self._closed: + return + self.canvas.mpl_disconnect(self._draw_cid) + self.canvas.mpl_disconnect(self._resize_cid) + for artist in tuple(self._artists): + artist.set_animated(self._animated[artist]) + self._artists.clear() + self._animated.clear() + self._background = None + self._closed = True + managers = getattr(self.figure, "_blit_managers", ()) + managers.discard(self) + if redraw: + self.canvas.draw_idle() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + self.close() diff --git a/ultraplot/_interaction.py b/ultraplot/_interaction.py new file mode 100644 index 000000000..25a220faf --- /dev/null +++ b/ultraplot/_interaction.py @@ -0,0 +1,694 @@ +#!/usr/bin/env python3 +"""Private helpers for responsive interactive figure navigation.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, field +import time + +import matplotlib.collections as mcollections +import numpy as np +from matplotlib.backend_bases import TimerBase +from matplotlib.ticker import MaxNLocator, NullLocator + +_MISSING = object() + + +def _preview_enabled(): + """Return the current runtime setting for approximate navigation frames.""" + # Keep this local to avoid config -> figure -> animation import cycles. + from .config import rc_ultraplot + + return rc_ultraplot["navigation.preview"] + + +def _state_equal(left, right): + """Return whether an artist property still matches our preview value.""" + if left is right: + return True + if isinstance(left, (tuple, list)) and isinstance(right, (tuple, list)): + return len(left) == len(right) and all( + _state_equal(item_left, item_right) + for item_left, item_right in zip(left, right) + ) + try: + return bool( + np.array_equal(np.asanyarray(left), np.asanyarray(right), equal_nan=True) + ) + except (TypeError, ValueError): + try: + return bool(left == right) + except (TypeError, ValueError): + return False + + +@dataclass +class _LocatorPreviewState: + """Exact and temporary locators for one axis.""" + + axis: object + original_major: object + original_minor: object + preview_major: object + preview_minor: object + + def restore(self): + if self.axis.get_major_locator() is self.preview_major: + self.axis.set_major_locator(self.original_major) + if self.axis.get_minor_locator() is self.preview_minor: + self.axis.set_minor_locator(self.original_minor) + + +@dataclass +class _LinePreviewState: + """Exact and sampled data for one line artist.""" + + artist: object + original: tuple + preview: tuple + dimensions: str + + def restore(self): + if self.dimensions == "3d": + current = tuple(np.asanyarray(item) for item in self.artist.get_data_3d()) + restored = tuple( + original if _state_equal(item, preview) else item + for item, original, preview in zip(current, self.original, self.preview) + ) + if not _state_equal(current, restored): + self.artist.set_data_3d(*restored) + else: + current = ( + np.asanyarray(self.artist.get_xdata(orig=True)), + np.asanyarray(self.artist.get_ydata(orig=True)), + ) + restored = tuple( + original if _state_equal(item, preview) else item + for item, original, preview in zip(current, self.original, self.preview) + ) + if not _state_equal(current, restored): + self.artist.set_data(*restored) + + +@dataclass +class _ScatterPreviewState: + """Exact and sampled private collection fields for one scatter artist.""" + + artist: object + original: dict + preview: dict + + def restore(self): + for name, preview in self.preview.items(): + current = getattr(self.artist, name, _MISSING) + if current is _MISSING: + continue + if name == "_offsets3d" and isinstance(current, tuple): + restored = tuple( + original if _state_equal(item, preview_item) else item + for item, original, preview_item in zip( + current, self.original[name], preview + ) + ) + if not _state_equal(current, restored): + setattr(self.artist, name, restored) + elif _state_equal(current, preview): + setattr(self.artist, name, self.original[name]) + self.artist.stale = True + + +@dataclass +class _SurfacePreviewState: + """Temporary surface proxy attachment and draw-suppression state.""" + + artist: object + proxy: object + ax: object + proxy_attached: bool + proxy_visible: bool + hidden_marker: object + + def restore(self): + if getattr(self.artist, "_ultraplot_navigation_hidden", _MISSING) is True: + if self.hidden_marker is _MISSING: + delattr(self.artist, "_ultraplot_navigation_hidden") + else: + self.artist._ultraplot_navigation_hidden = self.hidden_marker + if self.proxy.get_visible(): + self.proxy.set_visible(self.proxy_visible) + if not self.proxy_attached and self.proxy.axes is self.ax: + self.proxy.remove() + + +@dataclass +class _AxesPreviewState: + """All temporary navigation state owned for one axes.""" + + ax: object + grid_marker: object = _MISSING + locators: list = field(default_factory=list) + lines: list = field(default_factory=list) + scatters: list = field(default_factory=list) + surfaces: list = field(default_factory=list) + restored: bool = False + + def restore(self): + if self.restored: + return + if getattr(self.ax, "_ultraplot_navigation_hide_grid", _MISSING) is True: + if self.grid_marker is _MISSING: + delattr(self.ax, "_ultraplot_navigation_hide_grid") + else: + self.ax._ultraplot_navigation_hide_grid = self.grid_marker + for state in self.locators: + state.restore() + for state in self.lines: + state.restore() + for state in self.scatters: + state.restore() + for state in self.surfaces: + state.restore() + self.ax.stale = True + self.restored = True + + +@dataclass +class _SurfaceProxyRecipe: + """Lazy recipe for a coarse surface used only during navigation.""" + + arrays: tuple + args: tuple + kwargs: dict + geometry_signature: tuple + facecolor_signature: tuple + proxy: object = None + + +def _surface_geometry_signature(surface): + vector = getattr(surface, "_vec", None) + return (id(vector), getattr(vector, "shape", None)) + + +def _surface_facecolor_signature(surface): + colors = getattr(surface, "_facecolor3d", None) + return (id(colors), getattr(colors, "shape", None)) + + +def _register_surface_preview(surface, X, Y, Z, args, kwargs): + """Attach a lazy coarse-surface recipe without constructing another artist.""" + try: + arrays = tuple(np.asanyarray(array) for array in (X, Y, Z)) + if any(array.ndim != 2 for array in arrays): + return + rows, cols = arrays[2].shape + if rows * cols <= 625: + return + row_idx = np.unique(np.linspace(0, rows - 1, min(10, rows), dtype=int)) + col_idx = np.unique(np.linspace(0, cols - 1, min(10, cols), dtype=int)) + sampled = tuple( + np.array(array[np.ix_(row_idx, col_idx)], copy=True) for array in arrays + ) + preview_kwargs = dict(kwargs) + for key in ("rcount", "ccount", "rstride", "cstride"): + preview_kwargs.pop(key, None) + facecolors = preview_kwargs.get("facecolors") + if facecolors is not None: + facecolors = np.asanyarray(facecolors) + if facecolors.shape[:2] == (rows, cols): + preview_kwargs["facecolors"] = np.array( + facecolors[np.ix_(row_idx, col_idx)], copy=True + ) + else: + preview_kwargs.pop("facecolors") + surface._ultraplot_lod_recipe = _SurfaceProxyRecipe( + arrays=sampled, + args=tuple(args), + kwargs=preview_kwargs, + geometry_signature=_surface_geometry_signature(surface), + facecolor_signature=_surface_facecolor_signature(surface), + ) + except (AttributeError, IndexError, TypeError, ValueError): + return + + +def _sync_surface_proxy(surface, proxy): + """Copy safe presentation properties from an exact surface to its proxy.""" + proxy.set_alpha(surface.get_alpha()) + proxy.set_zorder(surface.get_zorder()) + proxy.set_cmap(surface.get_cmap()) + proxy.set_norm(surface.norm) + proxy.set_clim(*surface.get_clim()) + for getter_name, setter_name in ( + ("get_edgecolor", "set_edgecolor"), + ("get_linewidth", "set_linewidth"), + ("get_antialiased", "set_antialiased"), + ): + getter = getattr(surface, getter_name, None) + setter = getattr(proxy, setter_name, None) + if getter is None or setter is None: + continue + value = np.asanyarray(getter()) + if value.ndim == 0 or len(value) <= 1: + setter(value) + + +def _prepare_surface_preview(surface): + """Synchronize an attached proxy and return whether to suppress the exact one.""" + recipe = getattr(surface, "_ultraplot_lod_recipe", None) + if recipe is None or recipe.proxy is None or recipe.proxy.axes is not surface.axes: + return False + if _surface_geometry_signature(surface) != recipe.geometry_signature: + recipe.proxy.set_visible(False) + return False + if _surface_facecolor_signature(surface) != recipe.facecolor_signature: + colors = np.asanyarray(getattr(surface, "_facecolor3d", ())) + if colors.ndim != 2 or len(colors) != 1: + recipe.proxy.set_visible(False) + return False + recipe.proxy.set_facecolor(colors) + _sync_surface_proxy(surface, recipe.proxy) + recipe.proxy.set_visible(surface.get_visible()) + return surface.get_visible() + + +def _resolve_surface_proxy(surface): + """Create or return a valid lazy surface proxy for an exact collection.""" + recipe = getattr(surface, "_ultraplot_lod_recipe", None) + ax = surface.axes + if ( + recipe is None + or ax is None + or _surface_geometry_signature(surface) != recipe.geometry_signature + ): + return None + if recipe.proxy is not None: + _sync_surface_proxy(surface, recipe.proxy) + return recipe.proxy + + from mpl_toolkits.mplot3d import Axes3D + + kwargs = dict(recipe.kwargs) + + limits = (ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()) + autoscale = ax.get_autoscale_on() + proxy = None + try: + proxy = Axes3D.plot_surface(ax, *recipe.arrays, *recipe.args, **kwargs) + proxy.set_visible(False) + proxy._ultraplot_lod_proxy = True + proxy.remove() + recipe.proxy = proxy + _sync_surface_proxy(surface, proxy) + return proxy + except (AttributeError, IndexError, TypeError, ValueError): + if proxy is not None and proxy.axes is ax: + proxy.remove() + return None + finally: + ax.set_xlim3d(*limits[0], auto=autoscale) + ax.set_ylim3d(*limits[1], auto=autoscale) + ax.set_zlim3d(*limits[2], auto=autoscale) + + +class _FramePacer: + """Coalesce GUI draws and submit the newest view near a 60 Hz cadence.""" + + _interval = 1 / 60 + + def __init__(self, canvas, is_active): + self.canvas = canvas + self._is_active = is_active + self._draw_pending = False + self._draw_requested = False + self._timer = None + self._idle_draw = None + self._last_frame_started = 0.0 + + def cancel(self): + if self._timer is not None: + self._timer.stop() + self._timer = None + self._idle_draw = None + self._draw_pending = False + self._draw_requested = False + + def _submit(self): + self._timer = None + if not self._is_active() or not self._draw_requested: + return False + draw, self._idle_draw = self._idle_draw, None + self._draw_requested = False + self._draw_pending = True + self._last_frame_started = time.monotonic() + draw() + return False + + def _schedule(self): + delay = max(0.0, self._interval - (time.monotonic() - self._last_frame_started)) + timer = self.canvas.new_timer(interval=max(1, round(1_000 * delay))) + if type(timer)._timer_start is TimerBase._timer_start: + return False + timer.single_shot = True + timer.add_callback(self._submit) + self._timer = timer + timer.start() + return True + + def request(self, draw): + if not self._is_active(): + return False + self._idle_draw = draw + self._draw_requested = True + if self._draw_pending or self._timer is not None: + return True + return self._schedule() + + def acknowledge(self): + if not self._is_active() or not self._draw_pending: + return + self._draw_pending = False + if self._draw_requested and self._timer is None: + self._schedule() + + +class _NavigationInteractionManager: + """Temporarily simplify dense scenes during interactive navigation.""" + + _line_limit = 2_000 + _scatter_limit = 2_000 + + def __init__(self, canvas, figure, selective): + self.canvas = canvas + self.figure = figure + self.selective = selective + self._state = None + self._building_state = None + self._closed = False + self._pacer = _FramePacer(canvas, self._is_active) + # FigureCanvasBase invokes Figure.set_canvas() before assigning its own + # ``figure`` attribute, so the public canvas connector is not available + # during this constructor. Keep this compatibility detail isolated here. + callbacks = figure._canvas_callbacks + self._press_cid = callbacks.connect("button_press_event", self._on_press) + self._release_cid = callbacks.connect("button_release_event", self._on_release) + self._draw_cid = callbacks.connect("draw_event", self._on_draw) + self._close_cid = callbacks.connect("close_event", self._on_close) + + def _is_active(self): + return not self._closed and self._state is not None + + @staticmethod + def _is_three_axes(ax): + return getattr(ax, "_name", None) == "three" + + @staticmethod + def _subset_indices(arrays, limit): + size = min(len(array) for array in arrays) + if size <= limit: + return None + indices = list(np.linspace(0, size - 1, limit, dtype=int)) + for array in arrays: + values = np.asanyarray(array) + if values.dtype.kind not in "biufc": + continue + try: + indices.extend((int(np.nanargmin(values)), int(np.nanargmax(values)))) + except ValueError: + pass + return np.unique(np.clip(indices, 0, size - 1)) + + @staticmethod + def _shared_view_axes(ax): + grouper = getattr(ax, "_shared_axes", {}).get("view") + if grouper is None: + return (ax,) + return tuple( + item + for item in grouper.get_siblings(ax) + if getattr(item, "_name", None) == "three" + ) + + @staticmethod + def _shared_two_axes(ax): + axes = {ax} + for grouper in (ax.get_shared_x_axes(), ax.get_shared_y_axes()): + axes.update(grouper.get_siblings(ax)) + return tuple( + item + for item in ax.figure.axes + if item in axes + and getattr(item, "_name", None) in ("cartesian", "cartopy", "basemap") + ) + + @staticmethod + def _simplify_locator(axis, state): + original_major = axis.get_major_locator() + original_minor = axis.get_minor_locator() + locator_state = _LocatorPreviewState( + axis, + original_major, + original_minor, + original_major, + original_minor, + ) + state.locators.append(locator_state) + axis.set_minor_locator(NullLocator()) + locator_state.preview_minor = axis.get_minor_locator() + get_converter = getattr(axis, "get_converter", None) + converter = get_converter() if get_converter is not None else axis.converter + if axis.get_scale() == "linear" and converter is None: + axis.set_major_locator(MaxNLocator(nbins=3, min_n_ticks=3)) + locator_state.preview_major = axis.get_major_locator() + + def _simplify_line(self, line, dimensions, state): + try: + if dimensions == "3d": + original = tuple(np.asanyarray(array) for array in line.get_data_3d()) + else: + original = ( + np.asanyarray(line.get_xdata(orig=True)), + np.asanyarray(line.get_ydata(orig=True)), + ) + indices = self._subset_indices(original, self._line_limit) + except (IndexError, TypeError, ValueError): + return + if indices is None: + return + preview = tuple(array[indices] for array in original) + line_state = _LinePreviewState(line, original, preview, dimensions) + state.lines.append(line_state) + if dimensions == "3d": + line.set_data_3d(*preview) + else: + line.set_data(*preview) + + def _simplify_scatter(self, artist, arrays, indices, names, state): + original = { + name: getattr(artist, name) for name in names if hasattr(artist, name) + } + preview = dict(original) + offset_name = "_offsets3d" if len(arrays) == 3 else "_offsets" + preview[offset_name] = ( + tuple(array[indices] for array in arrays) + if len(arrays) == 3 + else np.column_stack(tuple(array[indices] for array in arrays)) + ) + for name in ( + "_sizes", + "_sizes3d", + "_linewidths", + "_linewidths3d", + "_facecolors", + "_edgecolors", + ): + values = original.get(name) + if values is None: + continue + values = np.asanyarray(values) + if values.ndim and len(values) == len(arrays[0]): + preview[name] = values[indices] + if len(arrays) == 3: + preview["_depthshade"] = False + preview["_offset_zordered"] = None + preview["_z_markers_idx"] = slice(None) + scatter_state = _ScatterPreviewState(artist, original, preview) + state.scatters.append(scatter_state) + for name, value in preview.items(): + setattr(artist, name, value) + artist.stale = True + + def _simplify_three_axes(self, ax): + from mpl_toolkits.mplot3d.art3d import Path3DCollection + + state = _AxesPreviewState(ax) + self._building_state = state + state.grid_marker = getattr(ax, "_ultraplot_navigation_hide_grid", _MISSING) + ax._ultraplot_navigation_hide_grid = True + for axis in ax._axis_map.values(): + self._simplify_locator(axis, state) + for line in ax.lines: + if hasattr(line, "get_data_3d") and hasattr(line, "set_data_3d"): + self._simplify_line(line, "3d", state) + + for artist in tuple(ax.collections): + if hasattr(artist, "_ultraplot_lod_recipe"): + if not artist.get_visible(): + continue + proxy = _resolve_surface_proxy(artist) + if proxy is None: + continue + attached = proxy.axes is ax + surface_state = _SurfacePreviewState( + artist, + proxy, + ax, + attached, + proxy.get_visible(), + getattr(artist, "_ultraplot_navigation_hidden", _MISSING), + ) + state.surfaces.append(surface_state) + if not attached: + ax.add_collection(proxy, autolim=False) + artist._ultraplot_navigation_hidden = True + proxy.set_visible(True) + continue + if not isinstance(artist, Path3DCollection): + continue + offsets = getattr(artist, "_offsets3d", None) + if offsets is None: + continue + arrays = tuple(np.asanyarray(array) for array in offsets) + indices = self._subset_indices(arrays, self._scatter_limit) + if indices is None: + continue + names = ( + "_offsets3d", + "_sizes", + "_sizes3d", + "_linewidths", + "_linewidths3d", + "_facecolors", + "_edgecolors", + "_depthshade", + "_offset_zordered", + "_z_markers_idx", + ) + self._simplify_scatter(artist, arrays, indices, names, state) + ax.stale = True + self._building_state = None + return state + + def _simplify_two_axes(self, ax): + state = _AxesPreviewState(ax) + self._building_state = state + for axis in (ax.xaxis, ax.yaxis): + self._simplify_locator(axis, state) + for line in ax.lines: + self._simplify_line(line, "2d", state) + for artist in ax.collections: + if not isinstance(artist, mcollections.PathCollection): + continue + offsets = np.asanyarray(artist.get_offsets()) + if offsets.ndim != 2 or offsets.shape[1] != 2: + continue + arrays = (offsets[:, 0], offsets[:, 1]) + indices = self._subset_indices(arrays, self._scatter_limit) + if indices is None: + continue + names = ("_offsets", "_sizes", "_linewidths", "_facecolors", "_edgecolors") + self._simplify_scatter(artist, arrays, indices, names, state) + ax.stale = True + self._building_state = None + return state + + def activate(self, ax): + """Activate preview quality for the navigated axes and shared siblings.""" + is_three = self._is_three_axes(ax) + is_two = getattr(ax, "_name", None) in ("cartesian", "cartopy", "basemap") + if ( + self._closed + or self._state is not None + or not _preview_enabled() + or not (is_three or is_two) + ): + return False + states = [] + try: + axes = self._shared_view_axes(ax) if is_three else self._shared_two_axes(ax) + simplify = ( + self._simplify_three_axes if is_three else self._simplify_two_axes + ) + for item in axes: + states.append(simplify(item)) + except Exception: + if self._building_state is not None: + states.append(self._building_state) + for state in reversed(states): + state.restore() + self._building_state = None + return False + self._state = states + self.selective.invalidate() + return True + + def deactivate(self, *, redraw=True): + """Restore exact artists and formatting after interactive navigation.""" + if self._state is None: + return False + self._pacer.cancel() + states, self._state = self._state, None + for state in reversed(states): + state.restore() + self.selective.invalidate() + if redraw: + self.canvas.draw_idle() + return True + + def request_draw(self, draw): + if self._state is not None and not _preview_enabled(): + self.deactivate(redraw=False) + return False + return self._pacer.request(draw) + + @contextmanager + def full_quality_context(self): + active_ax = None if self._state is None else self._state[0].ax + if active_ax is not None: + self.deactivate(redraw=False) + try: + yield + finally: + if active_ax is not None and not self._closed: + self.activate(active_ax) + + def _on_press(self, event): + ax = event.inaxes + if self._is_three_axes(ax): + buttons = (*ax._rotate_btn, *ax._pan_btn, *ax._zoom_btn) + if event.button in buttons: + self.activate(ax) + elif getattr(ax, "_name", None) in ("cartesian", "cartopy", "basemap") and str( + ax.get_navigate_mode() + ).upper().endswith("PAN"): + self.activate(ax) + + def _on_release(self, event): + self.deactivate(redraw=True) + + def _on_draw(self, event): + self._pacer.acknowledge() + + def _on_close(self, event): + self.close() + + def close(self): + if self._closed: + return + self.deactivate(redraw=False) + callbacks = self.figure._canvas_callbacks + callbacks.disconnect(self._press_cid) + callbacks.disconnect(self._release_cid) + callbacks.disconnect(self._draw_cid) + callbacks.disconnect(self._close_cid) + self._closed = True diff --git a/ultraplot/_layout.py b/ultraplot/_layout.py new file mode 100644 index 000000000..936354245 --- /dev/null +++ b/ultraplot/_layout.py @@ -0,0 +1,592 @@ +""" +Private helpers for reducing repeated layout work. + +There are two cache lifetimes: + +- Tick computations are reused only within one layout-and-render transaction. +- Relative axes outsets persist across transactions until their dependencies + change. + +``_LayoutTransaction`` owns both lifecycles. Temporary matplotlib method +overrides are restored when the transaction exits, including after exceptions. +""" + +from __future__ import annotations + +from collections import OrderedDict +from contextlib import ExitStack +from dataclasses import dataclass + +import matplotlib.transforms as mtransforms +import numpy as np + +_MISSING = object() + + +def _is_internal_ticker(obj): + """Return whether a locator or formatter is safe for draw-local reuse.""" + module = type(obj).__module__ + return ( + module == "matplotlib" + or module.startswith("matplotlib.") + or module == "ultraplot.ticker" + ) + + +def _interval_key(values): + """Convert a numerical interval to an immutable exact cache key.""" + return tuple(np.asarray(values).reshape(-1).tolist()) + + +@dataclass(frozen=True) +class _AxisTickState: + """State that can affect ``Axis._update_ticks`` within one canvas draw.""" + + view_interval: tuple + data_interval: tuple + axes_size: tuple + dpi: float + scale: str + major_locator: int + major_formatter: int + minor_locator: int + minor_formatter: int + converter: int + units: int + + +@dataclass +class _AxisTickResult: + """Cached ticks and formatter locations for one axis state.""" + + ticks: list + major_locs: np.ndarray | tuple + minor_locs: np.ndarray | tuple + + +class _AxisTickCache: + """ + Cache repeated tick updates during one layout-and-render transaction. + + Tight bounding-box calculation and the final axes draw repeatedly call + ``Axis._update_ticks`` with identical state. The method runs locators, + formatters, tick positioning, and visibility filtering each time. This + manager replaces the method on individual axes for the duration of a + canvas draw and restores the original instance state afterwards. + + Custom third-party locators and formatters conservatively bypass the + cache because they may rely on repeated side effects. + """ + + _MAX_STATES_PER_AXIS = 4 + + def __init__(self, figure): + self.figure = figure + self._cache = {} + self._patched = {} + self.hits = 0 + self.misses = 0 + self.bypasses = 0 + self.evictions = 0 + + def __enter__(self): + self.figure._axis_tick_cache = self + self.refresh() + return self + + def __exit__(self, *args): + for axis, previous in self._patched.items(): + if previous is _MISSING: + axis.__dict__.pop("_update_ticks", None) + else: + axis.__dict__["_update_ticks"] = previous + self._patched.clear() + self.figure.__dict__.pop("_axis_tick_cache", None) + self.figure._last_axis_tick_cache_stats = { + "hits": self.hits, + "misses": self.misses, + "bypasses": self.bypasses, + "evictions": self.evictions, + } + + def refresh(self): + """Patch axes added while queued guides and panels are materialized.""" + for axes in self.figure._iter_axes(hidden=True, children=True): + axis_map = getattr(axes, "_axis_map", {}) + for axis in axis_map.values(): + if axis is not None and axis not in self._patched: + self._patch(axis) + + def _patch(self, axis): + previous = axis.__dict__.get("_update_ticks", _MISSING) + original = axis._update_ticks + self._patched[axis] = previous + + def _cached_update_ticks(): + if not self._is_cacheable(axis): + self.bypasses += 1 + return original() + state = self._get_state(axis) + states = self._cache.setdefault(axis, OrderedDict()) + result = states.pop(state, None) + if result is not None: + self.hits += 1 + states[state] = result + self._restore_formatter_locs(axis, result) + return result.ticks + self.misses += 1 + ticks = original() + result = _AxisTickResult( + ticks=ticks, + major_locs=self._copy_formatter_locs(axis.major.formatter), + minor_locs=self._copy_formatter_locs(axis.minor.formatter), + ) + states[state] = result + if len(states) > self._MAX_STATES_PER_AXIS: + states.popitem(last=False) + self.evictions += 1 + return ticks + + axis._update_ticks = _cached_update_ticks + + @staticmethod + def _is_cacheable(axis): + if getattr(axis.axes, "_name", None) != "cartesian": + return False + ticker_objects = ( + axis.major.locator, + axis.major.formatter, + axis.minor.locator, + axis.minor.formatter, + ) + return all(_is_internal_ticker(obj) for obj in ticker_objects) + + def _get_state(self, axis): + axes = axis.axes + get_converter = getattr(axis, "get_converter", None) + converter = get_converter() if get_converter is not None else None + get_units = getattr(axis, "get_units", None) + units = get_units() if get_units is not None else None + return _AxisTickState( + view_interval=_interval_key(axis.get_view_interval()), + data_interval=_interval_key(axis.get_data_interval()), + axes_size=(axes.bbox.width, axes.bbox.height), + dpi=float(self.figure.dpi), + scale=axis.get_scale(), + major_locator=id(axis.major.locator), + major_formatter=id(axis.major.formatter), + minor_locator=id(axis.minor.locator), + minor_formatter=id(axis.minor.formatter), + converter=id(converter), + units=id(units), + ) + + @staticmethod + def _copy_formatter_locs(formatter): + locs = getattr(formatter, "locs", ()) + try: + return np.array(locs, copy=True) + except Exception: + return tuple(locs) + + @staticmethod + def _restore_formatter_locs(axis, result): + for formatter, locs in ( + (axis.major.formatter, result.major_locs), + (axis.minor.formatter, result.minor_locs), + ): + setter = getattr(formatter, "set_locs", None) + if setter is not None: + setter(locs) + + +@dataclass(frozen=True) +class _AxesExtentState: + """Geometry that can alter outsets relative to an axes rectangle.""" + + bbox_size: tuple + bbox_position: tuple + dpi: float + axis_states: tuple + decorations: tuple + subset_titles: tuple + + +@dataclass +class _AxesExtentRecord: + """One relative tight-bbox measurement.""" + + version: int + state: _AxesExtentState + outsets: tuple + + +class _LayoutExtentStore: + """ + Persist relative axes outsets and dependency versions between layouts. + + Absolute axes positions are solver outputs. Tick labels, axis labels, and + titles are better represented as four overhangs around those positions. + Standard Cartesian axes can therefore move without repeating renderer text + measurements. Position-sensitive axes and extra artists automatically add + the absolute origin to their state key. + """ + + def __init__(self, figure): + self.figure = figure + self._records = {} + self._versions = {} + self._axes = () + self._active = False + self.hits = 0 + self.misses = 0 + + def __enter__(self): + self._active = True + self.hits = 0 + self.misses = 0 + axes = self.refresh() + for axis in axes: + if axis.stale: + self._versions[axis] += 1 + return self + + def refresh(self): + """Synchronize axes added by queued guide and panel creation.""" + axes = tuple(self.figure.axes) + if self._axes != axes: + self._axes = axes + current = set(axes) + self._records = { + key: value for key, value in self._records.items() if key[0] in current + } + self._versions = { + axis: version + for axis, version in self._versions.items() + if axis in current + } + for axis in axes: + if axis not in self._versions: + self._versions[axis] = 1 + return axes + + def __exit__(self, *args): + self._rebase_records() + self._active = False + self.figure._last_layout_extent_stats = { + "hits": self.hits, + "misses": self.misses, + } + + def get_tightbbox( + self, + axes, + renderer, + *, + include_subset_titles=True, + use_cache=True, + ): + """Return an exact or reconstructed tight bbox in display units.""" + if not self._active or not use_cache or not self._is_cacheable_axes(axes): + return self._measure_tightbbox(axes, renderer, include_subset_titles) + + version = self._versions.setdefault(axes, 1) + state = self._get_state(axes, include_subset_titles) + cache_key = (axes, bool(include_subset_titles)) + record = self._records.get(cache_key) + if record is not None and record.version == version and record.state == state: + self.hits += 1 + bbox = self._bbox_from_outsets(axes.bbox, record.outsets) + axes._tight_bbox = bbox + else: + self.misses += 1 + bbox = self._measure_tightbbox(axes, renderer, include_subset_titles) + if bbox is not None: + self._records[cache_key] = _AxesExtentRecord( + version=version, + state=self._get_state(axes, include_subset_titles), + outsets=self._get_outsets(axes.bbox, bbox), + ) + return bbox + + def _get_state(self, axes, include_subset_titles=True): + bbox = axes.bbox + position_sensitive = self._is_position_sensitive(axes) + axis_states = [] + for axis in getattr(axes, "_axis_map", {}).values(): + if axis is None: + continue + get_converter = getattr(axis, "get_converter", None) + converter = get_converter() if get_converter is not None else None + get_units = getattr(axis, "get_units", None) + units = get_units() if get_units is not None else None + axis_states.append( + ( + _interval_key(axis.get_view_interval()), + _interval_key(axis.get_data_interval()), + axis.get_scale(), + id(axis.major.locator), + id(axis.major.formatter), + id(axis.minor.locator), + id(axis.minor.formatter), + id(converter), + id(units), + _interval_key(getattr(axis.major.formatter, "locs", ())), + _interval_key(getattr(axis.minor.formatter, "locs", ())), + ) + ) + return _AxesExtentState( + bbox_size=(bbox.width, bbox.height), + bbox_position=(bbox.x0, bbox.y0) if position_sensitive else (), + dpi=float(self.figure.dpi), + axis_states=tuple(axis_states), + decorations=self._get_decoration_state(axes), + subset_titles=self._get_subset_title_state(axes, include_subset_titles), + ) + + def _rebase_records(self): + """ + Rebase reusable outsets onto final post-render axes dimensions. + + UltraLayout may make a small solver adjustment after measuring an axes. + The final render updates locator/formatter locations for that geometry. + If those locations and every non-size dependency are unchanged, the + relative outsets remain valid for the next layout transaction. + """ + for (axes, include_subset_titles), record in self._records.items(): + if axes not in self._versions: + continue + if record.version != self._versions[axes]: + continue + if self._is_position_sensitive(axes): + continue + state = self._get_state(axes, include_subset_titles) + if ( + record.state.dpi == state.dpi + and record.state.axis_states == state.axis_states + and record.state.decorations == state.decorations + and record.state.subset_titles == state.subset_titles + ): + record.state = state + + def _get_retained_bboxes(self, axes): + """Return exact cached display bboxes for retained axes drawing.""" + self._rebase_records() + bboxes = {} + for axis in axes: + record = self._records.get((axis, True)) + if ( + record is not None + and record.version == self._versions.get(axis) + and record.state == self._get_state(axis, True) + ): + bboxes[axis] = self._bbox_from_outsets(axis.bbox, record.outsets) + return bboxes + + @staticmethod + def _get_decoration_state(axes): + texts = [] + core_texts = ( + getattr(axes, "title", None), + getattr(axes, "_left_title", None), + getattr(axes, "_right_title", None), + getattr(getattr(axes, "xaxis", None), "label", None), + getattr(getattr(axes, "yaxis", None), "label", None), + ) + extra_texts = tuple( + text for text in getattr(axes, "texts", ()) if text.get_text() + ) + for text in (*core_texts, *extra_texts): + if text is None: + continue + texts.append( + ( + id(text), + text.get_visible(), + text.get_in_layout(), + text.get_text(), + text.get_rotation(), + hash(text.get_fontproperties()), + ) + ) + legend = getattr(axes, "legend_", None) + if legend is None: + legend_state = () + else: + legend_state = ( + id(legend), + legend.get_visible(), + legend.get_in_layout(), + id(getattr(legend, "_bbox_to_anchor", None)), + tuple(text.get_text() for text in legend.get_texts()), + ) + extra_ids = tuple( + id(artist) + for artists in ( + getattr(axes, "artists", ()), + getattr(axes, "tables", ()), + ) + for artist in artists + if artist.get_visible() and artist.get_in_layout() + ) + return tuple(texts), legend_state, extra_ids + + def _get_subset_title_state(self, axes, include_subset_titles): + if not include_subset_titles: + return () + figure = self.figure + groups = getattr(figure, "_subset_title_dict", {}) + state = [] + parent = getattr(axes, "_panel_parent", None) or axes + for group in groups.values(): + group_axes = tuple( + getattr(item, "_panel_parent", None) or item + for item in group["axes"] + if item is not None and item.figure is figure + ) + if parent not in group_axes: + continue + artist = group["artist"] + state.append( + ( + tuple(id(item) for item in group_axes), + artist.get_visible(), + artist.get_text(), + artist.get_position(), + artist.get_ha(), + artist.get_va(), + artist.get_rotation(), + hash(artist.get_fontproperties()), + group.get("pad"), + group.get("y"), + ) + ) + return tuple(state) + + @staticmethod + def _is_position_sensitive(axes): + if getattr(axes, "_name", None) != "cartesian": + return True + legend = getattr(axes, "legend_", None) + if legend is not None and getattr(legend, "_bbox_to_anchor", None) is not None: + return True + collections = ( + getattr(axes, "artists", ()), + getattr(axes, "tables", ()), + ) + if any( + artist.get_visible() and artist.get_in_layout() + for artists in collections + for artist in artists + ): + return True + relative_texts = set(getattr(axes, "_title_dict", {}).values()) + for text in getattr(axes, "texts", ()): + if not ( + text.get_visible() and text.get_in_layout() and bool(text.get_text()) + ): + continue + if text in relative_texts: + continue + transform = text.get_transform() + if not ( + transform.contains_branch(axes.transAxes) + or transform.contains_branch(axes.transData) + ): + return True + return False + + @staticmethod + def _is_cacheable_axes(axes): + if getattr(axes, "_name", None) != "cartesian": + return False + return all( + axis is None + or all( + _is_internal_ticker(obj) + for obj in ( + axis.major.locator, + axis.major.formatter, + axis.minor.locator, + axis.minor.formatter, + ) + ) + for axis in getattr(axes, "_axis_map", {}).values() + ) + + @staticmethod + def _get_outsets(axes_bbox, tight_bbox): + return ( + axes_bbox.xmin - tight_bbox.xmin, + tight_bbox.xmax - axes_bbox.xmax, + axes_bbox.ymin - tight_bbox.ymin, + tight_bbox.ymax - axes_bbox.ymax, + ) + + @staticmethod + def _bbox_from_outsets(axes_bbox, outsets): + left, right, bottom, top = outsets + return mtransforms.Bbox.from_extents( + axes_bbox.xmin - left, + axes_bbox.ymin - bottom, + axes_bbox.xmax + right, + axes_bbox.ymax + top, + ) + + @staticmethod + def _measure_tightbbox(axes, renderer, include_subset_titles): + try: + return axes.get_tightbbox( + renderer, include_subset_titles=include_subset_titles + ) + except TypeError: + return axes.get_tightbbox(renderer) + + +class _LayoutTransaction: + """ + Own temporary and persistent caches for one dirty canvas draw. + + Figure code only needs to know whether a transaction is active. Cache setup, + dynamic-axes refresh, and exception-safe cleanup stay private to this object. + """ + + def __init__(self, figure, *, cache_ticks=True, cache_extents=True): + self.figure = figure + self.ticks = _AxisTickCache(figure) if cache_ticks else None + if cache_extents: + extents = getattr(figure, "_layout_extent_store", None) + if extents is None: + extents = figure._layout_extent_store = _LayoutExtentStore(figure) + self.extents = extents + else: + self.extents = None + self._stack = None + + def __enter__(self): + stack = self._stack = ExitStack() + self.figure._layout_transaction = self + try: + if self.ticks is not None: + stack.enter_context(self.ticks) + if self.extents is not None: + stack.enter_context(self.extents) + except Exception: + self.figure.__dict__.pop("_layout_transaction", None) + stack.close() + raise + return self + + def __exit__(self, *args): + try: + return self._stack.__exit__(*args) + finally: + self._stack = None + self.figure.__dict__.pop("_layout_transaction", None) + + def refresh(self): + """Synchronize caches after queued guides create axes or panels.""" + if self.ticks is not None: + self.ticks.refresh() + if self.extents is not None: + self.extents.refresh() diff --git a/ultraplot/_subplots.py b/ultraplot/_subplots.py index b0594973b..9e75a4ed3 100644 --- a/ultraplot/_subplots.py +++ b/ultraplot/_subplots.py @@ -153,7 +153,7 @@ def add_subplot(self, *args, **kwargs): The driver function for adding single subplots. """ fig = self.figure - fig._layout_dirty = True + fig._invalidate_layout() kwargs = self.parse_proj(**kwargs) args = args or (1, 1, 1) diff --git a/ultraplot/axes/_formatting.py b/ultraplot/axes/_formatting.py index 655fee713..f0489c1da 100644 --- a/ultraplot/axes/_formatting.py +++ b/ultraplot/axes/_formatting.py @@ -50,6 +50,22 @@ "labelweight": ("{axis}labelweight", "labelweight"), } +# These fields change only how existing geometry is painted. They do not change +# tick locations, text metrics, padding, or another layout input. Keep this list +# deliberately conservative: unknown fields must continue to invalidate layout. +_PAINT_ONLY_AXIS_STYLE_FIELDS = { + "color", + "linewidth", + "grid", + "gridminor", + "gridcolor", + "tickcolor", + "tickwidth", + "tickwidthratio", + "ticklabelcolor", + "labelcolor", +} + def _dedupe(items): return tuple(dict.fromkeys(items)) @@ -62,6 +78,13 @@ def _dedupe(items): if "{axis}" not in name ) +PAINT_ONLY_AXIS_FORMAT_KEYS = frozenset( + name.format(axis=axis) + for field in _PAINT_ONLY_AXIS_STYLE_FIELDS + for name in _AXIS_STYLE_FIELD_TEMPLATES[field] + for axis in ("x", "y") +) + CARTESIAN_PARENT_FILTER_KEYS = GENERIC_AXIS_FORMAT_KEYS + ( "label_kw", @@ -72,6 +95,26 @@ def _dedupe(items): ) +def axis_format_requires_layout(keys): + """ + Return whether explicit Cartesian formatting keys can affect layout. + + Unknown keys are treated as layout-affecting so new formatting options + remain correct until they are deliberately classified. + """ + keys = set(keys) + keys.difference_update( + { + "_explicit_format_keys", + "rc_kw", + "rc_mode", + "skip_axes", + "skip_figure", + } + ) + return bool(keys - PAINT_ONLY_AXIS_FORMAT_KEYS) + + def get_axis_style_fields(axis): """ Return the parameter names used to store explicit style overrides. diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 735e99bee..d0af2a91a 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3335,8 +3335,12 @@ def format( ultraplot.gridspec.SubplotGrid.format ultraplot.config.Configurator.context """ - if self.figure is not None: - self.figure._layout_dirty = True + if self.figure is not None and getattr(self, "_format_layout_required", True): + invalidate = getattr(self.figure, "_invalidate_layout", None) + if invalidate is None: + self.figure._layout_dirty = True + else: + invalidate() skip_figure = kwargs.pop("skip_figure", False) # internal keyword arg params = _pop_params(kwargs, self.figure._format_signature) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 677f81989..bbe6a541c 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -32,6 +32,7 @@ from ..utils import units from ._formatting import ( CARTESIAN_PARENT_FILTER_KEYS, + axis_format_requires_layout, get_axis_style_fields, pop_axis_format_kwargs, ) @@ -1697,8 +1698,7 @@ def format( or `datetime.datetime` array as the x or y axis coordinate, the axis ticks and tick labels will be automatically formatted as dates. """ - explicit_format_keys = set(kwargs) - explicit_format_keys.update(kwargs.pop("_explicit_format_keys", ())) + explicit_format_keys = set(kwargs.pop("_explicit_format_keys", ())) signature_axis_kwargs, generic_axis_kwargs = pop_axis_format_kwargs( kwargs, self._format_signatures[CartesianAxes] ) @@ -1750,7 +1750,22 @@ def format( if aspect is not None: self.set_aspect(aspect) - super().format(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) + + # Base Axes.format() historically invalidated layout on every call. Let + # clearly paint-only Cartesian updates bypass that invalidation while + # remaining conservative for rc changes and unknown formatting keys. + sentinel = object() + previous = getattr(self, "_format_layout_required", sentinel) + self._format_layout_required = bool(rc_kw) or axis_format_requires_layout( + explicit_format_keys + ) + try: + super().format(rc_kw=rc_kw, rc_mode=rc_mode, **base_kwargs) + finally: + if previous is sentinel: + del self._format_layout_required + else: + self._format_layout_required = previous @docstring._snippet_manager def altx(self, **kwargs): diff --git a/ultraplot/axes/three.py b/ultraplot/axes/three.py index cf0e314e9..f6e2228c5 100644 --- a/ultraplot/axes/three.py +++ b/ultraplot/axes/three.py @@ -41,3 +41,33 @@ def graph(self, *args, **kwargs): from .plot import PlotAxes return PlotAxes.graph(self, *args, **kwargs) + + def draw(self, renderer): + """Draw while suppressing exact surfaces replaced by navigation proxies.""" + from .._interaction import _prepare_surface_preview + + hidden = tuple( + artist + for artist in self.collections + if getattr(artist, "_ultraplot_navigation_hidden", False) + and _prepare_surface_preview(artist) + ) + draw_grid = self._draw_grid + try: + for artist in hidden: + artist.set_visible(False) + if getattr(self, "_ultraplot_navigation_hide_grid", False): + self._draw_grid = False + super().draw(renderer) + finally: + self._draw_grid = draw_grid + for artist in hidden: + artist.set_visible(True) + + def plot_surface(self, X, Y, Z, *args, **kwargs): + """Plot a surface and register a lazy private interaction preview.""" + surface = super().plot_surface(X, Y, Z, *args, **kwargs) + from .._interaction import _register_surface_preview + + _register_surface_preview(surface, X, Y, Z, args, kwargs) + return surface diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 60cf99014..2014185e6 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -6,6 +6,7 @@ import functools import inspect import os +from contextlib import ExitStack try: from typing import Any, Iterable, List, Optional, Tuple, Union @@ -24,7 +25,7 @@ from typing_extensions import override from . import axes as paxes -from .axes._formatting import pop_axis_format_kwargs +from .axes._formatting import axis_format_requires_layout, pop_axis_format_kwargs from . import constructor from . import gridspec as pgridspec from . import legend as plegend @@ -41,6 +42,7 @@ labels, warnings, ) +from ._layout import _LayoutTransaction from ._subplots import SubplotManager from .utils import _Crawler, units @@ -49,6 +51,11 @@ ] +def _any_not_none(*values): + """Return whether at least one value is not ``None``.""" + return any(value is not None for value in values) + + # Preset figure widths or sizes based on academic journal recommendations # NOTE: Please feel free to add to this! JOURNAL_SIZES = { @@ -617,6 +624,7 @@ def _canvas_preprocess(self, *args, **kwargs): skip_autolayout = getattr(fig, "_skip_autolayout", False) layout_dirty = getattr(fig, "_layout_dirty", False) + needs_layout = not getattr(fig, "_layout_initialized", False) or layout_dirty saving_frame_count = getattr(fig, "_saving_frame_count", 0) lock_tight_during_save = ( getattr(self, "_is_saving", False) @@ -638,7 +646,16 @@ def _canvas_preprocess(self, *args, **kwargs): ctx1 = fig._context_adjusting(cache=cache) ctx2 = fig._context_authorized() # skip backend set_constrained_layout() ctx3 = rc.context(fig._render_context) # draw with figure-specific setting - with ctx1, ctx2, ctx3: + ctx4 = ( + _LayoutTransaction( + fig, + cache_ticks=not getattr(fig, "_disable_axis_tick_cache", False), + cache_extents=not getattr(fig, "_disable_layout_extent_cache", False), + ) + if needs_layout + else context._empty_context() + ) + with ctx1, ctx2, ctx3, ctx4: needs_post_layout = False if not fig._layout_initialized or layout_dirty: fig.auto_layout(tight=False if lock_tight_during_save else None) @@ -647,10 +664,26 @@ def _canvas_preprocess(self, *args, **kwargs): needs_post_layout = ( not lock_tight_during_save and _needs_post_tight_layout(fig) ) - result = func(self, *args, **kwargs) + selective = getattr(fig, "_selective_draw_manager", None) + if ( + method != "print_figure" + and selective is not None + and not needs_layout + and selective.draw_if_possible() + ): + return None + + def _draw_context(): + if method != "print_figure" and selective is not None: + return selective.full_draw_context() + return context._empty_context() + + with _draw_context(): + result = func(self, *args, **kwargs) if needs_post_layout: fig.auto_layout() - result = func(self, *args, **kwargs) + with _draw_context(): + result = func(self, *args, **kwargs) if method == "print_figure" and getattr(self, "_is_saving", False): fig._saving_frame_count = saving_frame_count + 1 elif not getattr(self, "_is_saving", False): @@ -1060,6 +1093,13 @@ def _init_super_labels(self): d["bottom"] = rc["bottomlabel.sharedpad"] d["top"] = rc["toplabel.sharedpad"] + def _invalidate_layout(self, *, reset=False): + """Mark automatic layout stale, optionally discarding persistent state.""" + self._layout_dirty = True + if reset: + self._layout_initialized = False + self.__dict__.pop("_layout_extent_store", None) + @_clear_border_cache def clear(self, keep_observers=False): """ @@ -1081,8 +1121,7 @@ def clear(self, keep_observers=False): super().clear(keep_observers=keep_observers) self._subplots.reset() self._panel_dict = {"left": [], "right": [], "bottom": [], "top": []} - self._layout_initialized = False - self._layout_dirty = True + self._invalidate_layout(reset=True) self._init_super_labels() @override @@ -1111,6 +1150,28 @@ def draw_without_rendering(self): if self.dpi != dpi: mfigure.Figure.set_dpi(self, dpi) + def _blit_manager(self, *artists, bbox=None): + """ + Return a manager for efficient updates of changing artists. + + Parameters + ---------- + *artists : `~matplotlib.artist.Artist` + Artists that will change between updates. + bbox : `~matplotlib.transforms.Bbox` or object with a ``bbox`` attribute, optional + Region to cache and blit. By default, the union of the artists' + axes bounding boxes is used. + + Returns + ------- + `~ultraplot._animation._BlitManager` + Manager that restores the cached static background and redraws only + the supplied artists. + """ + from ._animation import _BlitManager + + return _BlitManager(self.canvas, artists, bbox=bbox) + def _is_auto_share_mode(self, which: str) -> bool: """Return whether a given axis uses auto-share mode.""" if which not in ("x", "y"): @@ -1943,8 +2004,11 @@ def _get_offset_coord( if label.get_text() and not label.get_text().strip(): label.set_visible(False) if isinstance(obj, paxes.Axes): - bbox = obj.get_tightbbox( - renderer, include_subset_titles=include_subset_titles + bbox = self._get_layout_axes_bbox( + obj, + renderer, + include_subset_titles=include_subset_titles, + use_cache=not exclude_spanning_axis_labels, ) else: bbox = obj.get_tightbbox(renderer) # cannot use cached bbox @@ -1962,6 +2026,74 @@ def _get_offset_coord( pad = pad / width if side in ("left", "right") else pad / height return min(cs) - pad if side in ("left", "bottom") else max(cs) + pad + def _get_layout_axes_bbox( + self, + axes, + renderer, + *, + include_subset_titles=True, + use_cache=True, + ): + """Return an axes bbox using the active relative-outset store.""" + transaction = getattr(self, "_layout_transaction", None) + store = transaction.extents if transaction is not None else None + if store is not None: + return store.get_tightbbox( + axes, + renderer, + include_subset_titles=include_subset_titles, + use_cache=use_cache, + ) + try: + return axes.get_tightbbox( + renderer, include_subset_titles=include_subset_titles + ) + except TypeError: + return axes.get_tightbbox(renderer) + + def _get_layout_tightbbox(self, renderer): + """ + Return the figure tight bbox while reusing relative axes outsets. + + This mirrors matplotlib's ``Figure.get_tightbbox`` but routes axes + measurements through the active relative-outset store. + """ + artists = [ + artist + for artist in self.get_children() + if ( + artist not in self.axes + and artist.get_visible() + and artist.get_in_layout() + ) + ] + bboxes = [] + for artist in artists: + bbox = artist.get_tightbbox(renderer) + if bbox is not None: + bboxes.append(bbox) + + for axes in self.axes: + if not axes.get_visible(): + continue + bbox = self._get_layout_axes_bbox(axes, renderer) + if bbox is not None: + bboxes.append(bbox) + + bboxes = [ + bbox + for bbox in bboxes + if ( + np.isfinite(bbox.width) + and np.isfinite(bbox.height) + and (bbox.width != 0 or bbox.height != 0) + ) + ] + if not bboxes: + return self.bbox_inches + bbox = mtransforms.Bbox.union(bboxes) + return mtransforms.TransformedBbox(bbox, self.dpi_scale_trans.inverted()) + def _get_renderer(self): """ Get a renderer at all costs. See matplotlib's tight_layout.py. @@ -2145,7 +2277,7 @@ def _add_figure_panel( """ Add a figure panel. """ - self._layout_dirty = True + self._invalidate_layout() # Interpret args and enforce sensible keyword args side = _translate_loc(side, "panel", default="right") if side in ("left", "right"): @@ -3338,6 +3470,9 @@ def _align_content(): # noqa: E306 # WARNING: Tried to avoid two figure resizes but made # subsequent tight layout really weird. Have to resize twice. _draw_content() + transaction = getattr(self, "_layout_transaction", None) + if transaction is not None: + transaction.refresh() if not gs: return if aspect: @@ -3414,7 +3549,6 @@ def format( ultraplot.gridspec.SubplotGrid.format ultraplot.config.Configurator.context """ - self._layout_dirty = True # Initiate context block axs = axs or self._iter_subplots() skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg @@ -3425,6 +3559,32 @@ def format( explicit_format_keys.update(signature_axis_kwargs) explicit_format_keys.update(generic_axis_kwargs) rc_kw, rc_mode = _pop_rc(kwargs) + figure_layout_requested = _any_not_none( + figtitle, + suptitle, + suptitle_kw, + llabels, + leftlabels, + leftlabels_kw, + rlabels, + rightlabels, + rightlabels_kw, + blabels, + bottomlabels, + bottomlabels_kw, + tlabels, + toplabels, + toplabels_kw, + rowlabels, + collabels, + includepanels, + ) + if ( + figure_layout_requested + or bool(rc_kw) + or axis_format_requires_layout(explicit_format_keys) + ): + self._invalidate_layout() kwargs.update(signature_axis_kwargs) with rc.context(rc_kw, mode=rc_mode): # Update background patch @@ -3991,9 +4151,21 @@ def savefig(self, filename, **kwargs): # do not want to overwrite the matplotlib docstring. if isinstance(filename, str): filename = os.path.expanduser(filename) - # NOTE: this draw ensures that we are applying ultraplots layout adjustment. It is unclear what changed with ultraplot's history that makes this necessary, but it seems to cause no issues. Future devs, if unnecessary remove this line and test. - self.canvas.draw() - super().savefig(filename, **kwargs) + # Blitting marks managed artists as animated so full interactive draws can + # cache the static background. Temporarily restore their original states + # so savefig includes them in normal z-order. + managers = tuple(getattr(self, "_blit_managers", ())) + with ExitStack() as stack: + selective = getattr(self, "_selective_draw_manager", None) + if selective is not None: + stack.enter_context(selective.save_context()) + for manager in managers: + stack.enter_context(manager._save_context()) + # NOTE: this draw ensures that we are applying ultraplots layout + # adjustment. It is unclear what changed with ultraplot's history that + # makes this necessary, but it seems to cause no issues. + self.canvas.draw() + super().savefig(filename, **kwargs) @docstring._concatenate_inherited def set_canvas(self, canvas): @@ -4022,6 +4194,9 @@ def set_canvas(self, canvas): # around this by forcing additional draw() call in this function before # proceeding with print_figure). Set the canvas and add monkey patches # to the instance-level draw and print_figure methods. + previous = getattr(self, "_selective_draw_manager", None) + if previous is not None: + previous.close() method = "draw" # if getattr(canvas, "_draw", None): # method = "_draw" @@ -4036,10 +4211,19 @@ def _draw_idle(self, *args, **kwargs): fig = self.figure if fig is not None: fig._skip_autolayout = True + selective = getattr(fig, "_selective_draw_manager", None) + preview = getattr(selective, "_navigation_preview", None) + if preview is not None and preview.request_draw( + lambda: orig_draw_idle(self, *args, **kwargs) + ): + return None return orig_draw_idle(self, *args, **kwargs) canvas.draw_idle = _draw_idle.__get__(canvas) super().set_canvas(canvas) + from ._animation import _SelectiveDrawManager + + self._selective_draw_manager = _SelectiveDrawManager(canvas, self) def _is_same_size(self, figsize, eps=None): """ @@ -4106,7 +4290,7 @@ def set_size_inches(self, w, h=None, *, forward=True, internal=False, eps=None): if not samesize: # gridspec positions will resolve differently self.gridspec.update() if not backend and not internal: - self._layout_dirty = True + self._invalidate_layout() def _iter_axes(self, hidden=False, children=False, panels=True): """ diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index eb7fdd59f..89915645e 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1249,7 +1249,7 @@ def _auto_layout_tight(self, renderer): # computed bounding boxes used by _range_tightbbox below. pad = self._outerpad obox = fig.bbox_inches # original bbox - bbox = fig.get_tightbbox(renderer) + bbox = fig._get_layout_tightbbox(renderer) # Calculate new figure margins # NOTE: Negative spaces are common where entire rows/columns of gridspec diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 6493df60d..190cfc0be 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -981,6 +981,13 @@ def _validator_accepts(validator, value): "interpreted by `~ultraplot.utils.units`. Numeric units are points." ) _rc_ultraplot_table = { + # Interactive navigation settings + "navigation.preview": ( + True, + _validate_bool, + "Whether to simplify dense artists and ticks while interactively panning " + "or rotating. Disable for exact rendering during navigation.", + ), # Curved quiver settings "curved_quiver.arrowsize": ( 1.0, diff --git a/ultraplot/tests/test_animation.py b/ultraplot/tests/test_animation.py index 00ee7c007..ad6d228ab 100644 --- a/ultraplot/tests/test_animation.py +++ b/ultraplot/tests/test_animation.py @@ -1,11 +1,18 @@ from unittest.mock import MagicMock +import datetime +import io import matplotlib import numpy as np import pytest +from matplotlib import ticker as mticker from matplotlib.animation import FuncAnimation +from matplotlib.backend_bases import FigureCanvasBase, MouseEvent, TimerBase +from PIL import Image import ultraplot as uplt +from ultraplot._animation import _BlitManager +from ultraplot._layout import _AxisTickCache, _LayoutTransaction def test_auto_layout_not_called_on_every_frame(): @@ -44,6 +51,1246 @@ def test_draw_idle_skips_auto_layout_after_first_draw(): assert fig.auto_layout.call_count == 1 +def test_initial_draw_reuses_tick_updates(): + """ + Layout and render phases should share identical tick computations. + """ + fig, axs = uplt.subplots(nrows=2, ncols=2, share=False) + for ax in axs: + ax.plot([0, 1, 2], [0, 1, 0]) + axs.format(xlabel="Coordinate", ylabel="Response", suptitle="Tick cache") + + fig.canvas.draw() + + stats = fig._last_axis_tick_cache_stats + assert stats["hits"] > 0 + assert stats["misses"] > 0 + assert stats["bypasses"] == 0 + assert stats["evictions"] == 0 + assert "_axis_tick_cache" not in fig.__dict__ + assert "_layout_transaction" not in fig.__dict__ + for ax in fig.axes: + for axis in ax._axis_map.values(): + assert "_update_ticks" not in axis.__dict__ + + +def test_tick_cache_preserves_rendered_pixels(): + """ + Caching tick updates must produce the exact uncached raster output. + """ + + def _draw(disable): + fig, axs = uplt.subplots(nrows=2, ncols=2, share=False) + x = np.linspace(0, 2 * np.pi, 100) + for index, ax in enumerate(axs): + ax.plot(x, np.sin(x + index)) + axs.format( + xlabel="Coordinate", + ylabel="Response", + suptitle="Pixel comparison", + grid=True, + ) + fig._disable_axis_tick_cache = disable + fig._disable_layout_extent_cache = disable + fig.canvas.draw() + return np.asarray(fig.canvas.buffer_rgba()).copy() + + expected = _draw(True) + actual = _draw(False) + + assert np.array_equal(actual, expected) + + +@pytest.mark.parametrize("kind", ("log", "date", "categorical", "shared")) +def test_layout_caches_preserve_specialized_tick_pixels(kind): + """ + Built-in non-linear, unit-aware, categorical, and shared ticks stay exact. + """ + + def _draw(disable): + if kind == "shared": + fig, axs = uplt.subplots(ncols=2, share=True) + else: + fig, ax = uplt.subplots() + axs = [ax] + if kind == "log": + axs[0].semilogx([1, 10, 100, 1000], [0, 1, 0, 1]) + elif kind == "date": + dates = [datetime.date(2025, 1, day) for day in range(1, 8)] + axs[0].plot(dates, np.arange(len(dates))) + elif kind == "categorical": + axs[0].bar(["alpha", "beta", "gamma"], [1, 3, 2]) + else: + for index, ax in enumerate(axs): + ax.plot([0, 1, 2], np.asarray([0, 1, 0]) + index) + fig._disable_axis_tick_cache = disable + fig._disable_layout_extent_cache = disable + fig.canvas.draw() + return np.asarray(fig.canvas.buffer_rgba()).copy() + + expected = _draw(True) + actual = _draw(False) + + assert np.array_equal(actual, expected) + + +def test_layout_caches_bypass_non_cartesian_axes(): + """ + Projection-specific tick and bbox implementations use their native paths. + """ + fig, ax = uplt.subplots(proj="polar") + ax.plot(np.linspace(0, 2 * np.pi, 20), np.linspace(0, 1, 20)) + + fig.canvas.draw() + + stats = fig._last_axis_tick_cache_stats + assert stats["bypasses"] > 0 + assert fig._last_layout_extent_stats == {"hits": 0, "misses": 0} + + +def test_tick_cache_invalidates_changed_axis_geometry(): + """ + Limits and pixel geometry are part of the draw-local cache key. + """ + fig, ax = uplt.subplots() + axis = ax.xaxis + with _AxisTickCache(fig) as cache: + axis._update_ticks() + axis._update_ticks() + assert (cache.hits, cache.misses) == (1, 1) + + ax.set_xlim(0, 10) + axis._update_ticks() + assert (cache.hits, cache.misses) == (1, 2) + + position = ax.get_position() + ax.set_position( + [position.x0, position.y0, 0.5 * position.width, position.height] + ) + axis._update_ticks() + assert (cache.hits, cache.misses) == (1, 3) + + ax.set_position(position) + axis._update_ticks() + assert (cache.hits, cache.misses) == (2, 3) + + +def test_tick_cache_bypasses_custom_tickers(): + """ + Unknown locator and formatter implementations may have stateful calls. + """ + + class CustomFormatter(mticker.Formatter): + def __call__(self, value, pos=None): + return f"{value:g}" + + fig, ax = uplt.subplots() + ax.xaxis.set_major_formatter(CustomFormatter()) + with _AxisTickCache(fig) as cache: + ax.xaxis._update_ticks() + ax.xaxis._update_ticks() + + assert cache.hits == 0 + assert cache.misses == 0 + assert cache.bypasses == 2 + + +def test_tick_cache_lru_is_bounded(): + """ + Cycling through many geometries must not grow the draw-local cache. + """ + fig, ax = uplt.subplots() + with _AxisTickCache(fig) as cache: + for stop in range(2, 9): + ax.set_xlim(0, stop) + ax.xaxis._update_ticks() + states = cache._cache[ax.xaxis] + + assert len(states) == cache._MAX_STATES_PER_AXIS + assert cache.evictions == 3 + + +def test_tick_cache_includes_child_axes(): + """ + Colorbar child axes created during layout should share tick computations. + """ + fig, axs = uplt.subplots() + ax = axs[0] + ax.colorbar("magma", loc="r") + ax._add_queued_guides() + child_axes = [ + child + for child in fig._iter_axes(hidden=True, children=True) + if child not in fig.axes + ] + assert child_axes + + with _AxisTickCache(fig) as cache: + child_axes[0].xaxis._update_ticks() + child_axes[0].xaxis._update_ticks() + assert (cache.hits, cache.misses) == (1, 1) + + assert "_update_ticks" not in child_axes[0].xaxis.__dict__ + + +def test_layout_transaction_restores_after_exception(): + """ + Temporary hooks and active stores must be cleaned up after draw failures. + """ + fig, axs = uplt.subplots() + axis = axs[0].xaxis + + with pytest.raises(RuntimeError, match="draw failed"): + with _LayoutTransaction(fig) as transaction: + assert fig._layout_transaction is transaction + assert "_update_ticks" in axis.__dict__ + assert transaction.extents._active + raise RuntimeError("draw failed") + + assert "_layout_transaction" not in fig.__dict__ + assert "_axis_tick_cache" not in fig.__dict__ + assert "_update_ticks" not in axis.__dict__ + assert not fig._layout_extent_store._active + + +def test_layout_invalidation_has_one_lifecycle(): + """ + Normal invalidation preserves reusable state; reset discards it. + """ + fig, _ = uplt.subplots() + fig.canvas.draw() + store = fig._layout_extent_store + + fig._invalidate_layout() + assert fig._layout_dirty + assert fig._layout_initialized + assert fig._layout_extent_store is store + + fig._invalidate_layout(reset=True) + assert fig._layout_dirty + assert not fig._layout_initialized + assert "_layout_extent_store" not in fig.__dict__ + + +def test_layout_extent_store_reuses_unmodified_axes(): + """ + A one-axes title edit should only remeasure that axes. + """ + fig, axs = uplt.subplots(nrows=2, ncols=2, share=False) + axs.format(abc="a.") + axs[1].plot([0, 1], [0, 1], label="Default axes legend") + axs[1].legend() + fig.canvas.draw() + + axs[0].format(title="Changed") + fig.canvas.draw() + + stats = fig._last_layout_extent_stats + assert stats == {"hits": 3, "misses": 1} + + +def test_layout_extent_store_preserves_incremental_pixels(): + """ + Relative outsets must exactly match an uncached geometry update. + """ + + def _draw(disable): + fig, axs = uplt.subplots(nrows=2, ncols=2, share=False) + for ax in axs: + ax.plot([0, 1, 2], [0, 1, 0]) + axs.format(abc="a.") + axs[1].plot([0, 1], [1, 0], label="Legend entry") + axs[1].legend() + fig._disable_layout_extent_cache = disable + fig.canvas.draw() + axs[0].format(title="A longer changed title") + fig.canvas.draw() + return np.asarray(fig.canvas.buffer_rgba()).copy() + + expected = _draw(True) + actual = _draw(False) + + assert np.array_equal(actual, expected) + + +def test_layout_extent_store_tracks_subset_title_changes(): + """ + Shared subset-title state participates in axes extent cache keys. + """ + fig, axs = uplt.subplots(nrows=2, ncols=2, share=False) + axs[0, :].format(title="First") + fig.canvas.draw() + + axs[0, :].format(title="A substantially longer shared title") + fig.canvas.draw() + + stats = fig._last_layout_extent_stats + assert stats["misses"] >= 2 + + +@pytest.mark.parametrize( + "kwargs", + ( + {"xgrid": True}, + {"gridcolor": "red"}, + {"xtickcolor": "red"}, + {"xticklabelcolor": "red"}, + {"xlabelcolor": "red"}, + {"xlinewidth": 1.5}, + ), +) +def test_paint_only_format_does_not_invalidate_layout(kwargs): + """ + Paint-only Cartesian formatting should reuse the initialized layout. + """ + fig, ax = uplt.subplots() + fig.canvas.draw() + assert not fig._layout_dirty + + ax.format(**kwargs) + + assert not fig._layout_dirty + + +@pytest.mark.parametrize( + "kwargs", + ( + {"title": "Changed"}, + {"xlabel": "Changed"}, + {"xticklabelsize": 14}, + {"xlim": (0, 2)}, + {"xlocator": 0.5}, + ), +) +def test_geometry_format_invalidates_layout(kwargs): + """ + Text, tick geometry, limits, and locators must still invalidate layout. + """ + fig, ax = uplt.subplots() + fig.canvas.draw() + assert not fig._layout_dirty + + ax.format(**kwargs) + + assert fig._layout_dirty + + +def test_figure_paint_only_format_does_not_invalidate_layout(): + """ + Figure-wide routing should preserve paint-only invalidation decisions. + """ + fig, _ = uplt.subplots(ncols=2) + fig.canvas.draw() + + fig.format(xgrid=True, ygrid=True) + + assert not fig._layout_dirty + + +def test_blit_manager_updates_without_full_redraw(): + """ + After capturing a background, updates should use draw_artist and blit. + """ + fig, ax = uplt.subplots() + (line,) = ax.plot([0, 1, 2], [0, 1, 0]) + manager = fig._blit_manager(line) + assert isinstance(manager, _BlitManager) + assert manager.supports_blit + assert line.get_animated() + + manager.update() + assert manager._background is not None + + original_draw = fig.canvas.draw + original_blit = fig.canvas.blit + fig.canvas.draw = MagicMock(wraps=original_draw) + fig.canvas.blit = MagicMock(wraps=original_blit) + line.set_ydata([1, 0, 1]) + assert manager.update() + + fig.canvas.draw.assert_not_called() + fig.canvas.blit.assert_called_once() + manager.close(redraw=False) + assert not line.get_animated() + + +def test_blit_manager_matches_full_draw(): + """ + A blitted artist update should match a subsequent complete Agg draw. + """ + fig, ax = uplt.subplots() + (line,) = ax.plot([0.2, 1, 1.8], [0.2, 0.8, 0.2]) + ax.format(xlim=(0, 2), ylim=(0, 1)) + manager = fig._blit_manager(line) + manager.update() + + line.set_ydata([0.8, 0.2, 0.8]) + manager.update() + blitted = np.asarray(fig.canvas.buffer_rgba()).copy() + + manager.close(redraw=False) + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert np.array_equal(blitted, complete) + + +def test_blit_manager_savefig_includes_managed_artist(): + """ + Saving should temporarily restore normal artist drawing and z-order. + """ + fig, ax = uplt.subplots() + (line,) = ax.plot([0.2, 1, 1.8], [0.2, 0.8, 0.2], color="red", lw=4) + ax.format(xlim=(0, 2), ylim=(0, 1)) + + baseline_buffer = io.BytesIO() + fig.savefig(baseline_buffer, format="png") + baseline_buffer.seek(0) + baseline = np.asarray(Image.open(baseline_buffer)).copy() + + manager = fig._blit_manager(line) + manager.update() + managed_buffer = io.BytesIO() + fig.savefig(managed_buffer, format="png") + managed_buffer.seek(0) + managed = np.asarray(Image.open(managed_buffer)).copy() + + assert np.array_equal(managed, baseline) + assert line.get_animated() + manager.close(redraw=False) + + +def test_blit_manager_invalidates_on_resize(): + """ + Resize events must discard pixel-sized background caches. + """ + fig, ax = uplt.subplots() + (line,) = ax.plot([0, 1], [0, 1]) + manager = fig._blit_manager(line) + manager.update() + assert manager._background is not None + + manager._on_resize(None) + + assert manager._background is None + manager.close(redraw=False) + + +def test_blit_manager_falls_back_for_unsupported_canvas(): + """ + Non-blitting canvases should retain normal artists and request idle draws. + """ + fig, ax = uplt.subplots() + (line,) = ax.plot([0, 1], [0, 1]) + canvas = FigureCanvasBase(fig) + canvas.draw_idle = MagicMock() + manager = _BlitManager(canvas, [line]) + + assert not manager.supports_blit + assert not line.get_animated() + assert not manager.update() + canvas.draw_idle.assert_called_once() + manager.close(redraw=False) + + +def test_blit_manager_rejects_artist_from_another_figure(): + fig1, _ = uplt.subplots() + _fig2, ax2 = uplt.subplots() + (line,) = ax2.plot([0, 1], [0, 1]) + + with pytest.raises(RuntimeError, match="must belong"): + fig1._blit_manager(line) + + +def test_selective_draw_full_layer_matches_normal_draw(): + """Splitting a full draw into static and axes layers must preserve pixels.""" + fig, axs = uplt.subplots(ncols=2) + for index, ax in enumerate(axs): + ax.plot([0, 0.5, 1], [0.2, 0.8 - 0.1 * index, 0.3]) + ax.format(xlabel=f"x {index}", ylabel=f"y {index}", title=f"axis {index}") + fig.format(suptitle="Layer fidelity") + manager = fig._selective_draw_manager + + with manager.save_context(): + fig.canvas.draw() + normal = np.asarray(fig.canvas.buffer_rgba()).copy() + fig.canvas.draw() + layered = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert np.array_equal(layered, normal) + + +@pytest.mark.parametrize("overlay", ("text", "patch")) +def test_selective_draw_falls_back_for_overlapping_figure_artist(overlay): + """Figure-level overlays must retain their normal ordering above axes.""" + fig, axs = uplt.subplots(ncols=2) + for ax in axs: + ax.plot([0, 1], [0.2, 0.8], lw=5) + if overlay == "text": + fig.text(0.32, 0.5, "OVERLAY", fontsize=28, zorder=20) + else: + from matplotlib.patches import Rectangle + + artist = Rectangle( + (0.2, 0.25), + 0.3, + 0.5, + transform=fig.transFigure, + color="red", + alpha=0.35, + zorder=20, + ) + fig.add_artist(artist) + fig.canvas.draw() + manager = fig._selective_draw_manager + + with manager.save_context(): + fig.canvas.draw() + normal = np.asarray(fig.canvas.buffer_rgba()).copy() + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._cache_mode is None + assert np.array_equal(retained, normal) + + +@pytest.mark.parametrize("ncols", (1, 2)) +def test_selective_draw_defers_cache_until_after_initial_display(ncols): + """The first display should have no retained-layer setup overhead.""" + fig, _axs = uplt.subplots(ncols=ncols) + for ax in fig.axes: + ax.plot([0, 1], [0, 1]) + manager = fig._selective_draw_manager + + fig.canvas.draw() + assert manager._cache_mode is None + assert manager._full_draw_count == 0 + + fig.canvas.draw() + assert manager._cache_mode == "suffix" + assert manager._full_draw_count == 1 + + +def test_selective_draw_redraws_only_changed_axes(): + """A paint-only line update should bypass every unchanged axes.""" + fig, axs = uplt.subplots(ncols=2) + lines = [ax.plot([0, 0.5, 1], [0.2, 0.8, 0.3])[0] for ax in axs] + fig.canvas.draw() + fig.canvas.draw() # Prime retained axes layers after the initial display. + manager = fig._selective_draw_manager + unchanged_draw = axs[1].draw + axs[1].draw = MagicMock(wraps=unchanged_draw) + + lines[0].set_ydata([0.8, 0.2, 0.7]) + fig.canvas.draw() + + assert manager._selective_draw_count == 1 + axs[1].draw.assert_not_called() + + +def test_selective_draw_multi_axes_suffix_matches_complete_draw(): + """Each dirty axes should redraw only its exact artist suffix.""" + fig, axs = uplt.subplots(ncols=2) + x = np.linspace(0, 2 * np.pi, 500) + lines = [] + for index, ax in enumerate(axs): + ax.scatter(x[::20], np.cos(x[::20]), s=6, zorder=1) + lines.append(ax.plot(x, np.sin(x + index), zorder=2)[0]) + ax.format(title=f"Axis {index}", grid=True) + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + axis_draws = [ax.xaxis.draw for ax in axs] + for ax, draw in zip(axs, axis_draws): + ax.xaxis.draw = MagicMock(wraps=draw) + + lines[0].set_ydata(np.cos(x)) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._cache_mode == "suffix" + assert manager._selective_draw_count == 1 + for ax in axs: + ax.xaxis.draw.assert_not_called() + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_multi_axes_suffix_updates_multiple_dirty_axes(): + """Simultaneous line changes should restore and redraw every dirty suffix.""" + fig, axs = uplt.subplots(ncols=2) + x = np.linspace(0, 2 * np.pi, 500) + lines = [ax.plot(x, np.sin(x + index))[0] for index, ax in enumerate(axs)] + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + + for index, line in enumerate(lines): + line.set_ydata(np.cos(x + index)) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._selective_draw_count == 1 + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_multi_axes_uses_axes_mode_without_safe_suffixes(): + """A figure must not mix suffix and whole-axes retained layers.""" + fig, axs = uplt.subplots(ncols=2) + axs[0].plot([0, 1], [0, 1]) + axs[1].scatter([0, 1], [1, 0]) + fig.canvas.draw() + fig.canvas.draw() + + assert fig._selective_draw_manager._cache_mode == "axes" + + +def test_selective_draw_stays_fast_across_consecutive_frames(): + """Placeholder staleness must not force alternating full redraws.""" + fig, axs = uplt.subplots(ncols=2) + x = np.linspace(0, 2 * np.pi, 100) + line = axs[0].plot(x, np.sin(x))[0] + axs[1].plot(x, np.cos(x)) + fig.canvas.draw() + fig.canvas.draw() # Prime retained axes layers after the initial display. + manager = fig._selective_draw_manager + + for frame in range(4): + line.set_ydata(np.sin(x + frame / 10)) + fig.canvas.draw() + + assert manager._selective_draw_count == 4 + assert manager._full_draw_count == 1 + + +def test_selective_draw_single_axes_line_suffix_matches_complete_draw(): + """A retained line suffix should skip axes while preserving exact pixels.""" + fig, ax = uplt.subplots() + x = np.linspace(0, 2 * np.pi, 200) + ax.scatter(x[::8], np.cos(x[::8]), s=8, zorder=1) + ax.plot(x, 0.4 * np.cos(x), color="orange", zorder=2) + line = ax.plot(x, np.sin(x), color="cerulean", zorder=2)[0] + ax.text(0.03, 0.96, "Annotation", transform=ax.transAxes, va="top", zorder=3.2) + ax.format(title="Retained suffix", xlabel="x", ylabel="y", grid=True) + fig.canvas.draw() + manager = fig._selective_draw_manager + axis_draw = ax.xaxis.draw + ax.xaxis.draw = MagicMock(wraps=axis_draw) + + line.set_ydata(np.sin(x + 0.1)) + fig.canvas.draw() # Prime the retained suffix on the first redraw. + ax.xaxis.draw.reset_mock() + for frame in range(4): + line.set_ydata(np.sin(x + frame / 10)) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._cache_mode == "suffix" + assert manager._selective_draw_count == 4 + assert manager._full_draw_count == 1 + ax.xaxis.draw.assert_not_called() + + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_single_axes_savefig_matches_unretained_output(): + """Saving must restore ordinary ordering after retained suffix updates.""" + fig, ax = uplt.subplots() + line = ax.plot([0, 1, 2], [0.8, 0.2, 0.7], color="red", lw=3)[0] + ax.format(xlim=(0, 2), ylim=(0, 1), title="Export fidelity") + + expected_buffer = io.BytesIO() + fig.savefig(expected_buffer, format="png") + expected_buffer.seek(0) + expected = np.asarray(Image.open(expected_buffer)).copy() + + fig.canvas.draw() # Prime the retained suffix after the initial save. + line.set_ydata([0.2, 0.8, 0.3]) + fig.canvas.draw() + line.set_ydata([0.8, 0.2, 0.7]) + fig.canvas.draw() + assert fig._selective_draw_manager._selective_draw_count == 2 + + retained_buffer = io.BytesIO() + fig.savefig(retained_buffer, format="png") + retained_buffer.seek(0) + retained = np.asarray(Image.open(retained_buffer)).copy() + + assert np.array_equal(retained, expected) + + +def test_selective_draw_invalidates_on_dpi_change_without_resize(): + """Copied pixel regions must never survive a silent DPI change.""" + fig, ax = uplt.subplots() + line = ax.plot([0, 1, 2], [0.2, 0.8, 0.3], lw=4)[0] + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + assert manager._cache_safe + + fig.set_dpi(fig.dpi * 1.5) + line.set_ydata([0.8, 0.2, 0.7]) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_rejects_suffix_outside_damage_region(): + """Unclipped translucent suffix artists must not accumulate outside cache.""" + from matplotlib.patches import Rectangle + + fig, ax = uplt.subplots() + x = np.linspace(0, 1, 100) + line = ax.plot(x, x, zorder=2)[0] + patch = Rectangle( + (-0.2, 0.3), + 1.4, + 0.4, + transform=ax.transAxes, + clip_on=False, + in_layout=False, + alpha=0.2, + color="red", + zorder=3, + ) + ax.add_patch(patch) + fig.canvas.draw() + manager = fig._selective_draw_manager + + for offset in (0.1, 0.2, 0.3): + line.set_ydata(x + offset) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert not manager._cache_safe + assert manager._selective_draw_count == 0 + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_does_not_recapture_during_pan_frames(): + """Changing views should not rebuild a cache discarded by the next motion.""" + fig, ax = uplt.subplots() + line = ax.plot(np.linspace(0, 10, 1000), np.sin(np.linspace(0, 10, 1000)))[0] + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + assert manager._cache_safe + full_count = manager._full_draw_count + + for left in (1, 2, 3): + ax.set_xlim(left, left + 5) + fig.canvas.draw() + + assert manager._cache_mode is None + assert manager._full_draw_count == full_count + + line.set_ydata(np.cos(np.linspace(0, 10, 1000))) + fig.canvas.draw() # Stable view primes retention once after navigation. + line.set_ydata(np.sin(np.linspace(0, 10, 1000))) + fig.canvas.draw() + assert manager._selective_draw_count == 1 + + +def test_selective_draw_retains_unchanged_three_axes_during_rotation(): + """A rotated 3D subplot should preserve exact pixels and distant axes.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, proj="3d", share=False) + t = np.linspace(0, 6 * np.pi, 500) + for index, ax in enumerate(axs): + ax.plot(np.cos(t), np.sin(t), t + index) + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + distant_draw = axs[-1].draw + axs[-1].draw = MagicMock(wraps=distant_draw) + + axs[0].view_init(elev=35, azim=55, roll=5) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._selective_draw_count == 1 + axs[-1].draw.assert_not_called() + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_selective_draw_three_axes_rotation_falls_back_when_not_profitable(): + """Measuring a rotated extent must not slow down a two-axes figure.""" + fig, axs = uplt.subplots(ncols=2, proj="3d", share=False) + for ax in axs: + ax.plot([0, 1], [0, 1], [0, 1]) + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + + axs[0].view_init(elev=40, azim=20) + fig.canvas.draw() + + assert manager._selective_draw_count == 0 + assert manager._cache_mode is None + + +def test_selective_draw_axes_mode_handles_view_limit_changes(): + """Whole-axes retention should support 2D and geographic-style view changes.""" + fig, axs = uplt.subplots(ncols=3) + for ax in axs: + ax.scatter([0, 1, 2], [0, 1, 0]) + fig.canvas.draw() + fig.canvas.draw() + manager = fig._selective_draw_manager + + axs[0].set_xlim(0.25, 1.75) + fig.canvas.draw() + retained = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._selective_draw_count == 1 + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + assert np.array_equal(retained, complete) + + +def test_three_d_interaction_preview_restores_exact_artist_state(): + """Dense preview artists and decorations must be completely reversible.""" + fig, ax = uplt.subplots(proj="3d") + ax = fig.axes[0] + t = np.linspace(0, 8 * np.pi, 5_000) + line = ax.plot(np.cos(t), np.sin(t), t)[0] + scatter = ax.scatter(np.cos(t), np.sin(t), t, s=np.linspace(1, 3, len(t))) + values = np.linspace(-2, 2, 30) + xx, yy = np.meshgrid(values, values) + surface = ax.plot_surface(xx, yy, np.sin(xx * yy), rcount=30, ccount=30) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + line_data = tuple(np.asanyarray(array).copy() for array in line.get_data_3d()) + offsets = tuple(np.asanyarray(array).copy() for array in scatter._offsets3d) + locators = tuple( + (axis.get_major_locator(), axis.get_minor_locator()) + for axis in ax._axis_map.values() + ) + grid = ax._draw_grid + recipe = surface._ultraplot_lod_recipe + assert recipe.proxy is None + assert surface.axes is ax and surface in ax.collections + + assert preview.activate(ax) + proxy = recipe.proxy + assert len(line.get_data_3d()[0]) <= preview._line_limit + 2 + assert len(scatter._offsets3d[0]) <= preview._scatter_limit + 2 + assert surface.axes is ax and surface.get_visible() + assert surface._ultraplot_navigation_hidden + assert proxy.get_visible() and proxy.axes is ax + assert ax._draw_grid is grid + assert ax._ultraplot_navigation_hide_grid + assert all( + isinstance(axis.get_minor_locator(), mticker.NullLocator) + for axis in ax._axis_map.values() + ) + + assert preview.deactivate(redraw=False) + assert all( + np.array_equal(expected, actual) + for expected, actual in zip(line_data, line.get_data_3d()) + ) + assert all( + np.array_equal(expected, actual) + for expected, actual in zip(offsets, scatter._offsets3d) + ) + assert surface.get_visible() and surface.axes is ax + assert not hasattr(surface, "_ultraplot_navigation_hidden") + assert not proxy.get_visible() + assert proxy.axes is None + assert ax._draw_grid is grid + assert not hasattr(ax, "_ultraplot_navigation_hide_grid") + assert all( + axis.get_major_locator() is major and axis.get_minor_locator() is minor + for axis, (major, minor) in zip(ax._axis_map.values(), locators) + ) + + +def test_three_d_interaction_preview_exports_full_quality(): + """Saving during rotation must temporarily restore the exact dense scene.""" + fig, ax = uplt.subplots(proj="3d") + ax = fig.axes[0] + t = np.linspace(0, 8 * np.pi, 5_000) + line = ax.plot(np.cos(t), np.sin(t), t)[0] + fig.canvas.draw() + + expected_buffer = io.BytesIO() + fig.savefig(expected_buffer, format="png") + expected_buffer.seek(0) + expected = np.asarray(Image.open(expected_buffer)).copy() + + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + assert len(line.get_data_3d()[0]) < len(t) + preview_buffer = io.BytesIO() + fig.savefig(preview_buffer, format="png") + preview_buffer.seek(0) + exported = np.asarray(Image.open(preview_buffer)).copy() + + assert np.array_equal(exported, expected) + assert preview._state is not None + assert len(line.get_data_3d()[0]) < len(t) + preview.deactivate(redraw=False) + + +def test_two_d_interaction_preview_restores_exact_artist_state(): + """Toolbar pan preview should reversibly reduce dense 2D artists.""" + fig, _ = uplt.subplots() + ax = fig.axes[0] + x = np.linspace(0, 100, 5_000) + line = ax.plot(x, np.sin(x))[0] + scatter = ax.scatter(x, np.cos(x), s=np.linspace(1, 3, len(x))) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + line_data = tuple(np.asanyarray(array).copy() for array in line.get_data()) + offsets = np.asanyarray(scatter.get_offsets()).copy() + locators = tuple( + (axis.get_major_locator(), axis.get_minor_locator()) + for axis in (ax.xaxis, ax.yaxis) + ) + + assert preview.activate(ax) + assert len(line.get_xdata()) <= preview._line_limit + 2 + assert len(scatter.get_offsets()) <= preview._scatter_limit + 2 + assert isinstance(ax.xaxis.get_minor_locator(), mticker.NullLocator) + assert isinstance(ax.yaxis.get_minor_locator(), mticker.NullLocator) + + assert preview.deactivate(redraw=False) + assert all( + np.array_equal(expected, actual) + for expected, actual in zip(line_data, line.get_data()) + ) + assert np.array_equal(offsets, scatter.get_offsets()) + assert all( + axis.get_major_locator() is major and axis.get_minor_locator() is minor + for axis, (major, minor) in zip((ax.xaxis, ax.yaxis), locators) + ) + + +@pytest.mark.parametrize("projection", (None, "3d")) +def test_navigation_preview_rc_disables_approximation(projection): + """The runtime rc setting should preserve exact interactive frames.""" + kwargs = {} if projection is None else {"proj": projection} + fig, _ = uplt.subplots(**kwargs) + ax = fig.axes[0] + values = np.linspace(0, 10, 5_000) + if projection is None: + line = ax.plot(values, np.sin(values))[0] + else: + line = ax.plot(np.cos(values), np.sin(values), values)[0] + + def get_size(): + data = line.get_xdata() if projection is None else line.get_data_3d()[0] + return len(data) + + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + + with uplt.rc.context({"navigation.preview": False}): + assert not preview.activate(ax) + assert get_size() == len(values) + + assert preview.activate(ax) + assert get_size() < len(values) + with uplt.rc.context({"navigation.preview": False}): + assert not preview.request_draw(fig.canvas.draw_idle) + assert preview._state is None + assert get_size() == len(values) + + +@pytest.mark.parametrize("projection", (None, "3d")) +def test_navigation_preview_preserves_updates_during_gesture(projection): + """Release must not overwrite artist or locator changes made while active.""" + kwargs = {} if projection is None else {"proj": projection} + fig, _ = uplt.subplots(**kwargs) + ax = fig.axes[0] + values = np.linspace(0, 10, 5_000) + if projection is None: + line = ax.plot(values, np.sin(values))[0] + scatter = ax.scatter(values, np.cos(values), s=1) + axes = (ax.xaxis, ax.yaxis) + else: + line = ax.plot(np.cos(values), np.sin(values), values)[0] + scatter = ax.scatter(np.cos(values), np.sin(values), values, s=1) + axes = tuple(ax._axis_map.values()) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + + updated = np.linspace(0, 12, 6_000) + if projection is None: + line.set_data(updated, np.sin(updated)) + scatter.set_offsets(np.column_stack((updated, np.cos(updated)))) + else: + line.set_data_3d(np.cos(updated), np.sin(updated), updated) + scatter._offsets3d = (np.cos(updated), np.sin(updated), updated) + locator = mticker.MaxNLocator(nbins=7) + axes[0].set_major_locator(locator) + line.set_color("red") + if projection == "3d": + ax.grid(False) + + assert preview.deactivate(redraw=False) + line_size = ( + len(line.get_xdata()) if projection is None else len(line.get_data_3d()[0]) + ) + scatter_size = ( + len(scatter.get_offsets()) if projection is None else len(scatter._offsets3d[0]) + ) + assert line_size == scatter_size == len(updated) + assert axes[0].get_major_locator() is locator + assert line.get_color() == "red" + if projection == "3d": + assert not ax._draw_grid + + +def test_navigation_preview_preserves_partial_line_update(): + """Updating only y data must restore exact x data without losing new y data.""" + fig, _ = uplt.subplots() + ax = fig.axes[0] + values = np.linspace(0, 10, 5_000) + line = ax.plot(values, np.sin(values))[0] + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + + updated_y = np.cos(values) + line.set_ydata(updated_y) + assert preview.deactivate(redraw=False) + assert np.array_equal(line.get_xdata(), values) + assert np.array_equal(line.get_ydata(), updated_y) + + +def test_three_d_hidden_surface_stays_hidden_during_preview(): + """Navigation must not expose a surface hidden by the user.""" + fig, _ = uplt.subplots(proj="3d") + ax = fig.axes[0] + values = np.linspace(-2, 2, 30) + xx, yy = np.meshgrid(values, values) + surface = ax.plot_surface(xx, yy, np.sin(xx * yy), rcount=30, ccount=30) + surface.set_visible(False) + fig.canvas.draw() + recipe = surface._ultraplot_lod_recipe + preview = fig._selective_draw_manager._navigation_preview + + assert preview.activate(ax) + assert not surface.get_visible() and surface in ax.collections + assert recipe.proxy is None + assert preview.deactivate(redraw=False) + assert not surface.get_visible() and surface in ax.collections + + +def test_three_d_surface_preview_tracks_active_updates(): + """Surface visibility and style changes should reach the active proxy.""" + fig, _ = uplt.subplots(proj="3d") + ax = fig.axes[0] + values = np.linspace(-2, 2, 30) + xx, yy = np.meshgrid(values, values) + surface = ax.plot_surface(xx, yy, np.sin(xx * yy), rcount=30, ccount=30) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + proxy = surface._ultraplot_lod_recipe.proxy + + surface.set_alpha(0.25) + surface.set_facecolor("blue") + surface.set_visible(False) + fig.canvas.draw() + assert proxy.get_alpha() == 0.25 + assert np.allclose(proxy._facecolor3d, [[0.0, 0.0, 1.0, 0.25]]) + assert not proxy.get_visible() + + assert preview.deactivate(redraw=False) + assert surface.get_alpha() == 0.25 + assert np.allclose(surface._facecolor3d, [[0.0, 0.0, 1.0, 0.25]]) + assert not surface.get_visible() + + +def test_three_d_surface_geometry_change_disables_stale_proxy(): + """Changed surface geometry should fall back to the exact collection.""" + fig, _ = uplt.subplots(proj="3d") + ax = fig.axes[0] + values = np.linspace(-2, 2, 30) + xx, yy = np.meshgrid(values, values) + surface = ax.plot_surface(xx, yy, np.sin(xx * yy), rcount=30, ccount=30) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + proxy = surface._ultraplot_lod_recipe.proxy + + surface.set_verts([np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.5]])]) + fig.canvas.draw() + assert surface.get_visible() + assert not proxy.get_visible() + + assert preview.deactivate(redraw=False) + assert surface.get_visible() and surface in ax.collections + + +@pytest.mark.parametrize("projection", (None, "3d")) +def test_navigation_preview_tracks_mouse_press_and_release(projection): + """Canvas callbacks should activate previews and always restore on release.""" + kwargs = {} if projection is None else {"proj": projection} + fig, _ = uplt.subplots(**kwargs) + ax = fig.axes[0] + values = np.linspace(0, 10, 5_000) + if projection is None: + line = ax.plot(values, np.sin(values))[0] + ax.set_navigate_mode("PAN") + original_size = len(line.get_xdata()) + else: + line = ax.plot(np.cos(values), np.sin(values), values)[0] + original_size = len(line.get_data_3d()[0]) + fig.canvas.draw() + x, y = ax.transAxes.transform((0.5, 0.5)) + + MouseEvent("button_press_event", fig.canvas, x, y, button=1)._process() + preview = fig._selective_draw_manager._navigation_preview + assert preview._state is not None + + MouseEvent("button_release_event", fig.canvas, x, y, button=1)._process() + assert preview._state is None + size = len(line.get_xdata()) if projection is None else len(line.get_data_3d()[0]) + assert size == original_size + + +def test_navigation_preview_coalesces_fast_draw_idle_requests(): + """Rapid motion updates should present only the newest scheduled frame.""" + + class TestTimer(TimerBase): + def _timer_start(self): + self.started = True + + def _timer_stop(self): + self.started = False + + fig, ax = uplt.subplots() + ax = fig.axes[0] + values = np.linspace(0, 10, 5_000) + ax.plot(values, np.sin(values)) + fig.canvas.draw() + preview = fig._selective_draw_manager._navigation_preview + assert preview.activate(ax) + + timers = [] + + def new_timer(interval): + timer = TestTimer(interval=interval) + timers.append(timer) + return timer + + fig.canvas.new_timer = new_timer + draws = [] + cid = fig.canvas.mpl_connect("draw_event", lambda event: draws.append(event)) + fig.canvas.draw_idle() + ax.set_xlim(1, 9) + fig.canvas.draw_idle() + + assert len(timers) == 1 + assert not draws + timers[0]._on_timer() + # Agg draws synchronously; GUI backends enqueue the submitted idle draw. + assert not preview._pacer._draw_requested + assert preview._pacer._draw_pending or len(draws) == 1 + + fig.canvas.mpl_disconnect(cid) + preview.deactivate(redraw=False) + + +@pytest.mark.parametrize("kind", ("scatter", "low_line", "unclipped", "overlay")) +def test_selective_draw_single_axes_falls_back_when_suffix_is_unsafe(kind): + """Single-axes retention requires a clipped line above the axes layer.""" + fig, ax = uplt.subplots() + if kind == "scatter": + artist = ax.scatter([0, 1], [0, 1]) + else: + artist = ax.plot([0, 1], [0, 1], zorder=1 if kind == "low_line" else 2)[0] + if kind == "unclipped": + artist.set_clip_on(False) + elif kind == "overlay": + fig.text(0.5, 0.5, "Figure overlay", zorder=10) + fig.canvas.draw() + manager = fig._selective_draw_manager + + if kind == "scatter": + artist.set_offsets([[0, 1], [1, 0]]) + else: + artist.set_ydata([1, 0]) + fig.canvas.draw() + + assert manager._cache_mode is None + assert manager._selective_draw_count == 0 + + +@pytest.mark.parametrize("kind", ("line", "scatter")) +def test_selective_draw_matches_complete_data_draw(kind): + """Retained line and collection updates must match a complete Agg draw.""" + fig, axs = uplt.subplots(ncols=2) + axs[1].plot([0, 1], [1, 0]) + if kind == "line": + artist = axs[0].plot([0, 0.5, 1], [0.2, 0.8, 0.3])[0] + else: + artist = axs[0].scatter([0, 0.5, 1], [0.2, 0.8, 0.3]) + fig.canvas.draw() + fig.canvas.draw() # Prime retained axes layers after the initial display. + manager = fig._selective_draw_manager + + if kind == "line": + artist.set_ydata([0.8, 0.2, 0.7]) + else: + artist.set_offsets([[0, 0.8], [0.5, 0.2], [1, 0.7]]) + fig.canvas.draw() + selective = np.asarray(fig.canvas.buffer_rgba()).copy() + + with manager.save_context(): + fig.canvas.draw() + complete = np.asarray(fig.canvas.buffer_rgba()).copy() + + assert manager._selective_draw_count == 1 + assert np.array_equal(selective, complete) + + +@pytest.mark.parametrize("change", ("limits", "text", "structure")) +def test_selective_draw_falls_back_for_unsafe_changes(change): + """Geometry, text, and artist-tree changes must retain full-draw semantics.""" + fig, axs = uplt.subplots(ncols=2) + ax = axs[0] + ax.plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [1, 0]) + fig.canvas.draw() + manager = fig._selective_draw_manager + full_count = manager._full_draw_count + + if change == "limits": + ax.set_xlim(-1, 2) + elif change == "text": + ax.set_title("Changed") + else: + ax.plot([0, 1], [1, 0]) + fig.canvas.draw() + + assert manager._selective_draw_count == 0 + assert manager._full_draw_count == full_count + (change != "limits") + + def test_layout_array_no_crash(): """ Test that using layout_array with FuncAnimation does not crash.