diff --git a/ultraplot/figure.py b/ultraplot/figure.py index b319a82e1..d44f31e61 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -965,9 +965,21 @@ def _get_border_axes( self._cached_border_axes = border_axes return border_axes - def _get_align_coord(self, side, axs, includepanels=False): + def _get_align_coord(self, side, axs, align="center", includepanels=False): """ - Return the figure coordinate for centering spanning axis labels or super titles. + Return the figure coordinate for positioning spanning axis labels or super titles. + + Parameters + ---------- + side : str + Side of the figure ('top', 'bottom', 'left', 'right'). + axs : list + List of axes to align across. + align : str, default 'center' + Horizontal alignment for x-axis positioning: 'left', 'center', or 'right'. + For y-axis positioning, always centers regardless of this parameter. + includepanels : bool, default False + Whether to include panel axes in the alignment calculation. """ # Get position in figure relative coordinates if not all(isinstance(ax, paxes.Axes) for ax in axs): @@ -985,8 +997,15 @@ def _get_align_coord(self, side, axs, includepanels=False): box_lo = ax_lo.get_subplotspec().get_position(self) box_hi = ax_hi.get_subplotspec().get_position(self) if s == "x": - pos = 0.5 * (box_lo.x0 + box_hi.x1) + # Calculate horizontal position based on alignment preference + if align == "left": + pos = box_lo.x0 + elif align == "right": + pos = box_hi.x1 + else: # 'center' + pos = 0.5 * (box_lo.x0 + box_hi.x1) else: + # For vertical positioning, always center between axes pos = 0.5 * (box_lo.y1 + box_hi.y0) # 'lo' is actually on top of figure ax = axs[(np.argmin(ranges[:, 0]) + np.argmax(ranges[:, 1])) // 2] ax = ax._panel_parent or ax # always use main subplot for spanning labels @@ -1541,7 +1560,10 @@ def _align_super_labels(self, side, renderer): def _align_super_title(self, renderer): """ - Adjust the position of the super title. + Adjust the position of the super title based on user alignment preferences. + + Respects horizontal and vertical alignment settings from suptitle_kw parameters, + while applying sensible defaults when no custom alignment is provided. """ if not self._suptitle.get_text(): return @@ -1550,10 +1572,23 @@ def _align_super_title(self, renderer): return labs = tuple(t for t in self._suplabel_dict["top"].values() if t.get_text()) pad = (self._suptitle_pad / 72) / self.get_size_inches()[1] - x, _ = self._get_align_coord("top", axs, includepanels=self._includepanels) + + # Get current alignment settings from suptitle (may be set via suptitle_kw) + ha = self._suptitle.get_ha() + va = self._suptitle.get_va() + + # Use original centering algorithm for positioning (regardless of alignment) + x, _ = self._get_align_coord( + "top", + axs, + includepanels=self._includepanels, + align=ha, + ) y = self._get_offset_coord("top", axs, renderer, pad=pad, extra=labs) - self._suptitle.set_ha("center") - self._suptitle.set_va("bottom") + + # Set final position and alignment on the suptitle + self._suptitle.set_ha(ha) + self._suptitle.set_va(va) self._suptitle.set_position((x, y)) def _update_axis_label(self, side, axs): @@ -1787,6 +1822,7 @@ def _align_content(): # noqa: E306 # subsequent tight layout really weird. Have to resize twice. _draw_content() if not gs: + print("hello") return if aspect: gs._auto_layout_aspect() diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index 974362383..0e19ad8c7 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -41,7 +41,7 @@ def test_outer_align(): ax.colorbar( "magma", loc="right", extend="both", label="test extensions", labelrotation=90 ) - fig.suptitle("Align demo") + fig.suptitle("Align demo", va="bottom") return fig diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 68d58505c..0e92f8f2f 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -134,3 +134,109 @@ def test_toggle_input_axis_sharing(): fig = uplt.figure() with pytest.warns(uplt.internals.warnings.UltraPlotWarning): fig._toggle_axis_sharing(which="does not exist") + + +def test_suptitle_alignment(): + """ + Test that suptitle uses the original centering behavior with includepanels parameter. + """ + # Test 1: Default behavior uses original centering algorithm + fig1, ax1 = uplt.subplots(ncols=3) + for ax in ax1: + ax.panel("top", width="1em") # Add panels + fig1.suptitle("Default") + fig1.canvas.draw() # Trigger alignment + pos1 = fig1._suptitle.get_position() + + # Test 2: includepanels=False should use original centering behavior + fig2, ax2 = uplt.subplots(ncols=3, includepanels=False) + for ax in ax2: + ax.panel("top", width="1em") # Add panels + fig2.suptitle("includepanels=False") + fig2.canvas.draw() # Trigger alignment + pos2 = fig2._suptitle.get_position() + + # Test 3: includepanels=True should use original centering behavior + fig3, ax3 = uplt.subplots(ncols=3, includepanels=True) + for ax in ax3: + ax.panel("top", width="1em") # Add panels + fig3.suptitle("includepanels=True") + fig3.canvas.draw() # Trigger alignment + pos3 = fig3._suptitle.get_position() + + # With reverted behavior, all use the same original centering algorithm + # Note: In the original code, includepanels didn't actually affect suptitle positioning + assert ( + abs(pos1[0] - pos2[0]) < 0.001 + ), f"Default and includepanels=False should be same: {pos1[0]} vs {pos2[0]}" + + assert ( + abs(pos2[0] - pos3[0]) < 0.001 + ), f"includepanels=False and True should be same with reverted behavior: {pos2[0]} vs {pos3[0]}" + + uplt.close("all") + + +import pytest + + +@pytest.mark.parametrize( + "suptitle, suptitle_kw, expected_ha, expected_va", + [ + ("Default alignment", {}, "center", "bottom"), # Test 1: Default alignment + ( + "Left aligned", + {"ha": "left"}, + "left", + "bottom", + ), # Test 2: Custom horizontal alignment + ( + "Top aligned", + {"va": "top"}, + "center", + "top", + ), # Test 3: Custom vertical alignment + ( + "Custom aligned", + {"ha": "right", "va": "top"}, + "right", + "top", + ), # Test 4: Both custom alignments + ], +) +def test_suptitle_kw_alignment(suptitle, suptitle_kw, expected_ha, expected_va): + """ + Test that suptitle_kw alignment parameters work correctly and are not overridden. + """ + fig, ax = uplt.subplots() + fig.format(suptitle=suptitle, suptitle_kw=suptitle_kw) + fig.canvas.draw() + assert ( + fig._suptitle.get_ha() == expected_ha + ), f"Expected ha={expected_ha}, got {fig._suptitle.get_ha()}" + assert ( + fig._suptitle.get_va() == expected_va + ), f"Expected va={expected_va}, got {fig._suptitle.get_va()}" + + +@pytest.mark.parametrize( + "ha, expectation", + [ + ("left", 0), + ("center", 0.5), + ("right", 1), + ], +) +def test_suptitle_kw_position_reverted(ha, expectation): + """ + Test that position remains the same while alignment properties differ. + """ + fig, ax = uplt.subplots(ncols=3) + fig.format(suptitle=ha, suptitle_kw=dict(ha=ha)) + fig.canvas.draw() # trigger alignment + x, y = fig._suptitle.get_position() + + # Note values are dynamic so atol is a bit wide here + assert np.isclose(x, expectation, atol=0.1), f"Expected x={expectation}, got {x=}" + + uplt.close("all")