diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 140e9a3b6..832b3a5cf 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys +from functools import wraps from pathlib import Path from typing import Optional @@ -86,6 +87,7 @@ def _setup(): from .utils import check_for_update check_for_update("ultraplot") + _patch_funcanimation_draw_idle() success = True finally: if success: @@ -106,6 +108,89 @@ def setup(eager: Optional[bool] = None) -> None: _LOADER.load_all(globals()) +def _patch_funcanimation_draw_idle(): + try: + import matplotlib.animation as mpl_animation + except Exception: + return + + try: + from .config import rc + except Exception: + return + if not rc.get("animation.force_draw_idle", True): + return + + if getattr(mpl_animation.FuncAnimation, "_ultra_draw_idle_patched", False): + return + + orig_init = mpl_animation.FuncAnimation.__init__ + orig_stop = getattr(mpl_animation.FuncAnimation, "_stop", None) + + def _install_draw_idle(self, fig): + if fig is None or not hasattr(fig, "_layout_dirty"): + return + canvas = getattr(fig, "canvas", None) + if canvas is None or not hasattr(canvas, "draw_idle"): + return + if getattr(canvas, "manager", None) is None: + return + + count = getattr(canvas, "_ultra_draw_idle_count", 0) + if count == 0: + canvas._ultra_draw_idle_orig = canvas.draw_idle + + # TODO: Replace this monkeypatch with a backend-level fix once + # draw_idle artifacts are resolved upstream. + def draw_idle(*args, **kwargs): + return canvas.draw(*args, **kwargs) + + canvas.draw_idle = draw_idle + canvas._ultra_draw_idle_count = count + 1 + + import weakref + + canvas_ref = weakref.ref(canvas) + + def restore(): + canvas = canvas_ref() + if canvas is None: + return + count = getattr(canvas, "_ultra_draw_idle_count", 0) + if count <= 1: + orig = getattr(canvas, "_ultra_draw_idle_orig", None) + if orig is not None: + canvas.draw_idle = orig + delattr(canvas, "_ultra_draw_idle_orig") + canvas._ultra_draw_idle_count = 0 + else: + canvas._ultra_draw_idle_count = count - 1 + + self._ultra_restore_draw_idle = restore + self._ultra_draw_idle_finalizer = weakref.finalize(self, restore) + + @wraps(orig_init) + def __init__(self, fig, *args, **kwargs): + orig_init(self, fig, *args, **kwargs) + _install_draw_idle(self, fig) + + mpl_animation.FuncAnimation.__init__ = __init__ + + if orig_stop is not None: + + @wraps(orig_stop) + def _stop(self, *args, **kwargs): + restore = getattr(self, "_ultra_restore_draw_idle", None) + if restore is not None: + restore() + self._ultra_restore_draw_idle = None + return orig_stop(self, *args, **kwargs) + + mpl_animation.FuncAnimation._stop = _stop + + mpl_animation.FuncAnimation._ultra_draw_idle_patched = True + + def _build_registry_map(): global _REGISTRY_ATTRS if _REGISTRY_ATTRS is not None: diff --git a/ultraplot/figure.py b/ultraplot/figure.py index b2612d6a3..6ea87d3dc 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -483,7 +483,8 @@ def _canvas_preprocess(self, *args, **kwargs): ctx2 = fig._context_authorized() # skip backend set_constrained_layout() ctx3 = rc.context(fig._render_context) # draw with figure-specific setting with ctx1, ctx2, ctx3: - fig.auto_layout() + if fig._layout_dirty: + fig.auto_layout() return func(self, *args, **kwargs) # Add preprocessor @@ -799,6 +800,9 @@ def __init__( self._is_authorized = False self._includepanels = None self._render_context = {} + self._layout_dirty = True + self._ultra_layout_scheduled = False + self._ultra_layout_in_progress = False rc_kw, rc_mode = _pop_rc(kwargs) kw_format = _pop_params(kwargs, self._format_signature) if figwidth is not None and figheight is not None: @@ -1497,6 +1501,7 @@ def _add_axes_panel( pax.yaxis.set_tick_params(**{pax._label_key("labelright"): on}) ax.yaxis.set_tick_params(**{ax._label_key("labelright"): False}) + self._layout_dirty = True return pax @_clear_border_cache @@ -1539,6 +1544,7 @@ def _add_figure_panel( pax._panel_side = side pax._panel_share = False pax._panel_parent = None + self._layout_dirty = True return pax @_clear_border_cache @@ -2308,6 +2314,7 @@ def add_axes(self, rect, **kwargs): %(figure.axes)s """ kwargs = self._parse_proj(**kwargs) + self._layout_dirty = True return super().add_axes(rect, **kwargs) @docstring._concatenate_inherited @@ -2316,7 +2323,9 @@ def add_subplot(self, *args, **kwargs): """ %(figure.subplot)s """ - return self._add_subplot(*args, **kwargs) + ax = self._add_subplot(*args, **kwargs) + self._layout_dirty = True + return ax @docstring._snippet_manager def subplot(self, *args, **kwargs): # shorthand @@ -2330,7 +2339,9 @@ def add_subplots(self, *args, **kwargs): """ %(figure.subplots)s """ - return self._add_subplots(*args, **kwargs) + axs = self._add_subplots(*args, **kwargs) + self._layout_dirty = True + return axs @docstring._snippet_manager def subplots(self, *args, **kwargs): @@ -2363,14 +2374,13 @@ def auto_layout(self, renderer=None, aspect=None, tight=None, resize=None): to `~Figure.subplots`, `~Figure.set_size_inches` was called manually, or the figure was resized manually with an interactive backend. """ + # *Impossible* to get notebook backend to work with auto resizing so we # just do the tight layout adjustments and skip resizing. gs = self.gridspec renderer = self._get_renderer() - if aspect is None: - aspect = True - if tight is None: - tight = self._tight_active + layout_aspect = True if aspect is None else aspect + layout_tight = self._tight_active if tight is None else tight if resize is False: # fix the size self._figwidth, self._figheight = self.get_size_inches() self._refwidth = self._refheight = None # critical! @@ -2390,19 +2400,47 @@ def _align_content(): # noqa: E306 self._align_super_labels(side, renderer) self._align_super_title(renderer) + before = self._layout_signature() + # Update the layout # WARNING: Tried to avoid two figure resizes but made # subsequent tight layout really weird. Have to resize twice. _draw_content() if not gs: + self._layout_dirty = False return - if aspect: + if layout_aspect: gs._auto_layout_aspect() _align_content() - if tight: + if layout_tight: gs._auto_layout_tight(renderer) _align_content() + after = self._layout_signature() + self._layout_dirty = before != after + if self._layout_dirty and getattr(self, "canvas", None): + # Only schedule when an interactive manager exists to avoid recursion. + if getattr(self.canvas, "manager", None) is not None: + if not getattr(self.canvas, "_is_idle_drawing", False): + self.canvas.draw_idle() + + def _layout_signature(self): + """ + Snapshot layout-defining state to detect convergence across draws. + """ + gs = self.gridspec + if not gs: + return None + return ( + tuple(self.get_size_inches()), + gs._left_default, + gs._right_default, + gs._bottom_default, + gs._top_default, + tuple(gs._hspace_total_default), + tuple(gs._wspace_total_default), + ) + @warnings._rename_kwargs( "0.10.0", mathtext_fallback="uplt.rc.mathtext_fallback = {}" ) @@ -2780,6 +2818,7 @@ def colorbar( pad=pad, ) cb = ax.colorbar(mappable, values, loc="fill", **kwargs) + self._layout_dirty = True return cb @docstring._concatenate_inherited @@ -2995,6 +3034,7 @@ def legend( pad=pad, ) leg = ax.legend(handles, labels, loc="fill", **kwargs) + self._layout_dirty = True return leg @docstring._snippet_manager diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 6f4c2d229..3aa462612 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1129,6 +1129,7 @@ def _update_params( wratios=None, width_ratios=None, height_ratios=None, + layout_array=None, ): """ Update the user-specified properties. diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 7a2dc6cb8..81cd81b0a 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -988,6 +988,12 @@ def copy(self): "name. If ``None``, a custom ultraplot style is used. " "If ``'default'``, the default matplotlib style is used.", ), + "animation.force_draw_idle": ( + True, + _validate_bool, + "Whether to force `draw_idle` to call `draw` during animations for " + "ultraplot figures to avoid backend redraw artifacts.", + ), # A-b-c labels "abc": ( False,