diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c8c5c579d..bfa31f5df 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -9,9 +9,11 @@ import numpy as np from matplotlib import cm as mcm from matplotlib import colors as mcolors +from matplotlib.colors import is_color_like as _mpl_is_color_like from matplotlib import lines as mlines from matplotlib import legend as mlegend from matplotlib import legend_handler as mhandler +from matplotlib.markers import MarkerStyle from .config import rc from .internals import _not_none, _pop_props, guides, rcsetup @@ -91,8 +93,25 @@ def __init__( markeredgecolor=None, markeredgewidth=None, alpha=None, + marker_capstyle=None, + marker_joinstyle=None, + marker_transform=None, **kwargs, ): + if ( + marker_capstyle is not None + or marker_joinstyle is not None + or marker_transform is not None + ): + if not isinstance(marker, MarkerStyle): + marker_kw = {} + if marker_capstyle is not None: + marker_kw["capstyle"] = marker_capstyle + if marker_joinstyle is not None: + marker_kw["joinstyle"] = marker_joinstyle + if marker_transform is not None: + marker_kw["transform"] = marker_transform + marker = MarkerStyle(marker, **marker_kw) marker = "o" if marker is None and not line else marker linestyle = "none" if not line else linestyle if markerfacecolor is None and color is not None: @@ -851,12 +870,92 @@ def _geo_legend_entries( return handles, label_list -def _style_lookup(style, key, index, default=None): +# _is_color_like should only check the following args +_COLOR_KEYS = { + "color", + "facecolor", + "edgecolor", + "markerfacecolor", + "markeredgecolor", + "markerfacecoloralt", +} + + +def _is_color_like(value): + """ + Determine whether a value can be interpreted as a color (including RGBA tuples). + + For tuple/list, if its length is 3 or 4 and each element is a number + strictly in the range [0, 1], it is treated as a color rather than a style list. + """ + if value is None: + return False + # matplotlib's is_color_like can already handle tuples like (1, 0, 0.5) + # But we additionally check for numeric sequences with values in [0, 1] + # to avoid misidentifying coordinate pairs or other numeric lists as colors. + if isinstance(value, tuple): + if len(value) in (3, 4): + # Ensure all elements are numbers within [0, 1] + if all(isinstance(v, (int, float)) and 0.0 <= v <= 1.0 for v in value): + print( + f"Tuple {value} treated as a single color. Pass a list to apply per entry." + ) + return True + # List shouldn't be converted to color, to prevent confusion. + if isinstance(value, list): + return False + return _mpl_is_color_like(value) + + +# Line2D / LegendEntry alias mapping +_LINE_ALIAS_MAP = { + "c": "color", + "m": "marker", + "ms": "markersize", + "ls": "linestyle", + "lw": "linewidth", + "mec": "markeredgecolor", + "mew": "markeredgewidth", + "mfc": "markerfacecolor", + "mfcalt": "markerfacecoloralt", + "aa": "antialiased", + "fs": "fillstyle", + # "ec": "markeredgecolor", # Compatible with 'ec' in Line2D context + # "fc": "markerfacecolor", # Compatible with 'fc' in Line2D context +} + +# Patch alias mapping +_PATCH_ALIAS_MAP = { + "c": "color", + "fc": "facecolor", + "ec": "edgecolor", + "ls": "linestyle", + "lw": "linewidth", + "aa": "antialiased", +} + + +def _style_lookup(style, key, index, default=None, *, prop=None): """ - Resolve style values from scalar, mapping, or sequence inputs. + Resolve a style value from scalar, mapping, or sequence inputs. + + Parameters + ---------- + style : the style value (scalar, list, dict) + key : dict key when `style` is a mapping (typically a label) + index : list index when `style` is a sequence + default : fallback value + prop : optional attribute name; if it belongs to _COLOR_KEYS, + the function treats color-like sequences as single colors. """ if style is None: return default + + # Only perform color detection for known color properties + check_color = prop is not None and prop in _COLOR_KEYS + + if check_color and _is_color_like(style): + return style if isinstance(style, dict): return style.get(key, default) if isinstance(style, str): @@ -867,7 +966,10 @@ def _style_lookup(style, key, index, default=None): return style if not values: return default - return values[index % len(values)] + val = values[index % len(values)] + if check_color and _is_color_like(val): + return val + return val def _format_label(value, fmt): @@ -901,24 +1003,60 @@ def _default_cycle_colors(): "linestyles": "linestyle", "linewidths": "markeredgewidth", "sizes": "markersize", + "size": "markersize", } def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ - Pop style properties with line/scatter aliases for LegendEntry objects. + Extract LegendEntry style properties from kwargs. + Supports: + - Aliases (like 'c', 'ls', 'lw', 'mec', etc.) are automatically converted to full names + - Plural collection parameters (like 'colors', 'edgecolors') are converted to singular + - Full name parameters take precedence over aliases """ + # 1. Extract and resolve aliases (pop alias keys, map to full names) + resolved_aliases = {} + for alias in list(kwargs.keys()): + if alias in _LINE_ALIAS_MAP: + full_key = _LINE_ALIAS_MAP[alias] + resolved_aliases[full_key] = kwargs.pop(alias) + + # 2. Extract explicit collection-style plural parameters (like 'colors', 'edgecolors') explicit_collection = {} for key in _ENTRY_STYLE_FROM_COLLECTION: if key in kwargs: explicit_collection[key] = kwargs.pop(key) + + # 3. Use ultraplot's internal _pop_props to extract 'line' and 'collection' category properties props = _pop_props(kwargs, "line") collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) + + # 4. Map collection plural parameters to singular property names + # only if the singular name is not already set) for source, target in _ENTRY_STYLE_FROM_COLLECTION.items(): value = collection_props.get(source, None) if value is not None and target not in props: props[target] = value + + # 5. Merge resolved aliases (aliases have lowest priority, + # do not overwrite existing full-name parameters) + for full_key, value in resolved_aliases.items(): + if full_key not in props: + props[full_key] = value + # NEW: grab any remaining kwargs that are valid Line2D setters + for key in list(kwargs.keys()): + # without this, line 645 of test_legend.py won't pass + if key in ("labels", "label"): + continue + if key.startswith("_"): + continue + if hasattr(mlines.Line2D, "set_" + key): + props[key] = kwargs.pop(key) + for key in ("marker_capstyle", "marker_joinstyle", "marker_transform"): + if key in kwargs: + props[key] = kwargs.pop(key) return props @@ -935,6 +1073,13 @@ def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ Pop patch/collection style aliases for numeric semantic legend entries. """ + # Resolve aliases first + resolved = {} + for key in list(kwargs.keys()): + if key in _PATCH_ALIAS_MAP: + full_key = _PATCH_ALIAS_MAP[key] + resolved[full_key] = kwargs.pop(key) + explicit_collection = {} for key in _NUM_STYLE_FROM_COLLECTION: if key in kwargs: @@ -946,6 +1091,11 @@ def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: value = collection_props.get(source, None) if value is not None and target not in props: props[target] = value + + for full_key, value in resolved.items(): + if full_key not in props: + props[full_key] = value + return props @@ -959,17 +1109,17 @@ def _resolve_style_values( """ output = {} for key, value in styles.items(): - resolved = _style_lookup(value, label, index, default=None) + resolved = _style_lookup(value, label, index, default=None, prop=key) if resolved is not None: output[key] = resolved return output def _cat_legend_entries( - categories: Iterable[Any], + categories, *, - colors=None, - markers="o", + color=None, + marker="o", line=False, linestyle="-", linewidth=2.0, @@ -980,9 +1130,6 @@ def _cat_legend_entries( markerfacecolor=None, **entry_kwargs, ): - """ - Build categorical semantic legend handles and labels. - """ labels = list(dict.fromkeys(categories)) palette = _default_cycle_colors() base_styles = { @@ -999,18 +1146,28 @@ def _cat_legend_entries( handles = [] for idx, label in enumerate(labels): styles = _resolve_style_values(base_styles, label, idx) - color = _style_lookup(colors, label, idx, default=palette[idx % len(palette)]) - marker = _style_lookup(markers, label, idx, default="o") line_value = bool(styles.pop("line", False)) - if line_value and marker in (None, ""): - marker = None - styles.pop("marker", None) + linestyle_value = styles.pop("linestyle", "-") + marker_value = styles.pop("marker", None) + + # If line=False but user provides a non-default linestyle, automatically enable line=True + if not line_value and linestyle_value not in (None, "-", "none", "None"): + line_value = True + + color_val = _style_lookup( + color, label, idx, default=palette[idx % len(palette)], prop="color" + ) + marker_val = _style_lookup(marker, label, idx, default="o", prop="marker") + if line_value and marker_val in (None, ""): + marker_val = None + handles.append( LegendEntry( label=str(label), - color=color, + color=color_val, line=line_value, - marker=marker, + marker=marker_val, + linestyle=linestyle_value, **styles, ) ) @@ -1169,8 +1326,12 @@ def _size_legend_entries( handles = [] for idx, (value, label, size) in enumerate(zip(values, label_list, ms)): styles = _resolve_style_values(base_styles, float(value), idx) - color_value = _style_lookup(color, float(value), idx, default="0.35") - marker_value = _style_lookup(marker, float(value), idx, default="o") + color_value = _style_lookup( + color, float(value), idx, default="0.35", prop="color" + ) + marker_value = _style_lookup( + marker, float(value), idx, default="o", prop="marker" + ) line_value = bool(styles.pop("line", False)) if line_value and marker_value in ("", None): marker_value = None @@ -1425,55 +1586,34 @@ def entrylegend( line: Optional[bool] = None, marker=None, color=None, - linestyle=None, - linewidth: Optional[float] = None, - markersize: Optional[float] = None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): - """ - Build generic semantic legend entries and optionally draw a legend. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + styles.update(_pop_entry_props(kwargs)) + line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) color = _not_none(color, styles.pop("color", None)) - linestyle = _not_none( - linestyle, - styles.pop("linestyle", None), - rc["legend.cat.linestyle"], - ) - linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.cat.linewidth"], - ) + linestyle = _not_none(styles.pop("linestyle", None), rc["legend.cat.linestyle"]) + linewidth = _not_none(styles.pop("linewidth", None), rc["legend.cat.linewidth"]) markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], + styles.pop("markersize", None), rc["legend.cat.markersize"] ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.cat.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.cat.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.cat.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) + handles, labels = _entry_legend_entries( entries, line=line, @@ -1490,71 +1630,57 @@ def entrylegend( ) if not add: return handles, labels - self._validate_semantic_kwargs("entrylegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("entrylegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def catlegend( self, categories: Iterable[Any], *, - colors=None, - markers=None, + color=None, # Originally 'colors', change to singular form + marker=None, # Originally 'markers', change to singular form line: Optional[bool] = None, - linestyle=None, - linewidth: Optional[float] = None, - markersize: Optional[float] = None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build categorical legend entries and optionally draw a legend. """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + # Merge handle_kw with auto-extracted styles + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + styles.update( + _pop_entry_props(kwargs) + ) # Alias-to-full-name conversion happens here + + # Apply rc default values line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) - colors = _not_none(colors, styles.pop("color", None)) - markers = _not_none( - markers, styles.pop("marker", None), rc["legend.cat.marker"] - ) - linestyle = _not_none( - linestyle, - styles.pop("linestyle", None), - rc["legend.cat.linestyle"], - ) - linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.cat.linewidth"], - ) + color = _not_none(color, styles.pop("color", None)) + marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) + linestyle = _not_none(styles.pop("linestyle", None), rc["legend.cat.linestyle"]) + linewidth = _not_none(styles.pop("linewidth", None), rc["legend.cat.linewidth"]) markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], + styles.pop("markersize", None), rc["legend.cat.markersize"] ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.cat.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.cat.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.cat.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) + + # Remaining styles are passed as additional entry properties + # (e.g., 'markerfacecoloralt') to _cat_legend_entries handles, labels = _cat_legend_entries( categories, - colors=colors, - markers=markers, + color=color, + marker=marker, line=line, linestyle=linestyle, linewidth=linewidth, @@ -1567,10 +1693,9 @@ def catlegend( ) if not add: return handles, labels - self._validate_semantic_kwargs("catlegend", legend_kwargs) - # Route through Axes.legend so location shorthands (e.g. 'r', 'b') - # and queued guide keyword handling behave exactly like the public API. - return self.axes.legend(handles, labels, **legend_kwargs) + # Handle Patch styles and plural aliases + self._validate_semantic_kwargs("catlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def sizelegend( self, @@ -1583,40 +1708,30 @@ def sizelegend( scale: Optional[float] = None, minsize: Optional[float] = None, fmt=None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): - """ - Build size legend entries and optionally draw a legend. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + styles.update(_pop_entry_props(kwargs)) color = _not_none(color, styles.pop("color", None), rc["legend.size.color"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.size.marker"]) area = _not_none(area, rc["legend.size.area"]) scale = _not_none(scale, rc["legend.size.scale"]) minsize = _not_none(minsize, rc["legend.size.minsize"]) fmt = _not_none(fmt, rc["legend.size.format"]) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.size.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.size.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.size.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.size.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.size.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.size.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) handles, labels = _size_legend_entries( levels, labels=labels, @@ -1634,8 +1749,8 @@ def sizelegend( ) if not add: return handles, labels - self._validate_semantic_kwargs("sizelegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("sizelegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def numlegend( self, @@ -1654,30 +1769,28 @@ def numlegend( alpha=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): - """ - Build numeric-color legend entries and optionally draw a legend. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_num_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_num_props(handle_kw)) # Handle explicit handle_kw first + styles.update(_pop_num_props(kwargs)) # Handle Patch styles and plural aliases + color = styles.pop("color", None) n = _not_none(n, rc["legend.num.n"]) cmap = _not_none(cmap, rc["legend.num.cmap"]) facecolor = _not_none(facecolor, styles.pop("facecolor", None), color) edgecolor = _not_none( - edgecolor, - styles.pop("edgecolor", None), - rc["legend.num.edgecolor"], + edgecolor, styles.pop("edgecolor", None), rc["legend.num.edgecolor"] ) linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.num.linewidth"], + linewidth, styles.pop("linewidth", None), rc["legend.num.linewidth"] ) linestyle = _not_none(linestyle, styles.pop("linestyle", None)) alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.num.alpha"]) fmt = _not_none(fmt, rc["legend.num.format"]) + + # Remaining styles may include 'hatch', 'joinstyle', 'capstyle', 'fill', etc. handles, labels = _num_legend_entries( levels=levels, vmin=vmin, @@ -1693,10 +1806,11 @@ def numlegend( alpha=alpha, **styles, ) + if not add: return handles, labels - self._validate_semantic_kwargs("numlegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("numlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def geolegend( self, @@ -1712,20 +1826,14 @@ def geolegend( linewidth: Optional[float] = None, alpha: Optional[float] = None, fill: Optional[bool] = None, + handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): - """ - Build geometry legend entries and optionally draw a legend. - - Notes - ----- - Geometry legend entries use normalized patch proxies inside the legend - handle box rather than reusing the original map artist directly. This - preserves the general geometry shape and copied patch styling, but very - small or high-aspect-ratio handles can still make hatches difficult to - read at legend scale. - """ + # Geolegend can accept Patch styles (linestyle, hatch, etc.), similar to numlegend + styles = dict(handle_kw or {}) + styles.update(_pop_num_props(kwargs)) + facecolor = _not_none(facecolor, rc["legend.geo.facecolor"]) edgecolor = _not_none(edgecolor, rc["legend.geo.edgecolor"]) linewidth = _not_none(linewidth, rc["legend.geo.linewidth"]) @@ -1737,6 +1845,8 @@ def geolegend( ) country_proj = _not_none(country_proj, rc["legend.geo.country_proj"]) handlesize = _not_none(handlesize, rc["legend.geo.handlesize"]) + + # Additional styles (e.g., linestyle, hatch, joinstyle) are merged later handles, labels = _geo_legend_entries( entries, labels=labels, @@ -1748,19 +1858,20 @@ def geolegend( linewidth=linewidth, alpha=alpha, fill=fill, + **styles, # Additional Patch properties ) if not add: return handles, labels - self._validate_semantic_kwargs("geolegend", legend_kwargs) + self._validate_semantic_kwargs("geolegend", kwargs) if handlesize is not None: handlesize = float(handlesize) if handlesize <= 0: raise ValueError("geolegend handlesize must be positive.") - if "handlelength" not in legend_kwargs: - legend_kwargs["handlelength"] = rc["legend.handlelength"] * handlesize - if "handleheight" not in legend_kwargs: - legend_kwargs["handleheight"] = rc["legend.handleheight"] * handlesize - return self.axes.legend(handles, labels, **legend_kwargs) + if "handlelength" not in kwargs: + kwargs["handlelength"] = rc["legend.handlelength"] * handlesize + if "handleheight" not in kwargs: + kwargs["handleheight"] = rc["legend.handleheight"] * handlesize + return self.axes.legend(handles, labels, **kwargs) @staticmethod def _align_map() -> dict[Optional[str], dict[str, str]]: diff --git a/ultraplot/tests/test_sematic_legend.py b/ultraplot/tests/test_sematic_legend.py new file mode 100644 index 000000000..4d7239044 --- /dev/null +++ b/ultraplot/tests/test_sematic_legend.py @@ -0,0 +1,367 @@ +""" +Unit tests for semantic legend style aliases and color detection. +""" + +import matplotlib + +matplotlib.use("Agg") # Must be before any other matplotlib import for local test +import numpy as np +import pytest +from matplotlib import colors as mcolors + +import ultraplot as uplt + + +# ----------------------------------------------------------------------------- +# Color detection +# ----------------------------------------------------------------------------- +def test_catlegend_rgba_tuple_is_color(): + """RGBA tuple like (1, 0, 0.5, 0.5) is treated as a single color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("ABC"), color=(0.2, 0.4, 0.6, 0.8), add=False) + colors = [h.get_color() for h in handles] + assert all( + c == colors[0] for c in colors + ), f"All entries should share the same color, got {colors}" + finally: + uplt.close(fig) + + +def test_catlegend_rgba_list_of_tuples(): + """List of RGBA tuples is treated as a per‑entry color list.""" + c1 = (1.0, 0.0, 0.0, 1.0) + c2 = (0.0, 1.0, 0.0, 1.0) + c3 = (0.0, 0.0, 1.0, 1.0) + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("ABC"), color=[c1, c2, c3], add=False) + assert handles[0].get_color() == c1 + assert handles[1].get_color() == c2 + assert handles[2].get_color() == c3 + finally: + uplt.close(fig) + + +def test_numlegend_facecolor_rgba_tuple_is_color(): + """RGBA facecolor for numlegend is not mistaken for a list.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, facecolor=(0.8, 0.2, 0.3, 0.6), add=False + ) + ref = np.array(handles[0].get_facecolor()) + for h in handles: + assert np.allclose( + np.array(h.get_facecolor()), ref + ), "All patches should have identical facecolor" + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Line2D style aliases (catlegend) +# ----------------------------------------------------------------------------- +def test_alias_c_color(): + """'c' is an alias for 'color'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), c="red", add=False) + for h in handles: + assert h.get_color() == "red" + finally: + uplt.close(fig) + + +def test_alias_m_marker(): + """'m' is an alias for 'marker'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), m="^", add=False) + for h in handles: + assert h.get_marker() == "^" + finally: + uplt.close(fig) + + +def test_alias_ms_markersize_list(): + """'ms' can be a list that cycles through entries.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("ABCD"), ms=[10, 20], add=False) + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 10 # wraps around + finally: + uplt.close(fig) + + +def test_alias_ls_linestyle(): + """'ls' is an alias for 'linestyle'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), ls="--", add=False) + for h in handles: + assert h.get_linestyle() == "--" + finally: + uplt.close(fig) + + +def test_alias_lw_linewidth(): + """'lw' is an alias for 'linewidth'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), lw=3.0, add=False) + for h in handles: + assert h.get_linewidth() == 3.0 + finally: + uplt.close(fig) + + +def test_alias_mec_markeredgecolor(): + """'mec' is an alias for 'markeredgecolor'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mec="blue", add=False) + for h in handles: + assert h.get_markeredgecolor() == "blue" + finally: + uplt.close(fig) + + +def test_alias_mew_markeredgewidth(): + """'mew' is an alias for 'markeredgewidth'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mew=2.0, add=False) + for h in handles: + assert h.get_markeredgewidth() == 2.0 + finally: + uplt.close(fig) + + +def test_alias_mfc_markerfacecolor(): + """'mfc' is an alias for 'markerfacecolor'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mfc="yellow", add=False) + for h in handles: + assert h.get_markerfacecolor() == "yellow" + finally: + uplt.close(fig) + + +def test_alias_mfcalt_markerfacecoloralt(): + """'mfcalt' is an alias for 'markerfacecoloralt'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mfcalt="orange", add=False) + for h in handles: + assert h.get_markerfacecoloralt() == "orange" + finally: + uplt.close(fig) + + +def test_alias_aa_antialiased(): + """'aa' is an alias for 'antialiased'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), aa=False, add=False) + for h in handles: + assert h.get_antialiased() is False + finally: + uplt.close(fig) + + +def test_alias_fs_fillstyle(): + """'fs' is an alias for 'fillstyle'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), fs="none", add=False) + for h in handles: + assert h.get_fillstyle() == "none" + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Patch style aliases (numlegend) +# ----------------------------------------------------------------------------- +def test_numlegend_alias_fc_facecolor(): + """'fc' is an alias for 'facecolor' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, fc="lightblue", add=False) + for h in handles: + assert h.get_facecolor()[:3] == mcolors.to_rgb("lightblue") + finally: + uplt.close(fig) + + +def test_numlegend_alias_ec_edgecolor(): + """'ec' is an alias for 'edgecolor' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, ec="black", add=False) + for h in handles: + assert h.get_edgecolor()[:3] == (0.0, 0.0, 0.0) + finally: + uplt.close(fig) + + +def test_numlegend_alias_ls_linestyle(): + """'ls' is an alias for 'linestyle' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, ls=":", add=False) + for h in handles: + assert h.get_linestyle() == ":" + finally: + uplt.close(fig) + + +def test_numlegend_alias_lw_linewidth(): + """'lw' is an alias for 'linewidth' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, lw=1.5, add=False) + for h in handles: + assert h.get_linewidth() == 1.5 + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Alias priority & dict styles +# ----------------------------------------------------------------------------- +def test_alias_and_fullname_priority(): + """Full name should override its alias.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), markersize=15, ms=99, add=False) + for h in handles: + assert h.get_markersize() == 15 + finally: + uplt.close(fig) + + +def test_alias_dict_style(): + """Aliases work with dictionary-based per‑label styles.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend( + list("ABC"), + c={"A": "red", "B": "green", "C": "blue"}, + ms={"A": 10, "B": 20, "C": 30}, + add=False, + ) + assert handles[0].get_color() == "red" + assert handles[1].get_color() == "green" + assert handles[2].get_color() == "blue" + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 30 + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# sizelegend alias support +# ----------------------------------------------------------------------------- +def test_sizelegend_alias_c(): + """sizelegend accepts 'c' as color alias.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.sizelegend([1, 2, 3], c="purple", add=False) + for h in handles: + assert h.get_color() == "purple" + finally: + uplt.close(fig) + + +def test_sizelegend_alias_mec(): + """sizelegend accepts 'mec' for markeredgecolor.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.sizelegend([1, 2, 3], mec="green", add=False) + for h in handles: + assert h.get_markeredgecolor() == "green" + finally: + uplt.close(fig) + + +def test_catlegend_ms_length_three_is_not_color(): + """ms list of length 3 should be treated as per‑entry markersize, not a color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), ms=[10, 20, 30], add=False) + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 30 + finally: + uplt.close(fig) + + +def test_catlegend_lw_length_three(): + """Linewidth list of length 3 should work.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), lw=[1.5, 2.5, 3.5], line=True, add=False) + assert handles[0].get_linewidth() == 1.5 + assert handles[1].get_linewidth() == 2.5 + assert handles[2].get_linewidth() == 3.5 + finally: + uplt.close(fig) + + +def test_catlegend_alpha_length_three(): + """Alpha list of length 3 should be per‑entry, not mistaken for a color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), alpha=[0.2, 0.5, 0.8], add=False) + assert handles[0].get_alpha() == 0.2 + assert handles[1].get_alpha() == 0.5 + assert handles[2].get_alpha() == 0.8 + finally: + uplt.close(fig) + + +def test_catlegend_color_as_list_of_rgba_tuples(): + """Color with list of RGBA tuples still works correctly.""" + c1 = (1.0, 0.0, 0.0, 1.0) + c2 = (0.0, 1.0, 0.0, 1.0) + c3 = (0.0, 0.0, 1.0, 1.0) + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), color=[c1, c2, c3], add=False) + assert handles[0].get_color() == c1 + assert handles[1].get_color() == c2 + assert handles[2].get_color() == c3 + finally: + uplt.close(fig)