Repository: Ultraplot/UltraPlot

Issue:
[Feature request] Adjustable subplot label position offset
Currently, when adding subplot labels (e.g., "a", "b") using `format(abc='a')`, the position can only be selected from a few options. There is no argument to adjust the horizontal/vertical offset of labels relative to the axes. I wish I can place labels slightly outside the top-left corner of an axis, as shown in this example: 

```python
import matplotlib.pyplot as plt

fig, axs = plt.subplots(1, 2, figsize=(7, 3))
plt.subplots_adjust(wspace=0.3)

for i, ax in enumerate(axs.flat):
    ax.text(-0.2, 1.05, f'{chr(97 + i)}', transform=ax.transAxes, fontsize=12, va='bottom', ha='right')
    ax.set(ylabel=f"ylabel {i}")

plt.show()
```

![Image](https://github.com/user-attachments/assets/47b984d0-979b-4867-be69-56a145fa7d79)

A similar feature request was opened in `ProPlot` years ago but has not yet been addressed (see proplot-dev/proplot#288).

Hints from issue comments:
@cvanelteren: Good point. To bounce the ball back do you imagine a tuple to specify the loc or a separate padding keyword that can shift it relative to the preset locations, or something completely else? @cvanelteren: My two cents would be to add a keyword `abcpad` which can shift it depending on where it is set, eg for top left it would move it to the left for negative values and to the right for positive. @ToryDeng: > My two cents would be to add a keyword `abcpad` which can shift it depending on where it is set, eg for top left it would move it to the left for negative values and to the right for positive.

Yes, I prefer this approach. It's clearer than a tuple, especially for users less experienced with `matplotlib`.@cvanelteren: Sounds good. When I am back I can put in in the docket, if you can't wait PR as always very welcome 😉!@cvanelteren: ![Image](https://github.com/user-attachments/assets/fa5f2ac9-f74e-4adb-9b09-12b1f1afd206)

I implemented some changes that would allow for offsetting the title with a pad, however I am not very happy yet with the setup, what do you think?

```
fig, ax = uplt.subplots()
ax.format(
    abc="a.",
    title="testing",
    abc_kw=dict(pad=-0.15),
)
uplt.show(block=1)
```
@cvanelteren: Added the best of both worlds:

``` python
fig, ax = uplt.subplots(ncols=2)
ax[0].format(
    abc="a.",
    abcloc="ol",
    title="testing",
    # abc_kw=dict(pad=-0.15),
)
ax[1].format(
    abc="a.",
    abcloc="or",
    title="testing",
    # abc_kw=dict(pad=-0.15),
)
fig.savefig("test.png")
uplt.show(block=1)
```

![Image](https://github.com/user-attachments/assets/c58721b0-0828-4759-b9e7-ed3633d8e265)
@cvanelteren: This would add two new alignments: `outer right and outer left` or `ol` and `or`. This will work in addition to the padding. Only thing I have to check if whether combination plots would not look odd with having ticks to the right.@cvanelteren: @ToryDeng , You mind checking out #139 to see if this suits your goals?@ToryDeng: > [@ToryDeng](https://github.com/ToryDeng) , You mind checking out [#139](https://github.com/Ultraplot/ultraplot/pull/139) to see if this suits your goals?

Oh sorry for the late reply! The PR works has already satisfied my need. I just noticed a minor alignment issue: when `abcpad=0` (default, I believe?) is applied to a figure with variable subplot spacing, labels `a` and `b` are perfectly aligned:

```python
import numpy as np
import ultraplot as uplt

state = np.random.RandomState(51423)
data = 2 * (state.rand(100, 5) - 0.5).cumsum(axis=0)
array = [  # the "picture" (1 == subplot A, 2 == subplot B, etc.)
    [1, 1, 1],
    [2, 3, 4],
    [2, 3, 4],
]
fig, axs = uplt.subplots(array, figwidth=5, figheight=5, span=False)
axs.format(abc=True, abcloc="ol", abcpad=0)
```

![Image](https://github.com/user-attachments/assets/ebff43ca-9697-4b16-bcfc-ca0b765b406b)

However, a small offset (`abcpad=-0.05`) causes a slight vertical misalignment between the labels:

![Image](https://github.com/user-attachments/assets/14a02fe4-a2c7-410a-afdf-46691ddd7a36)


This is probably due to the different width of the two subplots. This edge case might require non-trivial layout recalibrations, so I totally understand if it's lower priority. If you feel this edge case isn’t critical to address now, feel free to close this issue. The current implementation is already a major improvement. Thank you for your work on this! 🙌@cvanelteren: Ah thanks for checking. In the first image it is also not aligned I think. Will have a closer look.@ToryDeng: > Ah thanks for checking. In the first image it is also not aligned I think. Will have a closer look.

Wow! Looking forward to it!@cvanelteren: @ToryDeng  Should be good now.@ToryDeng: > [@ToryDeng](https://github.com/ToryDeng) Should be good now.

Yes, the new version works great. Thank you for your efforts!@cvanelteren: Added in main. Release will follow soon.

Solution patch:
diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py
index e69bfeb4..70d7c4f9 100644
--- a/ultraplot/axes/base.py
+++ b/ultraplot/axes/base.py
@@ -317,6 +317,8 @@ abcloc, titleloc : str, default: :rc:`abc.loc`, :rc:`title.loc`
     upper left inside axes    ``'upper left'``, ``'ul'``
     lower left inside axes    ``'lower left'``, ``'ll'``
     lower right inside axes   ``'lower right'``, ``'lr'``
+    left of y axis            ```'outer left'``, ``'ol'``
+    right of y axis           ```'outer right'``, ``'or'``
     ========================  ============================
 
 abcborder, titleborder : bool, default: :rc:`abc.border` and :rc:`title.border`
@@ -327,6 +329,9 @@ abcbbox, titlebbox : bool, default: :rc:`abc.bbox` and :rc:`title.bbox`
     Whether to draw a white bbox around titles and a-b-c labels positioned
     inside the axes. This can help them stand out on top of artists plotted
     inside the axes.
+abcpad : float or unit-spec, default: :rc:`abc.pad`
+    The padding for the inner and outer titles and a-b-c labels.
+    %(units.pt)s
 abc_kw, title_kw : dict-like, optional
     Additional settings used to update the a-b-c label and title
     with ``text.update()``.
@@ -765,6 +770,7 @@ class Axes(maxes.Axes):
         self._auto_format = None  # manipulated by wrapper functions
         self._abc_border_kwargs = {}
         self._abc_loc = None
+        self._abc_pad = 0
         self._abc_title_pad = rc["abc.titlepad"]
         self._title_above = rc["title.above"]
         self._title_border_kwargs = {}  # title border properties
@@ -810,6 +816,8 @@ class Axes(maxes.Axes):
         d["lower left"] = self.text(0, 0, "", va="bottom", ha="left", **kw)
         d["lower center"] = self.text(0, 0.5, "", va="bottom", ha="center", **kw)
         d["lower right"] = self.text(0, 1, "", va="bottom", ha="right", **kw)
+        d["outer left"] = self.text(0, 1, "", va="bottom", ha="right", **kw)
+        d["outer right"] = self.text(1, 1, "", va="bottom", ha="left", **kw)
 
         # Subplot-specific settings
         # NOTE: Default number for any axes is None (i.e., no a-b-c labels allowed)
@@ -2414,8 +2422,62 @@ class Axes(maxes.Axes):
         if loc not in ("left", "right", "center"):
             kw.update(self._abc_border_kwargs)
         kw.update(kwargs)
+        self._update_outer_abc_loc(loc)
         self._title_dict["abc"].update(kw)
 
+    def _update_outer_abc_loc(self, loc):
+        """
+        For the outer labels, we need to align them vertically and create the
+        offset based on the tick length and the tick label. This function loops
+        through all axes in the figure to find maximum tick length and label size
+        and transforms the position accordingly.
+        """
+        if loc not in ["outer left", "outer right"]:
+            return
+        # Find the largest offset by considering the tick and label size
+        tick_length = label_size = 0
+        ha = "right" if loc == "outer left" else "left"
+        side = 0 if loc == "outer left" else -1
+
+        # Loop through all axes in the figure to find maximum tick length and label size
+        for axi in self.figure.axes:
+            axis = axi.yaxis
+            # Determine if ticks are visible and get their size
+            has_ticks = (
+                axis.get_major_ticks() and axis.get_major_ticks()[side].get_visible()
+            )
+            if has_ticks:
+                # Ignore if tick direction is not outwards
+                tickdir = axis._major_tick_kw.get("tickdir", "out")
+                if tickdir and tickdir != "in":
+                    tick_length = max(
+                        axis.majorTicks[0].tick1line.get_markersize(), tick_length
+                    )
+
+            # Get the size of tick labels if they exist
+            has_labels = True if axis.get_ticklabels() else False
+            # Estimate label size; note it uses the raw text representation which can be misleading due to the latex processing
+            if has_labels:
+                _offset = max(
+                    [
+                        len(l.get_text()) + l.get_fontsize()
+                        for l in axis.get_ticklabels()
+                    ]
+                )
+                label_size = max(_offset, label_size)
+        # Calculate symmetrical offset based on tick length and label size
+        base_offset = (tick_length / 72) + (label_size / 72)
+        offset = -base_offset if ha == "right" else base_offset
+
+        # Create text with appropriate position and transform
+        aobj = self._title_dict[loc]
+        aobj.set_transform(
+            self.transAxes
+            + mtransforms.ScaledTranslation(offset, 0, self.figure.dpi_scale_trans)
+        )
+        p = aobj.get_position()
+        aobj.set_position((p[0] + offset, p[1]))
+
     def _update_title(self, loc, title=None, **kwargs):
         """
         Update the title at the specified location.
@@ -2510,26 +2572,10 @@ class Axes(maxes.Axes):
         x_pad = self._title_pad / (72 * width)
         y_pad = self._title_pad / (72 * height)
         for loc, obj in self._title_dict.items():
-            x, y = (0, 1)
             if loc == "abc":  # redirect
                 loc = self._abc_loc
-            if loc == "left":
-                x = 0
-            elif loc == "center":
-                x = 0.5
-            elif loc == "right":
-                x = 1
-            if loc in ("upper center", "lower center"):
-                x = 0.5
-            elif loc in ("upper left", "lower left"):
-                x = x_pad
-            elif loc in ("upper right", "lower right"):
-                x = 1 - x_pad
-            if loc in ("upper left", "upper right", "upper center"):
-                y = 1 - y_pad
-            elif loc in ("lower left", "lower right", "lower center"):
-                y = y_pad
-            obj.set_position((x, y))
+            xy = _get_pos_from_locator(loc, x_pad, y_pad)
+            obj.set_position(xy)
 
         # Get title padding. Push title above tick marks since matplotlib ignores them.
         # This is known matplotlib problem but especially annoying with top panels.
@@ -2563,24 +2609,47 @@ class Axes(maxes.Axes):
 
         # Offset title away from a-b-c label
         # NOTE: Title texts all use axes transform in x-direction
-        if not tobj.get_text() or not aobj.get_text():
-            return
-        awidth, twidth = (
-            obj.get_window_extent(renderer).transformed(self.transAxes.inverted()).width
-            for obj in (aobj, tobj)
-        )
-        ha = aobj.get_ha()
+
+        # Offset title away from a-b-c label
+        atext, ttext = aobj.get_text(), tobj.get_text()
+        awidth = twidth = 0
         pad = (abcpad / 72) / self._get_size_inches()[0]
+        ha = aobj.get_ha()
+
+        # Get dimensions of non-empty elements
+        if atext:
+            awidth = (
+                aobj.get_window_extent(renderer)
+                .transformed(self.transAxes.inverted())
+                .width
+            )
+        if ttext:
+            twidth = (
+                tobj.get_window_extent(renderer)
+                .transformed(self.transAxes.inverted())
+                .width
+            )
+
+        # Calculate offsets based on alignment and content
         aoffset = toffset = 0
-        if ha == "left":
-            toffset = awidth + pad
-        elif ha == "right":
-            aoffset = -(twidth + pad)
-        else:  # guaranteed center, there are others
-            toffset = 0.5 * (awidth + pad)
-            aoffset = -0.5 * (twidth + pad)
-        aobj.set_x(aobj.get_position()[0] + aoffset)
-        tobj.set_x(tobj.get_position()[0] + toffset)
+        if atext and ttext:
+            if ha == "left":
+                toffset = awidth + pad
+            elif ha == "right":
+                aoffset = -(twidth + pad)
+            elif ha == "center":
+                toffset = 0.5 * (awidth + pad)
+                aoffset = -0.5 * (twidth + pad)
+
+        # Apply positioning adjustments
+        if atext:
+            aobj.set_x(
+                aobj.get_position()[0]
+                + aoffset
+                + (self._abc_pad / 72) / (self._get_size_inches()[0])
+            )
+        if ttext:
+            tobj.set_x(tobj.get_position()[0] + toffset)
 
     def _update_super_title(self, suptitle=None, **kwargs):
         """
@@ -2755,6 +2824,8 @@ class Axes(maxes.Axes):
             title_kw = title_kw or {}
             self._update_share_labels(share_xlabels, target="x")
             self._update_share_labels(share_ylabels, target="y")
+            if (pad := kwargs.pop("abcpad", None)) is not None:
+                self._abc_pad = units(pad)
             self._update_abc(**abc_kw)
             self._update_title(None, title, **title_kw)
             self._update_title(
@@ -3271,3 +3342,36 @@ class Axes(maxes.Axes):
 # NOTE: This is needed for __init__
 Axes._format_signatures = {Axes: inspect.signature(Axes.format)}
 Axes.format = docstring._obfuscate_kwargs(Axes.format)
+
+
+def _get_pos_from_locator(
+    loc: str,
+    x_pad: float,
+    y_pad: float,
+) -> tuple[float, float]:
+    """
+    Helper function to map string locators to x and y coordinates.
+    """
+    # Set x-coordinate based on horizontal position
+    x, y = 0, 1
+    match loc:
+        case "left" | "outer left":
+            x = 0
+        case "center":
+            x = 0.5
+        case "right" | "outer right":
+            x = 1
+        case "upper center" | "lower center":
+            x = 0.5
+        case "upper left" | "lower left":
+            x = x_pad
+        case "upper right" | "lower right":
+            x = 1 - x_pad
+
+    # Set y-coordinate based on vertical position
+    match loc:
+        case "upper left" | "upper right" | "upper center":
+            y = 1 - y_pad
+        case "lower left" | "lower right" | "lower center":
+            y = y_pad
+    return (x, y)
diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py
index 91a2e926..27eecd1a 100644
--- a/ultraplot/internals/rcsetup.py
+++ b/ultraplot/internals/rcsetup.py
@@ -105,6 +105,8 @@ LEGEND_LOCS = {
     "cl": "center left",
     "uc": "upper center",
     "lc": "lower center",
+    "ol": "outer left",
+    "or": "outer right",
 }
 for _loc in tuple(LEGEND_LOCS.values()):
     if _loc not in LEGEND_LOCS:
@@ -123,6 +125,8 @@ TEXT_LOCS = {
         "lower left",
         "lower center",
         "lower right",
+        "outer left",
+        "outer right",
     )
 }
 COLORBAR_LOCS = {


Test patch:
diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py
index 72e2804c..0aea33d9 100644
--- a/ultraplot/tests/test_axes.py
+++ b/ultraplot/tests/test_axes.py
@@ -170,7 +170,7 @@ def test_twin_axes_3():
     axs[-1].spines["right"].set_position(("axes", 1.2))
     colors = ("Green", "Red", "Blue")
     for ax, color in zip(axs, colors):
-        data = np.random.random(1) * np.random.random(10)
+        data = np.random.rand(1) * np.random.rand(10)
         ax.plot(data, marker="o", linestyle="none", color=color)
         ax.format(ylabel="%s Thing" % color, ycolor=color)
     axs[0].format(xlabel="xlabel")
diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py
index 4e57f0d6..788f09b1 100644
--- a/ultraplot/tests/test_format.py
+++ b/ultraplot/tests/test_format.py
@@ -401,3 +401,131 @@ def test_scaler():
     ax[0].set_yscale("mercator")
     ax[1].set_yscale("asinh")
     return fig
+
+
+@pytest.mark.mpl_image_compare
+def test_outer_labels():
+    """
+    Produces a plot where the abc loc is in top left or top right of a plot. Padding can be used for finer adjustment if necessary.
+    """
+    fig, ax = uplt.subplots(ncols=2)
+    ax[0].format(
+        abc="a.",
+        abcloc="ol",
+        title="testing",
+    )
+    ax[1].format(
+        abc="a.",
+        abcloc="outer right",
+        title="testing",
+        abcpad=-0.25,
+    )
+    return fig
+
+
+def test_abc_padding():
+    """
+    Test the specific calculation for ABC padding in title positioning.
+    """
+    fig, ax = uplt.subplots()
+
+    # Set up test scenario
+    ax.set_title("Test Title")
+    ax.format(
+        title="Testing",
+        abc="a.",
+        abcloc="or",
+    )
+    # Get initial position
+    initial_abc_x = ax.axes._title_dict["abc"].get_position()[0]
+
+    # Pad the position and check the offset
+    padding_value = 12
+
+    ax.format(
+        title="Testing",
+        abc="a.",
+        abcloc="or",
+        abcpad=padding_value,
+    )
+    fig.canvas.draw()
+
+    # Verify the new position
+    new_abc_x = ax.axes._title_dict["abc"].get_position()[0]
+
+    # Assert position has changed
+    assert new_abc_x != initial_abc_x, "ABC padding didn't affect position"
+
+    # Reset padding and position
+    ax.format(
+        title="Testing",
+        abc="a.",
+        abcloc="or",
+        abcpad=0,
+    )
+    fig.canvas.draw()
+    reference_position = ax.axes._title_dict["abc"].get_position()[0]
+
+    # Apply padding again
+    ax.format(
+        title="Testing",
+        abc="a.",
+        abcloc="or",
+        abcpad=padding_value,
+    )
+    # Verify the exact offset matches our expectation
+    actual_offset = ax.axes._title_dict["abc"].get_position()[0] - reference_position
+    diff = actual_offset - ax.axes._abc_pad  # Note pad is signed!
+    assert np.allclose(diff, -padding_value), "ABC padding offset calculation incorrect"
+    uplt.close(fig)
+
+
+@pytest.mark.mpl_image_compare
+def test_unequal_abc_padding():
+    """Check if labels are pushed out based on the largest labl length"""
+    fig, ax = uplt.subplots(ncols=2, nrows=2, share=0)
+    ax[0, 0].set_yscale("asinh")
+    ax[1, 0].set_yscale("mercator")
+    ax[1, 1].set_yscale("logit")
+    ax.format(abc="a.", abcloc="ol")
+    return fig
+
+
+def test_abc_with_labels():
+    """
+    This test should check the "normal" conditions in which the yaxis has labels and the location for abc is adjusted for the outer labels (left or right)
+    """
+    fig, ax = uplt.subplots()
+    ax.set_yticks([1, 2, 3])
+    ax.set_yticklabels(["one", "two", "three"])
+    ax.format(abc="a.", abcloc="ol")
+    uplt.close(fig)
+
+
+def test_abc_number():
+    """
+    Test handling of `abc` with lists of labels that exceed or match the number of axes.
+    """
+    # The keyword `abc` can take on lists, if the lists exceeds the number of the axes
+    with pytest.raises(ValueError):
+        fig, ax = uplt.subplots(ncols=3)
+        ax.format(abc=["a", "bb"])
+    # This should work fine
+    fig, ax = uplt.subplots(ncols=2)
+    ax.format(abc=["a", "b"])
+    uplt.close(fig)
+
+
+def test_loc_positions():
+    """
+    Test all locations the abc labels can be in
+    """
+    from ultraplot.internals.rcsetup import TEXT_LOCS
+
+    fig, ax = uplt.subplots()
+    ax.set_title(
+        "Dummy title"
+    )  # trigger sync with abc to ensure they both move correctly
+    for loc in TEXT_LOCS:
+        ax.format(abc="a.", abcloc=loc)
+    uplt.close(fig)
diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py
index 797068d3..0f3252b6 100644
--- a/ultraplot/tests/test_subplots.py
+++ b/ultraplot/tests/test_subplots.py
@@ -150,7 +150,6 @@ def test_reference_aspect():
     refwidth = 1.5
     fig, axs = uplt.subplots(ncols=2, refwidth=refwidth)
     fig.auto_layout()
-
     assert np.isclose(refwidth, axs[fig._refnum - 1]._get_size_inches()[0])
 
     # A test with funky layout