From 8121711410d25e9ed74fddc2a9e39caff4f754c1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 09:49:26 +1000 Subject: [PATCH 1/7] closes #679 by restoring alignment for mixed-span subplot layouts when one of the axes uses a fixed aspect ratio. The regression showed up in arrangements like [[1, 2], [1, 3]], where an equal-aspect axis on the left would shrink inside its gridspec slot but the stacked axes on the right would keep their full vertical extent and visibly stick out above and below it. This change teaches the figure layout pass to propagate the aspect-constrained bounds across the neighboring subplots that share the same span, and adds a regression test for both the legacy and UltraLayout code paths so the layout stays visually consistent going forward. --- ultraplot/figure.py | 81 +++++++++++++++++++++++++++++ ultraplot/tests/test_ultralayout.py | 20 +++++++ 2 files changed, 101 insertions(+) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 4f1bedb92..569c3f230 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1164,6 +1164,85 @@ def _snap_axes_to_pixel_grid(self, renderer) -> None: which="both", ) + def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: + """ + Propagate aspect-constrained spanning axes boxes across sibling rows/columns. + + When a fixed-aspect subplot spans multiple rows or columns, matplotlib shrinks + just that axes inside its gridspec slot. In layouts like ``[[1, 2], [1, 3]]`` + this leaves the adjacent stack slightly taller or wider than the spanning axes. + Here we remap the sibling subplot slots onto the aspect-constrained box so the + overall geometry stays aligned. + """ + axes = list(self._iter_axes(hidden=False, children=False, panels=False)) + if not axes: + return + + spans = [] + for ax in axes: + try: + aspect = ax.get_aspect() + if aspect == "auto": + continue + ax.apply_aspect() + ss = ax.get_subplotspec().get_topmost_subplotspec() + row1, row2, col1, col2 = ss._get_rows_columns() + slot = ss.get_position(self) + pos = ax.get_position(original=False) + except Exception: + continue + + if row2 > row1 and ( + abs(pos.y0 - slot.y0) > tol + or abs((pos.y0 + pos.height) - (slot.y0 + slot.height)) > tol + ): + spans.append(("y", row1, row2, slot, pos, ax)) + if col2 > col1 and ( + abs(pos.x0 - slot.x0) > tol + or abs((pos.x0 + pos.width) - (slot.x0 + slot.width)) > tol + ): + spans.append(("x", col1, col2, slot, pos, ax)) + + for axis, start, stop, slot, pos, ref_ax in spans: + slot0 = slot.y0 if axis == "y" else slot.x0 + slotsize = slot.height if axis == "y" else slot.width + pos0 = pos.y0 if axis == "y" else pos.x0 + possize = pos.height if axis == "y" else pos.width + if slotsize <= tol or possize <= tol: + continue + + for ax in axes: + if ax is ref_ax: + continue + try: + if ax.get_aspect() != "auto": + continue + ss = ax.get_subplotspec().get_topmost_subplotspec() + row1, row2, col1, col2 = ss._get_rows_columns() + if axis == "y": + if row1 < start or row2 > stop: + continue + else: + if col1 < start or col2 > stop: + continue + old = ss.get_position(self) + except Exception: + continue + + if axis == "y": + rel0 = (old.y0 - slot0) / slotsize + rel1 = (old.y0 + old.height - slot0) / slotsize + new0 = pos0 + rel0 * possize + new1 = pos0 + rel1 * possize + bounds = [old.x0, new0, old.width, new1 - new0] + else: + rel0 = (old.x0 - slot0) / slotsize + rel1 = (old.x0 + old.width - slot0) / slotsize + new0 = pos0 + rel0 * possize + new1 = pos0 + rel1 * possize + bounds = [new0, old.y0, new1 - new0, old.height] + ax.set_position(bounds, which="both") + def _share_ticklabels(self, *, axis: str) -> None: """ Tick label sharing is determined at the figure level. While @@ -2979,9 +3058,11 @@ def _align_content(): # noqa: E306 return if aspect: gs._auto_layout_aspect() + self._align_aspect_constrained_axes() _align_content() if tight: gs._auto_layout_tight(renderer) + self._align_aspect_constrained_axes() _align_content() @warnings._rename_kwargs( diff --git a/ultraplot/tests/test_ultralayout.py b/ultraplot/tests/test_ultralayout.py index 3ea43b1d8..8a72b7aab 100644 --- a/ultraplot/tests/test_ultralayout.py +++ b/ultraplot/tests/test_ultralayout.py @@ -184,6 +184,26 @@ def test_ultralayout_panel_alignment_matches_parent(): uplt.close(fig) +@pytest.mark.parametrize("ultra_layout", [False, True]) +def test_fixed_aspect_spanning_axes_keeps_adjacent_stack_aligned(ultra_layout): + """Fixed-aspect spanning axes should keep adjacent subplot stacks aligned.""" + if ultra_layout: + pytest.importorskip("kiwisolver") + + fig, axs = uplt.subplots(array=[[1, 2], [1, 3]], ultra_layout=ultra_layout) + axs[0].plot([0, 1], [0, 1]) + axs[0].format(aspect="equal") + fig.canvas.draw() + + left = axs[0].get_position() + top_right = axs[1].get_position() + bottom_right = axs[2].get_position() + + assert np.isclose(top_right.y1, left.y1) + assert np.isclose(bottom_right.y0, left.y0) + uplt.close(fig) + + def test_subplots_with_orthogonal_layout(): """Test creating subplots with orthogonal layout (should work as before).""" layout = [[1, 2], [3, 4]] From c07d2a64ef9332ae6d2118b15a6eeb444e71ff64 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 12:23:19 +1000 Subject: [PATCH 2/7] Refactor and add tests --- ultraplot/figure.py | 158 ++++++++----- ultraplot/tests/test_figure.py | 402 +++++++++++++++++++++++++++++++++ 2 files changed, 502 insertions(+), 58 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 569c3f230..bb995bd8c 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1164,20 +1164,14 @@ def _snap_axes_to_pixel_grid(self, renderer) -> None: which="both", ) - def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: + def _find_aspect_constrained_spans(self, axes, *, tol=1e-9): """ - Propagate aspect-constrained spanning axes boxes across sibling rows/columns. + Identify spanning axes whose aspect constraint caused matplotlib to + shrink them inside their gridspec slot. - When a fixed-aspect subplot spans multiple rows or columns, matplotlib shrinks - just that axes inside its gridspec slot. In layouts like ``[[1, 2], [1, 3]]`` - this leaves the adjacent stack slightly taller or wider than the spanning axes. - Here we remap the sibling subplot slots onto the aspect-constrained box so the - overall geometry stays aligned. + Returns a list of ``(axis, start, stop, slot, pos, ref_ax)`` tuples + where *axis* is ``'y'`` for row-spanning or ``'x'`` for column-spanning. """ - axes = list(self._iter_axes(hidden=False, children=False, panels=False)) - if not axes: - return - spans = [] for ax in axes: try: @@ -1189,7 +1183,7 @@ def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: row1, row2, col1, col2 = ss._get_rows_columns() slot = ss.get_position(self) pos = ax.get_position(original=False) - except Exception: + except (AttributeError, TypeError): continue if row2 > row1 and ( @@ -1202,7 +1196,13 @@ def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: or abs((pos.x0 + pos.width) - (slot.x0 + slot.width)) > tol ): spans.append(("x", col1, col2, slot, pos, ax)) + return spans + def _remap_axes_to_span(self, axes, spans, *, tol=1e-9): + """ + Remap auto-aspect sibling axes so they align with the + aspect-constrained bounds described by *spans*. + """ for axis, start, stop, slot, pos, ref_ax in spans: slot0 = slot.y0 if axis == "y" else slot.x0 slotsize = slot.height if axis == "y" else slot.width @@ -1226,7 +1226,7 @@ def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: if col1 < start or col2 > stop: continue old = ss.get_position(self) - except Exception: + except (AttributeError, TypeError): continue if axis == "y": @@ -1243,6 +1243,22 @@ def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: bounds = [new0, old.y0, new1 - new0, old.height] ax.set_position(bounds, which="both") + def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: + """ + Propagate aspect-constrained spanning axes boxes across sibling rows/columns. + + When a fixed-aspect subplot spans multiple rows or columns, matplotlib shrinks + just that axes inside its gridspec slot. In layouts like ``[[1, 2], [1, 3]]`` + this leaves the adjacent stack slightly taller or wider than the spanning axes. + Here we remap the sibling subplot slots onto the aspect-constrained box so the + overall geometry stays aligned. + """ + axes = list(self._iter_axes(hidden=False, children=False, panels=False)) + if not axes: + return + spans = self._find_aspect_constrained_spans(axes, tol=tol) + self._remap_axes_to_span(axes, spans, tol=tol) + def _share_ticklabels(self, *, axis: str) -> None: """ Tick label sharing is determined at the figure level. While @@ -2641,6 +2657,59 @@ def _align_super_title(self, renderer): y = y_target - y_bbox self._suptitle.set_position((x, y)) + @staticmethod + def _deduplicate_axes(axes): + """ + Resolve panel parents and remove duplicates, preserving order. + """ + seen = set() + unique = [] + for ax in axes: + ax = ax._panel_parent or ax + ax_id = id(ax) + if ax_id not in seen: + seen.add(ax_id) + unique.append(ax) + return unique + + @staticmethod + def _normalize_title_alignment(loc): + """ + Convert a *loc* string to a horizontal alignment for ``Text.set_ha``. + """ + align = _translate_loc(loc, "text") + match align: + case "left" | "outer left" | "upper left" | "lower left": + return "left" + case "center" | "upper center" | "lower center": + return "center" + case "right" | "outer right" | "upper right" | "lower right": + return "right" + case _: + raise ValueError(f"Invalid shared subplot title location {loc!r}.") + + @staticmethod + def _resolve_title_props(fontdict, kwargs): + """ + Build the property dict for a title from rc defaults, *fontdict*, + and extra *kwargs*. + """ + kw = rc.fill( + { + "size": "title.size", + "weight": "title.weight", + "color": "title.color", + "family": "font.family", + }, + context=True, + ) + if "color" in kw and kw["color"] == "auto": + del kw["color"] + if fontdict: + kw.update(fontdict) + kw.update(kwargs) + return kw + def _update_subset_title( self, axes: Iterable[paxes.Axes], @@ -2673,16 +2742,7 @@ def _update_subset_title( if not axes: raise ValueError("Need at least one axes to create a shared subplot title.") - seen = set() - unique_axes = [] - for ax in axes: - ax = ax._panel_parent or ax - ax_id = id(ax) - if ax_id in seen: - continue - seen.add(ax_id) - unique_axes.append(ax) - axes = unique_axes + axes = self._deduplicate_axes(axes) if len(axes) < 2: return axes[0].set_title( title, fontdict=fontdict, loc=loc, pad=pad, y=y, **kwargs @@ -2690,30 +2750,9 @@ def _update_subset_title( key = tuple(sorted(id(ax) for ax in axes)) group = self._subset_title_dict.get(key) - kw = rc.fill( - { - "size": "title.size", - "weight": "title.weight", - "color": "title.color", - "family": "font.family", - }, - context=True, - ) - if "color" in kw and kw["color"] == "auto": - del kw["color"] - if fontdict: - kw.update(fontdict) - kw.update(kwargs) - align = _translate_loc(loc, "text") - match align: - case "left" | "outer left" | "upper left" | "lower left": - align = "left" - case "center" | "upper center" | "lower center": - align = "center" - case "right" | "outer right" | "upper right" | "lower right": - align = "right" - case _: - raise ValueError(f"Invalid shared subplot title location {loc!r}.") + kw = self._resolve_title_props(fontdict, kwargs) + align = self._normalize_title_alignment(loc) + if group is None: artist = self.text( 0.5, @@ -2739,6 +2778,16 @@ def _update_subset_title( artist.update(kw) return artist + def _visible_subset_group_axes(self, group): + """ + Return visible axes from a subset-title group that belong to this figure. + """ + return [ + ax + for ax in group["axes"] + if ax is not None and ax.figure is self and ax.get_visible() + ] + def _get_subset_title_bbox( self, ax: paxes.Axes, renderer ) -> mtransforms.Bbox | None: @@ -2757,15 +2806,12 @@ def _get_subset_title_bbox( if not artist.get_visible() or not artist.get_text(): continue axs = [ - group_ax._panel_parent or group_ax - for group_ax in group["axes"] - if group_ax is not None - and group_ax.figure is self - and group_ax.get_visible() + a._panel_parent or a + for a in self._visible_subset_group_axes(group) ] if not axs or ax not in axs: continue - top = min(group_ax._range_subplotspec("y")[0] for group_ax in axs) + top = min(a._range_subplotspec("y")[0] for a in axs) if ax._range_subplotspec("y")[0] == top: bboxes.append(artist.get_window_extent(renderer)) return mtransforms.Bbox.union(bboxes) if bboxes else None @@ -2777,11 +2823,7 @@ def _align_subset_titles(self, renderer): for key in list(self._subset_title_dict): group = self._subset_title_dict[key] artist = group["artist"] - axs = [ - ax - for ax in group["axes"] - if ax is not None and ax.figure is self and ax.get_visible() - ] + axs = self._visible_subset_group_axes(group) if not axs: artist.remove() del self._subset_title_dict[key] diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 53a297399..72d4a586b 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -479,3 +479,405 @@ def test_subplots_pixelsnap_aligns_axes_bounds(): [bbox.x0 * width, bbox.y0 * height, bbox.x1 * width, bbox.y1 * height] ) assert np.allclose(coords, np.round(coords), atol=1e-8) + + + +def test_figure_repr(): + fig, axs = uplt.subplots(ncols=2, nrows=3) + r = repr(fig) + assert "Figure(" in r + assert "nrows=3" in r + assert "ncols=2" in r + uplt.close(fig) + + + +class TestShareLabelGroups: + def test_register_share_label_group_basic(self): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("shared x") + axs[1].set_xlabel("also x") + fig._register_share_label_group( + [axs[0], axs[1]], target="x", source=axs[0] + ) + assert fig._has_share_label_groups("x") + assert fig._is_share_label_group_member(axs[0], "x") + assert fig._is_share_label_group_member(axs[1], "x") + assert not fig._is_share_label_group_member(axs[2], "x") + uplt.close(fig) + + def test_register_share_label_group_y(self): + fig, axs = uplt.subplots(nrows=3) + axs[0].set_ylabel("shared y") + axs[1].set_ylabel("also y") + fig._register_share_label_group( + [axs[0], axs[1]], target="y", source=axs[0] + ) + assert fig._has_share_label_groups("y") + assert fig._is_share_label_group_member(axs[0], "y") + uplt.close(fig) + + def test_register_empty_and_single_axes(self): + fig, axs = uplt.subplots(ncols=2) + fig._register_share_label_group([], target="x") + assert not fig._has_share_label_groups("x") + fig._register_share_label_group([axs[0]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) + + def test_register_deduplicates(self): + fig, axs = uplt.subplots(ncols=2) + axs[0].set_xlabel("x") + fig._register_share_label_group( + [axs[0], axs[0], axs[1]], target="x" + ) + assert fig._has_share_label_groups("x") + uplt.close(fig) + + def test_clear_share_label_groups_all(self): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("x") + fig._register_share_label_group([axs[0], axs[1]], target="x") + fig._register_share_label_group([axs[0], axs[1]], target="y") + assert fig._has_share_label_groups("x") + fig._clear_share_label_groups() + assert not fig._has_share_label_groups("x") + assert not fig._has_share_label_groups("y") + uplt.close(fig) + + def test_clear_share_label_groups_by_axes(self): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("x0") + axs[2].set_xlabel("x2") + fig._register_share_label_group([axs[0], axs[1]], target="x") + fig._clear_share_label_groups(axes=[axs[0]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) + + def test_clear_share_label_groups_with_spanning_labels(self): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("shared x") + axs[1].set_xlabel("shared x") + fig._register_share_label_group( + [axs[0], axs[1]], target="x", source=axs[0] + ) + fig.canvas.draw() + fig._clear_share_label_groups(axes=[axs[0], axs[1]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) + + def test_apply_share_label_groups(self): + fig, axs = uplt.subplots(ncols=3, share=False) + axs[0].set_xlabel("shared label") + axs[1].set_xlabel("") + fig._register_share_label_group( + [axs[0], axs[1]], target="x", source=axs[0] + ) + fig.canvas.draw() + uplt.close(fig) + + def test_apply_share_label_groups_y(self): + fig, axs = uplt.subplots(nrows=3, share=False) + axs[0].set_ylabel("shared label") + axs[1].set_ylabel("") + fig._register_share_label_group( + [axs[0], axs[1]], target="y", source=axs[0] + ) + fig.canvas.draw() + uplt.close(fig) + + def test_register_for_side_updates_existing_group(self): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("original") + fig._register_share_label_group( + [axs[0], axs[1]], target="x", source=axs[0] + ) + axs[0].set_xlabel("updated") + fig._register_share_label_group( + [axs[0], axs[1]], target="x", source=axs[0] + ) + fig.canvas.draw() + uplt.close(fig) + + def test_mixed_label_position_splits(self): + fig, axs = uplt.subplots(ncols=3, share=False) + axs[0].set_xlabel("bottom") + axs[1].xaxis.set_label_position("top") + axs[1].set_xlabel("top") + axs[2].set_xlabel("bottom") + fig._register_share_label_group( + [axs[0], axs[1], axs[2]], target="x" + ) + fig.canvas.draw() + uplt.close(fig) + + + +class TestSubsetTitleHelpers: + def test_deduplicate_axes(self): + fig, axs = uplt.subplots(ncols=3) + result = fig._deduplicate_axes([axs[0], axs[0], axs[1]]) + assert len(result) == 2 + uplt.close(fig) + + def test_normalize_title_alignment_left(self): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("left") == "left" + + def test_normalize_title_alignment_center(self): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("center") == "center" + + def test_normalize_title_alignment_right(self): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("right") == "right" + + def test_normalize_title_alignment_invalid(self): + from ultraplot.figure import Figure + + with pytest.raises((ValueError, KeyError)): + Figure._normalize_title_alignment("invalid_loc_xyz") + + def test_resolve_title_props_defaults(self): + from ultraplot.figure import Figure + + kw = Figure._resolve_title_props(None, {}) + assert isinstance(kw, dict) + + def test_resolve_title_props_with_fontdict(self): + from ultraplot.figure import Figure + + kw = Figure._resolve_title_props({"size": 20}, {"weight": "bold"}) + assert kw["size"] == 20 + assert kw["weight"] == "bold" + + def test_visible_subset_group_axes(self): + fig, axs = uplt.subplots(ncols=3) + group = {"axes": list(axs), "artist": None} + result = fig._visible_subset_group_axes(group) + assert len(result) == 3 + uplt.close(fig) + + def test_update_subset_title_single_axes_delegates(self): + fig, axs = uplt.subplots(ncols=3) + artist = fig._update_subset_title([axs[0]], "Solo title") + assert artist.get_text() == "Solo title" + uplt.close(fig) + + def test_update_subset_title_empty_raises(self): + fig, axs = uplt.subplots(ncols=2) + with pytest.raises(ValueError, match="Need at least one"): + fig._update_subset_title([], "No axes") + uplt.close(fig) + + def test_update_subset_title_creates_group(self): + fig, axs = uplt.subplots(ncols=3) + artist = fig._update_subset_title( + [axs[0], axs[1]], "Two-panel title" + ) + assert artist.get_text() == "Two-panel title" + assert len(fig._subset_title_dict) == 1 + uplt.close(fig) + + def test_update_subset_title_update_existing(self): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "First") + fig._update_subset_title([axs[0], axs[1]], "Updated") + assert len(fig._subset_title_dict) == 1 + group = next(iter(fig._subset_title_dict.values())) + assert group["artist"].get_text() == "Updated" + uplt.close(fig) + + def test_get_subset_title_bbox_returns_none_when_empty(self): + fig, axs = uplt.subplots(ncols=2) + renderer = fig._get_renderer() + assert fig._get_subset_title_bbox(axs[0], renderer) is None + uplt.close(fig) + + def test_get_subset_title_bbox_for_top_row_only(self): + fig, axs = uplt.subplots(nrows=2, ncols=2) + fig._update_subset_title([axs[0], axs[1]], "Top row title") + fig.canvas.draw() + renderer = fig._get_renderer() + bbox_top = fig._get_subset_title_bbox(axs[0], renderer) + bbox_bottom = fig._get_subset_title_bbox(axs[2], renderer) + assert bbox_top is not None + assert bbox_bottom is None + uplt.close(fig) + + def test_align_subset_titles_removes_orphaned(self): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "Will be orphaned") + # Artificially remove the axes from the figure + key = next(iter(fig._subset_title_dict)) + fig._subset_title_dict[key]["axes"] = [] + renderer = fig._get_renderer() + fig._align_subset_titles(renderer) + assert len(fig._subset_title_dict) == 0 + uplt.close(fig) + + def test_align_subset_titles_with_manual_y(self): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title( + [axs[0], axs[1]], "Manual Y", y=0.95 + ) + fig.canvas.draw() + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert np.isclose(artist.get_position()[1], 0.95) + uplt.close(fig) + + def test_subset_title_left_alignment(self): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title( + [axs[0], axs[1]], "Left title", loc="left" + ) + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert artist.get_ha() == "left" + uplt.close(fig) + + def test_subset_title_right_alignment(self): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title( + [axs[0], axs[1]], "Right title", loc="right" + ) + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert artist.get_ha() == "right" + uplt.close(fig) + + + +class TestAspectConstrainedHelpers: + def test_find_spans_empty(self): + fig, axs = uplt.subplots(ncols=2) + spans = fig._find_aspect_constrained_spans([]) + assert spans == [] + uplt.close(fig) + + def test_find_spans_no_aspect(self): + fig, axs = uplt.subplots(ncols=2) + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert spans == [] + uplt.close(fig) + + def test_remap_with_empty_spans(self): + fig, axs = uplt.subplots(ncols=2) + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + fig._remap_axes_to_span(axes, []) # should be a no-op + uplt.close(fig) + + def test_align_aspect_constrained_no_axes(self): + fig = uplt.figure() + fig._align_aspect_constrained_axes() # should not raise + uplt.close(fig) + + def test_aspect_row_spanning_layout(self): + fig, axs = uplt.subplots([[1, 2], [1, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert len(spans) >= 1 + assert any(s[0] == "y" for s in spans) + uplt.close(fig) + + def test_aspect_col_spanning_layout(self): + fig, axs = uplt.subplots([[1, 1], [2, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert len(spans) >= 1 + assert any(s[0] == "x" for s in spans) + uplt.close(fig) + + def test_full_align_aspect_row_spanning(self): + fig, axs = uplt.subplots([[1, 2], [1, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + pos0 = axs[0].get_position() + pos1 = axs[1].get_position() + pos2 = axs[2].get_position() + assert pos1.y0 + pos1.height <= pos0.y0 + pos0.height + 0.01 + uplt.close(fig) + + + +def test_add_subplot_three_integer_args(): + fig = uplt.figure() + ax = fig.add_subplot(2, 2, 1) + assert ax is not None + ax2 = fig.add_subplot(2, 2, (3, 4)) + assert ax2 is not None + uplt.close(fig) + + + +def test_explicit_figwidth_figheight(): + fig, axs = uplt.subplots(figwidth=6, figheight=4) + w, h = fig.get_size_inches() + assert np.isclose(w, 6, atol=0.1) + assert np.isclose(h, 4, atol=0.1) + uplt.close(fig) + + +def test_figwidth_overrides_refwidth(): + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + fig, axs = uplt.subplots(figwidth=6, refwidth=3) + conflict_warnings = [ + w for w in record if "conflicting" in str(w.message).lower() + ] + assert len(conflict_warnings) >= 1 + uplt.close(fig) + + +def test_figheight_overrides_refheight(): + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + fig, axs = uplt.subplots(figheight=4, refheight=2) + conflict_warnings = [ + w for w in record if "conflicting" in str(w.message).lower() + ] + assert len(conflict_warnings) >= 1 + uplt.close(fig) + + +def test_journal_size(): + fig, axs = uplt.subplots(journal="ams1") + fig.canvas.draw() + uplt.close(fig) + + +def test_subplots_with_gridspec_kw_warns(): + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + fig, axs = uplt.subplots( + [[1, 2], [3, 4]], gridspec_kw={"hspace": 0.5} + ) + kw_warnings = [ + w for w in record if "not necessary" in str(w.message).lower() + ] + assert len(kw_warnings) >= 1 + uplt.close(fig) + + +def test_refaspect_as_tuple(): + fig, axs = uplt.subplots(refaspect=(16, 9)) + fig.canvas.draw() + uplt.close(fig) From d5deaf403d3df7a035fa92fc16fa8f216e18dc4d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 12:38:56 +1000 Subject: [PATCH 3/7] Black formatting --- ultraplot/figure.py | 5 +-- ultraplot/tests/test_figure.py | 74 ++++++++-------------------------- 2 files changed, 18 insertions(+), 61 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index bb995bd8c..685e1c96b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2805,10 +2805,7 @@ def _get_subset_title_bbox( artist = group["artist"] if not artist.get_visible() or not artist.get_text(): continue - axs = [ - a._panel_parent or a - for a in self._visible_subset_group_axes(group) - ] + axs = [a._panel_parent or a for a in self._visible_subset_group_axes(group)] if not axs or ax not in axs: continue top = min(a._range_subplotspec("y")[0] for a in axs) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 72d4a586b..73d500faa 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -481,7 +481,6 @@ def test_subplots_pixelsnap_aligns_axes_bounds(): assert np.allclose(coords, np.round(coords), atol=1e-8) - def test_figure_repr(): fig, axs = uplt.subplots(ncols=2, nrows=3) r = repr(fig) @@ -491,15 +490,12 @@ def test_figure_repr(): uplt.close(fig) - class TestShareLabelGroups: def test_register_share_label_group_basic(self): fig, axs = uplt.subplots(ncols=3) axs[0].set_xlabel("shared x") axs[1].set_xlabel("also x") - fig._register_share_label_group( - [axs[0], axs[1]], target="x", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) assert fig._has_share_label_groups("x") assert fig._is_share_label_group_member(axs[0], "x") assert fig._is_share_label_group_member(axs[1], "x") @@ -510,9 +506,7 @@ def test_register_share_label_group_y(self): fig, axs = uplt.subplots(nrows=3) axs[0].set_ylabel("shared y") axs[1].set_ylabel("also y") - fig._register_share_label_group( - [axs[0], axs[1]], target="y", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) assert fig._has_share_label_groups("y") assert fig._is_share_label_group_member(axs[0], "y") uplt.close(fig) @@ -528,9 +522,7 @@ def test_register_empty_and_single_axes(self): def test_register_deduplicates(self): fig, axs = uplt.subplots(ncols=2) axs[0].set_xlabel("x") - fig._register_share_label_group( - [axs[0], axs[0], axs[1]], target="x" - ) + fig._register_share_label_group([axs[0], axs[0], axs[1]], target="x") assert fig._has_share_label_groups("x") uplt.close(fig) @@ -558,9 +550,7 @@ def test_clear_share_label_groups_with_spanning_labels(self): fig, axs = uplt.subplots(ncols=3) axs[0].set_xlabel("shared x") axs[1].set_xlabel("shared x") - fig._register_share_label_group( - [axs[0], axs[1]], target="x", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) fig.canvas.draw() fig._clear_share_label_groups(axes=[axs[0], axs[1]], target="x") assert not fig._has_share_label_groups("x") @@ -570,9 +560,7 @@ def test_apply_share_label_groups(self): fig, axs = uplt.subplots(ncols=3, share=False) axs[0].set_xlabel("shared label") axs[1].set_xlabel("") - fig._register_share_label_group( - [axs[0], axs[1]], target="x", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) fig.canvas.draw() uplt.close(fig) @@ -580,22 +568,16 @@ def test_apply_share_label_groups_y(self): fig, axs = uplt.subplots(nrows=3, share=False) axs[0].set_ylabel("shared label") axs[1].set_ylabel("") - fig._register_share_label_group( - [axs[0], axs[1]], target="y", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) fig.canvas.draw() uplt.close(fig) def test_register_for_side_updates_existing_group(self): fig, axs = uplt.subplots(ncols=3) axs[0].set_xlabel("original") - fig._register_share_label_group( - [axs[0], axs[1]], target="x", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) axs[0].set_xlabel("updated") - fig._register_share_label_group( - [axs[0], axs[1]], target="x", source=axs[0] - ) + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) fig.canvas.draw() uplt.close(fig) @@ -605,14 +587,11 @@ def test_mixed_label_position_splits(self): axs[1].xaxis.set_label_position("top") axs[1].set_xlabel("top") axs[2].set_xlabel("bottom") - fig._register_share_label_group( - [axs[0], axs[1], axs[2]], target="x" - ) + fig._register_share_label_group([axs[0], axs[1], axs[2]], target="x") fig.canvas.draw() uplt.close(fig) - class TestSubsetTitleHelpers: def test_deduplicate_axes(self): fig, axs = uplt.subplots(ncols=3) @@ -675,9 +654,7 @@ def test_update_subset_title_empty_raises(self): def test_update_subset_title_creates_group(self): fig, axs = uplt.subplots(ncols=3) - artist = fig._update_subset_title( - [axs[0], axs[1]], "Two-panel title" - ) + artist = fig._update_subset_title([axs[0], axs[1]], "Two-panel title") assert artist.get_text() == "Two-panel title" assert len(fig._subset_title_dict) == 1 uplt.close(fig) @@ -721,9 +698,7 @@ def test_align_subset_titles_removes_orphaned(self): def test_align_subset_titles_with_manual_y(self): fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title( - [axs[0], axs[1]], "Manual Y", y=0.95 - ) + fig._update_subset_title([axs[0], axs[1]], "Manual Y", y=0.95) fig.canvas.draw() key = next(iter(fig._subset_title_dict)) artist = fig._subset_title_dict[key]["artist"] @@ -732,9 +707,7 @@ def test_align_subset_titles_with_manual_y(self): def test_subset_title_left_alignment(self): fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title( - [axs[0], axs[1]], "Left title", loc="left" - ) + fig._update_subset_title([axs[0], axs[1]], "Left title", loc="left") key = next(iter(fig._subset_title_dict)) artist = fig._subset_title_dict[key]["artist"] assert artist.get_ha() == "left" @@ -742,16 +715,13 @@ def test_subset_title_left_alignment(self): def test_subset_title_right_alignment(self): fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title( - [axs[0], axs[1]], "Right title", loc="right" - ) + fig._update_subset_title([axs[0], axs[1]], "Right title", loc="right") key = next(iter(fig._subset_title_dict)) artist = fig._subset_title_dict[key]["artist"] assert artist.get_ha() == "right" uplt.close(fig) - class TestAspectConstrainedHelpers: def test_find_spans_empty(self): fig, axs = uplt.subplots(ncols=2) @@ -817,7 +787,6 @@ def test_full_align_aspect_row_spanning(self): uplt.close(fig) - def test_add_subplot_three_integer_args(): fig = uplt.figure() ax = fig.add_subplot(2, 2, 1) @@ -827,7 +796,6 @@ def test_add_subplot_three_integer_args(): uplt.close(fig) - def test_explicit_figwidth_figheight(): fig, axs = uplt.subplots(figwidth=6, figheight=4) w, h = fig.get_size_inches() @@ -840,9 +808,7 @@ def test_figwidth_overrides_refwidth(): with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") fig, axs = uplt.subplots(figwidth=6, refwidth=3) - conflict_warnings = [ - w for w in record if "conflicting" in str(w.message).lower() - ] + conflict_warnings = [w for w in record if "conflicting" in str(w.message).lower()] assert len(conflict_warnings) >= 1 uplt.close(fig) @@ -851,9 +817,7 @@ def test_figheight_overrides_refheight(): with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") fig, axs = uplt.subplots(figheight=4, refheight=2) - conflict_warnings = [ - w for w in record if "conflicting" in str(w.message).lower() - ] + conflict_warnings = [w for w in record if "conflicting" in str(w.message).lower()] assert len(conflict_warnings) >= 1 uplt.close(fig) @@ -867,12 +831,8 @@ def test_journal_size(): def test_subplots_with_gridspec_kw_warns(): with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") - fig, axs = uplt.subplots( - [[1, 2], [3, 4]], gridspec_kw={"hspace": 0.5} - ) - kw_warnings = [ - w for w in record if "not necessary" in str(w.message).lower() - ] + fig, axs = uplt.subplots([[1, 2], [3, 4]], gridspec_kw={"hspace": 0.5}) + kw_warnings = [w for w in record if "not necessary" in str(w.message).lower()] assert len(kw_warnings) >= 1 uplt.close(fig) From 46b4ee230d89a6f99e43dee3ec02368c78ada14a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 13:37:12 +1000 Subject: [PATCH 4/7] Flatten tests --- ultraplot/tests/test_figure.py | 573 +++++++++++++++++---------------- 1 file changed, 301 insertions(+), 272 deletions(-) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 73d500faa..22d3dc61b 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -490,301 +490,330 @@ def test_figure_repr(): uplt.close(fig) -class TestShareLabelGroups: - def test_register_share_label_group_basic(self): - fig, axs = uplt.subplots(ncols=3) - axs[0].set_xlabel("shared x") - axs[1].set_xlabel("also x") - fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) - assert fig._has_share_label_groups("x") - assert fig._is_share_label_group_member(axs[0], "x") - assert fig._is_share_label_group_member(axs[1], "x") - assert not fig._is_share_label_group_member(axs[2], "x") - uplt.close(fig) - - def test_register_share_label_group_y(self): - fig, axs = uplt.subplots(nrows=3) - axs[0].set_ylabel("shared y") - axs[1].set_ylabel("also y") - fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) - assert fig._has_share_label_groups("y") - assert fig._is_share_label_group_member(axs[0], "y") - uplt.close(fig) - - def test_register_empty_and_single_axes(self): - fig, axs = uplt.subplots(ncols=2) - fig._register_share_label_group([], target="x") - assert not fig._has_share_label_groups("x") - fig._register_share_label_group([axs[0]], target="x") - assert not fig._has_share_label_groups("x") - uplt.close(fig) - - def test_register_deduplicates(self): - fig, axs = uplt.subplots(ncols=2) - axs[0].set_xlabel("x") - fig._register_share_label_group([axs[0], axs[0], axs[1]], target="x") - assert fig._has_share_label_groups("x") - uplt.close(fig) - - def test_clear_share_label_groups_all(self): - fig, axs = uplt.subplots(ncols=3) - axs[0].set_xlabel("x") - fig._register_share_label_group([axs[0], axs[1]], target="x") - fig._register_share_label_group([axs[0], axs[1]], target="y") - assert fig._has_share_label_groups("x") - fig._clear_share_label_groups() - assert not fig._has_share_label_groups("x") - assert not fig._has_share_label_groups("y") - uplt.close(fig) - - def test_clear_share_label_groups_by_axes(self): - fig, axs = uplt.subplots(ncols=3) - axs[0].set_xlabel("x0") - axs[2].set_xlabel("x2") - fig._register_share_label_group([axs[0], axs[1]], target="x") - fig._clear_share_label_groups(axes=[axs[0]], target="x") - assert not fig._has_share_label_groups("x") - uplt.close(fig) - - def test_clear_share_label_groups_with_spanning_labels(self): - fig, axs = uplt.subplots(ncols=3) - axs[0].set_xlabel("shared x") - axs[1].set_xlabel("shared x") - fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) - fig.canvas.draw() - fig._clear_share_label_groups(axes=[axs[0], axs[1]], target="x") - assert not fig._has_share_label_groups("x") - uplt.close(fig) - - def test_apply_share_label_groups(self): - fig, axs = uplt.subplots(ncols=3, share=False) - axs[0].set_xlabel("shared label") - axs[1].set_xlabel("") - fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) - fig.canvas.draw() - uplt.close(fig) +def test_register_share_label_group_basic(): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("shared x") + axs[1].set_xlabel("also x") + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) + assert fig._has_share_label_groups("x") + assert fig._is_share_label_group_member(axs[0], "x") + assert fig._is_share_label_group_member(axs[1], "x") + assert not fig._is_share_label_group_member(axs[2], "x") + uplt.close(fig) - def test_apply_share_label_groups_y(self): - fig, axs = uplt.subplots(nrows=3, share=False) - axs[0].set_ylabel("shared label") - axs[1].set_ylabel("") - fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) - fig.canvas.draw() - uplt.close(fig) - - def test_register_for_side_updates_existing_group(self): - fig, axs = uplt.subplots(ncols=3) - axs[0].set_xlabel("original") - fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) - axs[0].set_xlabel("updated") - fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) - fig.canvas.draw() - uplt.close(fig) - - def test_mixed_label_position_splits(self): - fig, axs = uplt.subplots(ncols=3, share=False) - axs[0].set_xlabel("bottom") - axs[1].xaxis.set_label_position("top") - axs[1].set_xlabel("top") - axs[2].set_xlabel("bottom") - fig._register_share_label_group([axs[0], axs[1], axs[2]], target="x") - fig.canvas.draw() - uplt.close(fig) +def test_register_share_label_group_y(): + fig, axs = uplt.subplots(nrows=3) + axs[0].set_ylabel("shared y") + axs[1].set_ylabel("also y") + fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) + assert fig._has_share_label_groups("y") + assert fig._is_share_label_group_member(axs[0], "y") + uplt.close(fig) -class TestSubsetTitleHelpers: - def test_deduplicate_axes(self): - fig, axs = uplt.subplots(ncols=3) - result = fig._deduplicate_axes([axs[0], axs[0], axs[1]]) - assert len(result) == 2 - uplt.close(fig) - def test_normalize_title_alignment_left(self): - from ultraplot.figure import Figure +def test_register_share_label_group_empty_and_single(): + fig, axs = uplt.subplots(ncols=2) + fig._register_share_label_group([], target="x") + assert not fig._has_share_label_groups("x") + fig._register_share_label_group([axs[0]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) - assert Figure._normalize_title_alignment("left") == "left" - def test_normalize_title_alignment_center(self): - from ultraplot.figure import Figure +def test_register_share_label_group_deduplicates(): + fig, axs = uplt.subplots(ncols=2) + axs[0].set_xlabel("x") + fig._register_share_label_group([axs[0], axs[0], axs[1]], target="x") + assert fig._has_share_label_groups("x") + uplt.close(fig) - assert Figure._normalize_title_alignment("center") == "center" - def test_normalize_title_alignment_right(self): - from ultraplot.figure import Figure +def test_clear_share_label_groups_all(): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("x") + fig._register_share_label_group([axs[0], axs[1]], target="x") + fig._register_share_label_group([axs[0], axs[1]], target="y") + assert fig._has_share_label_groups("x") + fig._clear_share_label_groups() + assert not fig._has_share_label_groups("x") + assert not fig._has_share_label_groups("y") + uplt.close(fig) - assert Figure._normalize_title_alignment("right") == "right" - def test_normalize_title_alignment_invalid(self): - from ultraplot.figure import Figure +def test_clear_share_label_groups_by_axes(): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("x0") + axs[2].set_xlabel("x2") + fig._register_share_label_group([axs[0], axs[1]], target="x") + fig._clear_share_label_groups(axes=[axs[0]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) - with pytest.raises((ValueError, KeyError)): - Figure._normalize_title_alignment("invalid_loc_xyz") - def test_resolve_title_props_defaults(self): - from ultraplot.figure import Figure +def test_clear_share_label_groups_with_spanning_labels(): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("shared x") + axs[1].set_xlabel("shared x") + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) + fig.canvas.draw() + fig._clear_share_label_groups(axes=[axs[0], axs[1]], target="x") + assert not fig._has_share_label_groups("x") + uplt.close(fig) - kw = Figure._resolve_title_props(None, {}) - assert isinstance(kw, dict) - def test_resolve_title_props_with_fontdict(self): - from ultraplot.figure import Figure +def test_apply_share_label_groups(): + fig, axs = uplt.subplots(ncols=3, share=False) + axs[0].set_xlabel("shared label") + axs[1].set_xlabel("") + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) + fig.canvas.draw() + uplt.close(fig) - kw = Figure._resolve_title_props({"size": 20}, {"weight": "bold"}) - assert kw["size"] == 20 - assert kw["weight"] == "bold" - def test_visible_subset_group_axes(self): - fig, axs = uplt.subplots(ncols=3) - group = {"axes": list(axs), "artist": None} - result = fig._visible_subset_group_axes(group) - assert len(result) == 3 - uplt.close(fig) +def test_apply_share_label_groups_y(): + fig, axs = uplt.subplots(nrows=3, share=False) + axs[0].set_ylabel("shared label") + axs[1].set_ylabel("") + fig._register_share_label_group([axs[0], axs[1]], target="y", source=axs[0]) + fig.canvas.draw() + uplt.close(fig) - def test_update_subset_title_single_axes_delegates(self): - fig, axs = uplt.subplots(ncols=3) - artist = fig._update_subset_title([axs[0]], "Solo title") - assert artist.get_text() == "Solo title" - uplt.close(fig) - def test_update_subset_title_empty_raises(self): - fig, axs = uplt.subplots(ncols=2) - with pytest.raises(ValueError, match="Need at least one"): - fig._update_subset_title([], "No axes") - uplt.close(fig) +def test_register_share_label_group_updates_existing(): + fig, axs = uplt.subplots(ncols=3) + axs[0].set_xlabel("original") + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) + axs[0].set_xlabel("updated") + fig._register_share_label_group([axs[0], axs[1]], target="x", source=axs[0]) + fig.canvas.draw() + uplt.close(fig) - def test_update_subset_title_creates_group(self): - fig, axs = uplt.subplots(ncols=3) - artist = fig._update_subset_title([axs[0], axs[1]], "Two-panel title") - assert artist.get_text() == "Two-panel title" - assert len(fig._subset_title_dict) == 1 - uplt.close(fig) - def test_update_subset_title_update_existing(self): - fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title([axs[0], axs[1]], "First") - fig._update_subset_title([axs[0], axs[1]], "Updated") - assert len(fig._subset_title_dict) == 1 - group = next(iter(fig._subset_title_dict.values())) - assert group["artist"].get_text() == "Updated" - uplt.close(fig) +def test_share_label_group_mixed_label_position_splits(): + fig, axs = uplt.subplots(ncols=3, share=False) + axs[0].set_xlabel("bottom") + axs[1].xaxis.set_label_position("top") + axs[1].set_xlabel("top") + axs[2].set_xlabel("bottom") + fig._register_share_label_group([axs[0], axs[1], axs[2]], target="x") + fig.canvas.draw() + uplt.close(fig) - def test_get_subset_title_bbox_returns_none_when_empty(self): - fig, axs = uplt.subplots(ncols=2) - renderer = fig._get_renderer() - assert fig._get_subset_title_bbox(axs[0], renderer) is None - uplt.close(fig) - def test_get_subset_title_bbox_for_top_row_only(self): - fig, axs = uplt.subplots(nrows=2, ncols=2) - fig._update_subset_title([axs[0], axs[1]], "Top row title") - fig.canvas.draw() - renderer = fig._get_renderer() - bbox_top = fig._get_subset_title_bbox(axs[0], renderer) - bbox_bottom = fig._get_subset_title_bbox(axs[2], renderer) - assert bbox_top is not None - assert bbox_bottom is None - uplt.close(fig) - - def test_align_subset_titles_removes_orphaned(self): - fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title([axs[0], axs[1]], "Will be orphaned") - # Artificially remove the axes from the figure - key = next(iter(fig._subset_title_dict)) - fig._subset_title_dict[key]["axes"] = [] - renderer = fig._get_renderer() - fig._align_subset_titles(renderer) - assert len(fig._subset_title_dict) == 0 - uplt.close(fig) +def test_deduplicate_axes(): + fig, axs = uplt.subplots(ncols=3) + result = fig._deduplicate_axes([axs[0], axs[0], axs[1]]) + assert len(result) == 2 + uplt.close(fig) - def test_align_subset_titles_with_manual_y(self): - fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title([axs[0], axs[1]], "Manual Y", y=0.95) - fig.canvas.draw() - key = next(iter(fig._subset_title_dict)) - artist = fig._subset_title_dict[key]["artist"] - assert np.isclose(artist.get_position()[1], 0.95) - uplt.close(fig) - - def test_subset_title_left_alignment(self): - fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title([axs[0], axs[1]], "Left title", loc="left") - key = next(iter(fig._subset_title_dict)) - artist = fig._subset_title_dict[key]["artist"] - assert artist.get_ha() == "left" - uplt.close(fig) - - def test_subset_title_right_alignment(self): - fig, axs = uplt.subplots(ncols=3) - fig._update_subset_title([axs[0], axs[1]], "Right title", loc="right") - key = next(iter(fig._subset_title_dict)) - artist = fig._subset_title_dict[key]["artist"] - assert artist.get_ha() == "right" - uplt.close(fig) - - -class TestAspectConstrainedHelpers: - def test_find_spans_empty(self): - fig, axs = uplt.subplots(ncols=2) - spans = fig._find_aspect_constrained_spans([]) - assert spans == [] - uplt.close(fig) - - def test_find_spans_no_aspect(self): - fig, axs = uplt.subplots(ncols=2) - axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) - assert spans == [] - uplt.close(fig) - - def test_remap_with_empty_spans(self): - fig, axs = uplt.subplots(ncols=2) - axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - fig._remap_axes_to_span(axes, []) # should be a no-op - uplt.close(fig) - - def test_align_aspect_constrained_no_axes(self): - fig = uplt.figure() - fig._align_aspect_constrained_axes() # should not raise - uplt.close(fig) - - def test_aspect_row_spanning_layout(self): - fig, axs = uplt.subplots([[1, 2], [1, 3]]) - axs[0].set_aspect("equal") - axs[0].plot([0, 1], [0, 1]) - axs[1].plot([0, 1], [0, 1]) - axs[2].plot([0, 1], [0, 1]) - fig.canvas.draw() - axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) - assert len(spans) >= 1 - assert any(s[0] == "y" for s in spans) - uplt.close(fig) - - def test_aspect_col_spanning_layout(self): - fig, axs = uplt.subplots([[1, 1], [2, 3]]) - axs[0].set_aspect("equal") - axs[0].plot([0, 1], [0, 1]) - axs[1].plot([0, 1], [0, 1]) - axs[2].plot([0, 1], [0, 1]) - fig.canvas.draw() - axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) - assert len(spans) >= 1 - assert any(s[0] == "x" for s in spans) - uplt.close(fig) - - def test_full_align_aspect_row_spanning(self): - fig, axs = uplt.subplots([[1, 2], [1, 3]]) - axs[0].set_aspect("equal") - axs[0].plot([0, 1], [0, 1]) - axs[1].plot([0, 1], [0, 1]) - axs[2].plot([0, 1], [0, 1]) - fig.canvas.draw() - pos0 = axs[0].get_position() - pos1 = axs[1].get_position() - pos2 = axs[2].get_position() - assert pos1.y0 + pos1.height <= pos0.y0 + pos0.height + 0.01 - uplt.close(fig) + +def test_normalize_title_alignment_left(): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("left") == "left" + + +def test_normalize_title_alignment_center(): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("center") == "center" + + +def test_normalize_title_alignment_right(): + from ultraplot.figure import Figure + + assert Figure._normalize_title_alignment("right") == "right" + + +def test_normalize_title_alignment_invalid(): + from ultraplot.figure import Figure + + with pytest.raises((ValueError, KeyError)): + Figure._normalize_title_alignment("invalid_loc_xyz") + + +def test_resolve_title_props_defaults(): + from ultraplot.figure import Figure + + kw = Figure._resolve_title_props(None, {}) + assert isinstance(kw, dict) + + +def test_resolve_title_props_with_fontdict(): + from ultraplot.figure import Figure + + kw = Figure._resolve_title_props({"size": 20}, {"weight": "bold"}) + assert kw["size"] == 20 + assert kw["weight"] == "bold" + + +def test_visible_subset_group_axes(): + fig, axs = uplt.subplots(ncols=3) + group = {"axes": list(axs), "artist": None} + result = fig._visible_subset_group_axes(group) + assert len(result) == 3 + uplt.close(fig) + + +def test_update_subset_title_single_axes_delegates(): + fig, axs = uplt.subplots(ncols=3) + artist = fig._update_subset_title([axs[0]], "Solo title") + assert artist.get_text() == "Solo title" + uplt.close(fig) + + +def test_update_subset_title_empty_raises(): + fig, axs = uplt.subplots(ncols=2) + with pytest.raises(ValueError, match="Need at least one"): + fig._update_subset_title([], "No axes") + uplt.close(fig) + + +def test_update_subset_title_creates_group(): + fig, axs = uplt.subplots(ncols=3) + artist = fig._update_subset_title([axs[0], axs[1]], "Two-panel title") + assert artist.get_text() == "Two-panel title" + assert len(fig._subset_title_dict) == 1 + uplt.close(fig) + + +def test_update_subset_title_update_existing(): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "First") + fig._update_subset_title([axs[0], axs[1]], "Updated") + assert len(fig._subset_title_dict) == 1 + group = next(iter(fig._subset_title_dict.values())) + assert group["artist"].get_text() == "Updated" + uplt.close(fig) + + +def test_get_subset_title_bbox_returns_none_when_empty(): + fig, axs = uplt.subplots(ncols=2) + renderer = fig._get_renderer() + assert fig._get_subset_title_bbox(axs[0], renderer) is None + uplt.close(fig) + + +def test_get_subset_title_bbox_for_top_row_only(): + fig, axs = uplt.subplots(nrows=2, ncols=2) + fig._update_subset_title([axs[0], axs[1]], "Top row title") + fig.canvas.draw() + renderer = fig._get_renderer() + bbox_top = fig._get_subset_title_bbox(axs[0], renderer) + bbox_bottom = fig._get_subset_title_bbox(axs[2], renderer) + assert bbox_top is not None + assert bbox_bottom is None + uplt.close(fig) + + +def test_align_subset_titles_removes_orphaned(): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "Will be orphaned") + key = next(iter(fig._subset_title_dict)) + fig._subset_title_dict[key]["axes"] = [] + renderer = fig._get_renderer() + fig._align_subset_titles(renderer) + assert len(fig._subset_title_dict) == 0 + uplt.close(fig) + + +def test_align_subset_titles_with_manual_y(): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "Manual Y", y=0.95) + fig.canvas.draw() + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert np.isclose(artist.get_position()[1], 0.95) + uplt.close(fig) + + +def test_subset_title_left_alignment(): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "Left title", loc="left") + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert artist.get_ha() == "left" + uplt.close(fig) + + +def test_subset_title_right_alignment(): + fig, axs = uplt.subplots(ncols=3) + fig._update_subset_title([axs[0], axs[1]], "Right title", loc="right") + key = next(iter(fig._subset_title_dict)) + artist = fig._subset_title_dict[key]["artist"] + assert artist.get_ha() == "right" + uplt.close(fig) + + +def test_find_aspect_spans_empty(): + fig, axs = uplt.subplots(ncols=2) + spans = fig._find_aspect_constrained_spans([]) + assert spans == [] + uplt.close(fig) + + +def test_find_aspect_spans_no_aspect(): + fig, axs = uplt.subplots(ncols=2) + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert spans == [] + uplt.close(fig) + + +def test_remap_axes_with_empty_spans(): + fig, axs = uplt.subplots(ncols=2) + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + fig._remap_axes_to_span(axes, []) + uplt.close(fig) + + +def test_align_aspect_constrained_no_axes(): + fig = uplt.figure() + fig._align_aspect_constrained_axes() + uplt.close(fig) + + +def test_aspect_row_spanning_layout(): + fig, axs = uplt.subplots([[1, 2], [1, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert len(spans) >= 1 + assert any(s[0] == "y" for s in spans) + uplt.close(fig) + + +def test_aspect_col_spanning_layout(): + fig, axs = uplt.subplots([[1, 1], [2, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) + spans = fig._find_aspect_constrained_spans(axes) + assert len(spans) >= 1 + assert any(s[0] == "x" for s in spans) + uplt.close(fig) + + +def test_full_align_aspect_row_spanning(): + fig, axs = uplt.subplots([[1, 2], [1, 3]]) + axs[0].set_aspect("equal") + axs[0].plot([0, 1], [0, 1]) + axs[1].plot([0, 1], [0, 1]) + axs[2].plot([0, 1], [0, 1]) + fig.canvas.draw() + pos0 = axs[0].get_position() + pos1 = axs[1].get_position() + pos2 = axs[2].get_position() + assert pos1.y0 + pos1.height <= pos0.y0 + pos0.height + 0.01 + uplt.close(fig) def test_add_subplot_three_integer_args(): From d3be86b2081e5d0da0fbcceecc4b77709576adf3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 15:16:47 +1000 Subject: [PATCH 5/7] Add type annotation --- ultraplot/figure.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 685e1c96b..4a6c3c91a 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1164,7 +1164,9 @@ def _snap_axes_to_pixel_grid(self, renderer) -> None: which="both", ) - def _find_aspect_constrained_spans(self, axes, *, tol=1e-9): + def _find_aspect_constrained_spans( + self, axes: List[paxes.Axes], *, tol: float = 1e-9 + ) -> List[Tuple[str, int, int, mtransforms.Bbox, mtransforms.Bbox, paxes.Axes]]: """ Identify spanning axes whose aspect constraint caused matplotlib to shrink them inside their gridspec slot. @@ -1198,7 +1200,13 @@ def _find_aspect_constrained_spans(self, axes, *, tol=1e-9): spans.append(("x", col1, col2, slot, pos, ax)) return spans - def _remap_axes_to_span(self, axes, spans, *, tol=1e-9): + def _remap_axes_to_span( + self, + axes: List[paxes.Axes], + spans: List[Tuple[str, int, int, mtransforms.Bbox, mtransforms.Bbox, paxes.Axes]], + *, + tol: float = 1e-9, + ) -> None: """ Remap auto-aspect sibling axes so they align with the aspect-constrained bounds described by *spans*. @@ -2658,7 +2666,7 @@ def _align_super_title(self, renderer): self._suptitle.set_position((x, y)) @staticmethod - def _deduplicate_axes(axes): + def _deduplicate_axes(axes: Iterable[paxes.Axes]) -> List[paxes.Axes]: """ Resolve panel parents and remove duplicates, preserving order. """ @@ -2673,7 +2681,7 @@ def _deduplicate_axes(axes): return unique @staticmethod - def _normalize_title_alignment(loc): + def _normalize_title_alignment(loc: str) -> str: """ Convert a *loc* string to a horizontal alignment for ``Text.set_ha``. """ @@ -2689,7 +2697,9 @@ def _normalize_title_alignment(loc): raise ValueError(f"Invalid shared subplot title location {loc!r}.") @staticmethod - def _resolve_title_props(fontdict, kwargs): + def _resolve_title_props( + fontdict: dict[str, Any] | None, kwargs: dict[str, Any] + ) -> dict[str, Any]: """ Build the property dict for a title from rc defaults, *fontdict*, and extra *kwargs*. @@ -2778,7 +2788,7 @@ def _update_subset_title( artist.update(kw) return artist - def _visible_subset_group_axes(self, group): + def _visible_subset_group_axes(self, group: dict[str, Any]) -> List[paxes.Axes]: """ Return visible axes from a subset-title group that belong to this figure. """ @@ -2813,7 +2823,7 @@ def _get_subset_title_bbox( bboxes.append(artist.get_window_extent(renderer)) return mtransforms.Bbox.union(bboxes) if bboxes else None - def _align_subset_titles(self, renderer): + def _align_subset_titles(self, renderer: Any) -> None: """ Update the positions of titles spanning subplot subsets. """ From b0208b619ad32a5ba14fde0b6663e80d54c14a72 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 15:20:51 +1000 Subject: [PATCH 6/7] Black --- ultraplot/figure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 4a6c3c91a..d81f90a1b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1203,7 +1203,9 @@ def _find_aspect_constrained_spans( def _remap_axes_to_span( self, axes: List[paxes.Axes], - spans: List[Tuple[str, int, int, mtransforms.Bbox, mtransforms.Bbox, paxes.Axes]], + spans: List[ + Tuple[str, int, int, mtransforms.Bbox, mtransforms.Bbox, paxes.Axes] + ], *, tol: float = 1e-9, ) -> None: From 4c4d49f9afc8801e159bd3d70e508428846e0d59 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 13 Apr 2026 15:29:15 +1000 Subject: [PATCH 7/7] Move to independence of aspect --- ultraplot/figure.py | 34 ++++++++++++++++------------------ ultraplot/tests/test_figure.py | 12 ++++++------ 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d81f90a1b..2bac74b22 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1164,12 +1164,12 @@ def _snap_axes_to_pixel_grid(self, renderer) -> None: which="both", ) - def _find_aspect_constrained_spans( + def _find_misaligned_spans( self, axes: List[paxes.Axes], *, tol: float = 1e-9 ) -> List[Tuple[str, int, int, mtransforms.Bbox, mtransforms.Bbox, paxes.Axes]]: """ - Identify spanning axes whose aspect constraint caused matplotlib to - shrink them inside their gridspec slot. + Identify spanning axes whose actual position differs from their + gridspec slot (e.g. because of an aspect constraint). Returns a list of ``(axis, start, stop, slot, pos, ref_ax)`` tuples where *axis* is ``'y'`` for row-spanning or ``'x'`` for column-spanning. @@ -1177,9 +1177,6 @@ def _find_aspect_constrained_spans( spans = [] for ax in axes: try: - aspect = ax.get_aspect() - if aspect == "auto": - continue ax.apply_aspect() ss = ax.get_subplotspec().get_topmost_subplotspec() row1, row2, col1, col2 = ss._get_rows_columns() @@ -1210,8 +1207,9 @@ def _remap_axes_to_span( tol: float = 1e-9, ) -> None: """ - Remap auto-aspect sibling axes so they align with the - aspect-constrained bounds described by *spans*. + Remap sibling axes so they align with the actual bounds of + spanning axes described by *spans*. Siblings with their own + fixed aspect are skipped since they have independent constraints. """ for axis, start, stop, slot, pos, ref_ax in spans: slot0 = slot.y0 if axis == "y" else slot.x0 @@ -1253,20 +1251,20 @@ def _remap_axes_to_span( bounds = [new0, old.y0, new1 - new0, old.height] ax.set_position(bounds, which="both") - def _align_aspect_constrained_axes(self, *, tol: float = 1e-9) -> None: + def _align_spanning_axes(self, *, tol: float = 1e-9) -> None: """ - Propagate aspect-constrained spanning axes boxes across sibling rows/columns. + Align sibling subplots to spanning axes whose actual position + differs from their gridspec slot. - When a fixed-aspect subplot spans multiple rows or columns, matplotlib shrinks - just that axes inside its gridspec slot. In layouts like ``[[1, 2], [1, 3]]`` - this leaves the adjacent stack slightly taller or wider than the spanning axes. - Here we remap the sibling subplot slots onto the aspect-constrained box so the - overall geometry stays aligned. + When a subplot spans multiple rows or columns and is shrunk inside + its slot (e.g. by a fixed aspect ratio), the adjacent subplots keep + their full extent and visibly stick out. This method detects the + mismatch and remaps the sibling positions proportionally. """ axes = list(self._iter_axes(hidden=False, children=False, panels=False)) if not axes: return - spans = self._find_aspect_constrained_spans(axes, tol=tol) + spans = self._find_misaligned_spans(axes, tol=tol) self._remap_axes_to_span(axes, spans, tol=tol) def _share_ticklabels(self, *, axis: str) -> None: @@ -3109,11 +3107,11 @@ def _align_content(): # noqa: E306 return if aspect: gs._auto_layout_aspect() - self._align_aspect_constrained_axes() + self._align_spanning_axes() _align_content() if tight: gs._auto_layout_tight(renderer) - self._align_aspect_constrained_axes() + self._align_spanning_axes() _align_content() @warnings._rename_kwargs( diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 22d3dc61b..a78ac0402 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -748,7 +748,7 @@ def test_subset_title_right_alignment(): def test_find_aspect_spans_empty(): fig, axs = uplt.subplots(ncols=2) - spans = fig._find_aspect_constrained_spans([]) + spans = fig._find_misaligned_spans([]) assert spans == [] uplt.close(fig) @@ -756,7 +756,7 @@ def test_find_aspect_spans_empty(): def test_find_aspect_spans_no_aspect(): fig, axs = uplt.subplots(ncols=2) axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) + spans = fig._find_misaligned_spans(axes) assert spans == [] uplt.close(fig) @@ -768,9 +768,9 @@ def test_remap_axes_with_empty_spans(): uplt.close(fig) -def test_align_aspect_constrained_no_axes(): +def test_align_spanning_axes_no_axes(): fig = uplt.figure() - fig._align_aspect_constrained_axes() + fig._align_spanning_axes() uplt.close(fig) @@ -782,7 +782,7 @@ def test_aspect_row_spanning_layout(): axs[2].plot([0, 1], [0, 1]) fig.canvas.draw() axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) + spans = fig._find_misaligned_spans(axes) assert len(spans) >= 1 assert any(s[0] == "y" for s in spans) uplt.close(fig) @@ -796,7 +796,7 @@ def test_aspect_col_spanning_layout(): axs[2].plot([0, 1], [0, 1]) fig.canvas.draw() axes = list(fig._iter_axes(hidden=False, children=False, panels=False)) - spans = fig._find_aspect_constrained_spans(axes) + spans = fig._find_misaligned_spans(axes) assert len(spans) >= 1 assert any(s[0] == "x" for s in spans) uplt.close(fig)