diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index dad82f6af4fb..11960b67037b 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -1331,3 +1331,19 @@ def test_draw_text_as_path_fallback(monkeypatch): _test_complex_shaping(subfig[0]) _test_text_features(subfig[1]) _test_text_language(subfig[2]) + + +def test_annotation_patchA_not_overridden(): + # Test that manually setting patchA on annotation arrow is not + # overridden by update_positions. See GitHub issue #28316. + fig, ax = plt.subplots() + ann = ax.annotate( + '', + xy=(0.5, 0.6), + xytext=(0.2, 0.5), + arrowprops=dict(arrowstyle="->") + ) + text = ax.text(0.2, 0.5, 'Text', ha='center', va='center') + ann.arrow_patch.set_patchA(text) + fig.draw_without_rendering() + assert ann.arrow_patch.patchA is text diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 9c6478f9c7df..4d104e51574c 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -2199,7 +2199,10 @@ def update_positions(self, renderer): xy=(bbox.x0 - pad / 2, bbox.y0 - pad / 2), width=bbox.width + pad, height=bbox.height + pad, transform=IdentityTransform(), clip_on=False) - self.arrow_patch.set_patchA(patchA) + if self.arrow_patch.patchA is getattr( + self, '_internal_patchA', None): + self.arrow_patch.set_patchA(patchA) + self._internal_patchA = patchA @artist.allow_rasterization def draw(self, renderer): diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index a279de0dfd8b..cedf451005f0 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -2662,6 +2662,67 @@ def get_matrix(self): return self._mtx +class IndirectTransform(Transform): + """ + A transform that wraps a callable and resolves it lazily at draw time. + + This is useful when the actual transform depends on something not + known until draw time, like another artist's bounding box. + + Parameters + ---------- + func : callable + A callable that takes no arguments and returns a `.Transform` + or `.BboxBase`. + + Examples + -------- + :: + + tr = IndirectTransform( + lambda: BboxTransformTo(some_artist.get_window_extent()) + ) + """ + + input_dims = 2 + output_dims = 2 + + def __init__(self, func, **kwargs): + if not callable(func): + raise TypeError( + f"func must be callable, not {type(func).__name__!r}" + ) + super().__init__(**kwargs) + self._func = func + + def _resolve(self): + """Call the wrapped function and return a proper Transform.""" + result = self._func() + if isinstance(result, BboxBase): + return BboxTransformTo(result) + elif isinstance(result, Transform): + return result + raise TypeError( + f"func must return a Transform or BboxBase, " + f"not {type(result).__name__!r}" + ) + + def get_affine(self): + return self._resolve().get_affine() + + def frozen(self): + return self._resolve().frozen() + + def inverted(self): + return self._resolve().inverted() + + def transform_affine(self, points): + return self._resolve().transform_affine(points) + + def transform_non_affine(self, points): + return self._resolve().transform_non_affine(points) + + class BboxTransformFrom(Affine2DBase): """ `BboxTransformFrom` linearly transforms points from a given `Bbox` to the diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index ebee3954a3a7..4e2cb4682f87 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -3,7 +3,7 @@ from .patches import Patch from .figure import Figure import numpy as np from numpy.typing import ArrayLike -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from typing import Literal DEBUG: bool @@ -346,3 +346,9 @@ def offset_copy( class _ScaledRotation(Affine2DBase): def __init__(self, theta: float, trans_shift: Transform) -> None: ... def get_matrix(self) -> np.ndarray: ... + + +class IndirectTransform(Transform): + def __init__(self, func: Callable[..., Transform], **kwargs) -> None: ... + def transform_non_affine(self, points) -> np.ndarray: ... + def transform_affine(self, points) -> np.ndarray: ...