Repository: Ultraplot/UltraPlot

Issue:
Colorbar params from docs yield errors
From the [example](https://github.com/Ultraplot/ultraplot/issues/163#issuecomment-2811919762) you provided, I know I can modify the rotation and alignment of the colorbar's label and remove the spines of the colorbar's axes by manually creating a colorbar. However, I still want to know if there is a more convenient way to modify these properties in `UltraPlot`.

An example is:
```python
import ultraplot as uplt

cmap = uplt.Colormap("plasma_r")
fig, axs = uplt.subplots(nrows=1, ncols=2, wspace=10)
axs[0].colorbar(cmap, loc="bottom", label="My Label", labelloc="left", length=0.5)

cax = axs[1].colorbar(cmap, loc="bottom", length=0.5)
cax.ax.set_ylabel("My Label", rotation=0, ha="right", va="center")
for spine in cax.ax.spines.values():
    spine.set_visible(False)
```

![Image](https://github.com/user-attachments/assets/8f0cac8a-c0ac-4de9-8297-32e3004a6327)

The title of the second color bar is easier to read and will not overlap with the xticklabels.

Discussion:
@cvanelteren: The docs would suggest that you could do 

```python
cmap = uplt.Colormap("plasma_r")
fig, axs = uplt.subplots(nrows=1, ncols=2)
cbar = fig.colorbar(
    cmap,
    loc="bottom",
    label="My Label",
    labelloc="right",
    frameon=False,
)
cbar.set_label("My Label", rotation=45)
uplt.show(block=1)
```

But I see that that produces an error.  In the codebase I can see that it indeed does not handle the frame on -- even though it is listed  (see https://ultraplot.readthedocs.io/en/latest/api/ultraplot.figure.Figure.html#ultraplot.figure.Figure.colorbar). I have another commit where you can toggle the spines with one command but it is currently private (and not merged yet) I could make it public to handle this and then incorporate it within the colorbar. 

All the issues raised are valid and should be incorporated. I do have, however, another 6 ish PRs that need merging and reviewing. In short, this will go on the todos and should not be a big deal to implement.@cvanelteren: Btw the code you are looking for is (for now)

```python

import ultraplot as uplt

cmap = uplt.Colormap("plasma_r")
fig, axs = uplt.subplots(nrows=1, ncols=2)
cbar = fig.colorbar(
    cmap,
    loc="bottom",
    label="My Label",
    labelloc="left",
)
cbar.set_label("My Label", rotation=0, ha = "left")
cbar.outline.set_visible(False)
uplt.show(block=1)
```@cvanelteren: @ToryDeng in case you or somebody else wants to tackle this. This may be quicker for development as you seem to be very good at finding bugs 😉. What needs to be done is that the docstring for colorbar would need to match the internal processing. 

This would require going through both to see which ones are missing and add those appropriately. I have been meaning to refactor colorbar but that can be done in a separate PR. 

Appropriate tests would need to be added to validate the settings, finalized with an image comp. 

@cvanelteren: I actually was less work than I initially thought. Wasn't thinking clearly yesterday. The input parameters work. The docs state that framealpha is only there fore inset colorbars. I added a PR that allows setting the outline. Please see #189.@cvanelteren: Fixed in main.

Solution patch:
diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py
index d5b1564b..73d451cc 100644
--- a/ultraplot/axes/base.py
+++ b/ultraplot/axes/base.py
@@ -21,6 +21,8 @@ import matplotlib.projections as mproj
 import matplotlib.text as mtext
 import matplotlib.ticker as mticker
 import matplotlib.transforms as mtransforms
+from typing import Union
+from numbers import Number
 import numpy as np
 from matplotlib import cbook
 
@@ -542,6 +544,13 @@ rasterize : bool, default: :rc:`colorbar.rasterize`
     Whether to rasterize the colorbar solids. The matplotlib default was ``True``
     but ultraplot changes this to ``False`` since rasterization can cause misalignment
     between the color patches and the colorbar outline.
+outline : bool, None default : None
+    Controls the visibility of the frame. When set to False, the spines of the colorbar are hidden. If set to `None` it uses the `rc['colorbar.outline']` value.
+labelrotation : str, float, default: None
+    Controls the rotation of the colorbar label. When set to None it takes on the value of `rc["colorbar.labelrotation"]`. When set to auto it produces a sensible default where the rotation is adjusted to where the colorbar is located. For example, a horizontal colorbar with a label to the left or right will match the horizontal alignment and rotate the label to 0 degrees. Users can provide a float to rotate to any arbitrary angle.
+
+
+
 **kwargs
     Passed to `~matplotlib.figure.Figure.colorbar`.
 """
@@ -1033,6 +1042,8 @@ class Axes(maxes.Axes):
         linewidth=None,
         edgefix=None,
         rasterized=None,
+        outline: Union[bool, None] = None,
+        labelrotation: Union[str, float] = None,
         **kwargs,
     ):
         """
@@ -1211,6 +1222,10 @@ class Axes(maxes.Axes):
         # Create colorbar and update ticks and axis direction
         # NOTE: This also adds the guides._update_ticks() monkey patch that triggers
         # updates to DiscreteLocator when parent axes is drawn.
+        orientation = _not_none(
+            kwargs.pop("orientation", None), kwargs.pop("vert", None)
+        )
+
         obj = cax._colorbar_fill = cax.figure.colorbar(
             mappable,
             cax=cax,
@@ -1218,8 +1233,11 @@ class Axes(maxes.Axes):
             format=formatter,
             drawedges=grid,
             extendfrac=extendfrac,
+            orientation=orientation,
             **kwargs,
         )
+        outline = _not_none(outline, rc["colorbar.outline"])
+        obj.outline.set_visible(outline)
         obj.ax.grid(False)
         # obj.minorlocator = minorlocator  # backwards compatibility
         obj.update_ticks = guides._update_ticks.__get__(obj)  # backwards compatible
@@ -1294,6 +1312,38 @@ class Axes(maxes.Axes):
                 case _:
                     raise ValueError("Location not understood.")
             axis.set_label_position(labelloc)
+        labelrotation = _not_none(labelrotation, rc["colorbar.labelrotation"])
+        if labelrotation == "auto":
+            # When set to auto, we make the colorbar appear "natural". For example, when we have a
+            # horizontal colorbar on the top, but we want the label to the sides, we make sure that the horizontal alignment is correct and the labelrotation is horizontal. Below produces "sensible defaults", but can be overridden by the user.
+            match (vert, labelloc, loc):
+                # Vertical colorbars
+                case (True, "left", "left" | "right"):
+                    labelrotation = 90
+                case (True, "right", "left" | "right"):
+                    if labelloc == "right":
+                        kw_label["va"] = "bottom"
+                    elif labelloc == "left":
+                        kw_label["va"] = "top"
+                    labelrotation = -90
+                case (True, None, _):
+                    labelrotation = 90
+                # Horizontal colorbar
+                case (False, _, _):
+                    if labelloc == "left":
+                        kw_label["va"] = "center"
+                        labelrotation = 90
+                    elif labelloc == "right":
+                        kw_label["va"] = "center"
+                        labelrotation = 270
+                    else:
+                        labelrotation = 0
+                case Number():
+                    pass
+                case _:
+                    labelrotation = 0
+
+            kw_label.update({"rotation": labelrotation})
         axis.label.update(kw_label)
         # Assume ticks are set on the long axis(!)
         for label in obj._long_axis().get_ticklabels():
diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py
index 27eecd1a..2484235c 100644
--- a/ultraplot/internals/rcsetup.py
+++ b/ultraplot/internals/rcsetup.py
@@ -421,6 +421,15 @@ def _validate_units(dest):
     return _validate_units
 
 
+def _validate_float_or_auto(value):
+    if value == "auto":
+        return value
+    try:
+        return float(value)
+    except (ValueError, TypeError):
+        raise ValueError(f"Value must be a float or 'auto', got {value!r}")
+
+
 def _rst_table():
     """
     Return the setting names and descriptions in an RST-style table.
@@ -992,6 +1001,16 @@ _rc_ultraplot_table = {
         'Length of rectangular or triangular "extensions" for panel colorbars.'
         + _addendum_em,
     ),
+    "colorbar.outline": (
+        True,
+        _validate_bool,
+        "Whether to draw a frame around the colorbar.",
+    ),
+    "colorbar.labelrotation": (
+        "auto",
+        _validate_float_or_auto,
+        "Rotation of colorbar labels.",
+    ),
     "colorbar.fancybox": (
         False,
         _validate_bool,


Test patch:
diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py
index 66f2e4db..2ae91c02 100644
--- a/ultraplot/tests/test_colorbar.py
+++ b/ultraplot/tests/test_colorbar.py
@@ -290,6 +290,59 @@ def test_label_placement_colorbar():
         ax.colorbar(h, loc=loc, labelloc=labelloc)
 
 
+def test_label_rotation_colorbar():
+    """
+    Ensure that all potential combinations of colorbar
+    label rotation is possible.
+    """
+    cmap = uplt.colormaps.get_cmap("viridis")
+    mylabel = "My Label"
+    fig, ax = uplt.subplots()
+    cbar = ax.colorbar(cmap, labelloc="top", loc="right", vert=False, labelrotation=23)
+    # Get the label Text object
+    for which in "xy":
+        tmp = getattr(cbar.ax, f"{which}axis").label
+        if tmp.get_text() == mylabel:
+            label = tmp
+            assert label.get_rotation() == 23
+            break
+
+
+def test_auto_labelrotation():
+    from itertools import product
+
+    locs = ["top", "bottom", "left", "right"]
+    labellocs = ["top", "bottom", "left", "right"]
+
+    cmap = uplt.colormaps.get_cmap("viridis")
+    mylabel = "My Label"
+
+    for loc, labelloc in product(locs, labellocs):
+        fig, ax = uplt.subplots()
+        cbar = ax.colorbar(cmap, loc=loc, labelloc=labelloc, label=mylabel)
+
+        # Get the label Text object
+        for which in "xy":
+            tmp = getattr(cbar.ax, f"{which}axis").label
+            if tmp.get_text() == mylabel:
+                label = tmp
+                break
+
+        is_vertical = loc in ("left", "right")
+        is_horizontal = not is_vertical
+
+        expected_rotation = 0
+        if labelloc == "left":
+            expected_rotation = 90
+        elif labelloc == "right":
+            expected_rotation = 270
+
+        actual_rotation = label.get_rotation()
+        ax.set_title(f"loc={loc}, labelloc={labelloc}, rotation={actual_rotation}")
+        assert actual_rotation == expected_rotation
+        uplt.close(fig)
+
+
 @pytest.mark.mpl_image_compare
 def test_label_placement_fig_colorbar2():
     """