diff --git a/ultraplot/colors.py b/ultraplot/colors.py index 6d1c18cc4..bab2f5c7a 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -17,6 +17,7 @@ # colors or truncate colors. So we translate the relevant ListedColormaps to # LinearSegmentedColormaps for consistency. See :rc:`cmap.listedthresh` import functools +import itertools import json import os import re @@ -1715,6 +1716,20 @@ def __repr__(self): string += "})" return string + @property + def monochrome(self): + """Whether every color is identical, normalized to a Python boolean.""" + if hasattr(self, "_monochrome"): + return self._monochrome + try: + return bool(super().monochrome) + except AttributeError: + return False + + @monochrome.setter + def monochrome(self, value): + self._monochrome = bool(value) + def __init__(self, colors, name=None, N=None, alpha=None, **kwargs): """ Parameters @@ -1748,7 +1763,13 @@ def __init__(self, colors, name=None, N=None, alpha=None, **kwargs): # identical monochromatic ListedColormaps when it receives scalar colors. N = _not_none(N, len(colors)) name = _not_none(name, DEFAULT_NAME) - super().__init__(colors, name=name, N=N, **kwargs) + if isinstance(colors, str): + colors = [colors] * N + elif np.iterable(colors): + colors = list(itertools.islice(itertools.cycle(colors), N)) + else: + colors = [colors] * N + super().__init__(colors, name=name, **kwargs) if alpha is not None: self.set_alpha(alpha) for i, color in enumerate(self.colors): diff --git a/ultraplot/tests/test_colors.py b/ultraplot/tests/test_colors.py index 407edbd0c..b1e78e009 100644 --- a/ultraplot/tests/test_colors.py +++ b/ultraplot/tests/test_colors.py @@ -1,12 +1,83 @@ import os +import warnings + +import matplotlib as mpl +import matplotlib.colors as mcolors import pytest import numpy as np -import matplotlib.colors as mcolors from ultraplot import colors as pcolors from ultraplot import config +@pytest.mark.parametrize( + ("N", "expected"), + ( + (None, ["#ff0000", "#0000ff"]), + (0, []), + (1, ["#ff0000"]), + (3, ["#ff0000", "#0000ff", "#ff0000"]), + ), +) +def test_discrete_colormap_resizes_without_listed_n_warning(N, expected): + """DiscreteColormap preserves N semantics without deprecated mpl input.""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message=r"Passing 'N' to ListedColormap.*", + category=mpl.MatplotlibDeprecationWarning, + ) + cmap = pcolors.DiscreteColormap(["red", "blue"], N=N) + + assert cmap.N == len(expected) + assert [mcolors.to_hex(color) for color in cmap.colors] == expected + + +@pytest.mark.parametrize( + ("N", "expected_size"), + ( + (None, 3), + (1, 1), + (4, 4), + ), +) +def test_discrete_colormap_expands_scalar_color(N, expected_size): + """Scalar color strings produce the requested monochromatic colormap.""" + cmap = pcolors.DiscreteColormap("red", N=N) + + assert cmap.N == expected_size + assert [mcolors.to_hex(color) for color in cmap.colors] == [ + "#ff0000" + ] * expected_size + assert cmap.monochrome is True + + +def test_discrete_colormap_resizing_preserves_alpha_override(): + """Alpha replacement is applied to every color after cycling the input.""" + cmap = pcolors.DiscreteColormap([(1, 0, 0, 0.2), (0, 0, 1, 0.8)], N=3, alpha=0.5) + + assert [mcolors.to_hex(color, keep_alpha=True) for color in cmap.colors] == [ + "#ff000080", + "#0000ff80", + "#ff000080", + ] + assert cmap.monochrome is False + + +@pytest.mark.parametrize( + ("colors", "expected"), + ( + (["red", "red"], True), + (["red", "blue"], False), + ), +) +def test_discrete_colormap_monochrome_is_python_bool(colors, expected): + """Monochrome detection remains available across matplotlib versions.""" + cmap = pcolors.DiscreteColormap(colors) + + assert cmap.monochrome is expected + + @pytest.fixture(autouse=True) def setup_teardown(): """