Source code for geeViz.outputLib.themes

"""
Unified theme system for geeViz output libraries.

Provides a :class:`Theme` class that holds all resolved color values for
backgrounds, text, accents, borders, and chart styling.  Used by
``charts``, ``thumbs``, and ``reports`` for consistent colors.

Usage::

    from geeViz.outputLib.themes import get_theme

    # Named presets
    dark  = get_theme("dark")
    light = get_theme("light")
    teal  = get_theme("teal")

    # Auto-generate from a single color
    red_bg = get_theme(bg_color="#F00")           # dark text auto-picked
    custom = get_theme(bg_color="#1a1a2e", font_color="#eee")

    # Access colors in different formats
    dark.bg_hex          # '#272822'
    dark.bg_rgb          # (39, 40, 34)
    dark.text_hex        # '#f8f8f2'
    dark.is_dark         # True
    dark.grid_rgba       # 'rgba(248,248,242,0.15)'
"""

"""
   Copyright 2026 Ian Housman

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
"""

from geeViz.outputLib._colors import resolve_color, luminance, to_hex, to_rgba, blend

import colorsys


# ---------------------------------------------------------------------------
#  Color derivation helpers
# ---------------------------------------------------------------------------
def _rgb_to_hsl(rgb):
    """Convert an RGB color tuple to HSL representation.

    Converts a color from the ``(R, G, B)`` color space (each component
    in the 0--255 range) to ``(H, S, L)`` where H is in degrees (0--360)
    and S and L are floating-point values in the 0--1 range.  Internally
    delegates to :func:`colorsys.rgb_to_hls` with appropriate rescaling.

    Args:
        rgb (tuple): An ``(R, G, B)`` tuple with integer values in the
            range 0--255.

    Returns:
        tuple: A ``(H, S, L)`` tuple where *H* is a float in 0--360 and
        *S* and *L* are floats in 0--1.

    Example:
        >>> _rgb_to_hsl((255, 0, 0))
        (0.0, 1.0, 0.5)
    """
    r, g, b = rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    return h * 360.0, s, l


def _hsl_to_rgb(h, s, l):
    """Convert an HSL color to an RGB tuple.

    Converts from the ``(H, S, L)`` color space back to ``(R, G, B)``
    with integer components in the 0--255 range.  Internally delegates
    to :func:`colorsys.hls_to_rgb` with appropriate rescaling.

    Args:
        h (float): Hue in degrees, 0--360.
        s (float): Saturation, 0--1.
        l (float): Lightness, 0--1.

    Returns:
        tuple: An ``(R, G, B)`` tuple with integer values in the range
        0--255.

    Example:
        >>> _hsl_to_rgb(0.0, 1.0, 0.5)
        (255, 0, 0)
    """
    r, g, b = colorsys.hls_to_rgb(h / 360.0, l, s)
    return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255)))


def _is_grayscale(rgb):
    """Check whether a color is approximately grayscale.

    A color is considered grayscale when the difference between its
    maximum and minimum RGB channel values is less than 20.  This is
    used by the accent/highlight derivation helpers to decide whether
    to preserve a neutral palette or introduce chromatic shifts.

    Args:
        rgb (tuple): An ``(R, G, B)`` tuple with integer values in the
            range 0--255.

    Returns:
        bool: ``True`` if the color is roughly grayscale (i.e.,
        ``max(R, G, B) - min(R, G, B) < 20``), ``False`` otherwise.

    Example:
        >>> _is_grayscale((128, 130, 126))
        True
        >>> _is_grayscale((255, 0, 0))
        False
    """
    return max(rgb) - min(rgb) < 20


def _derive_accent(bg, text, is_dark):
    """Generate an accent color from background and text colors.

    For **grayscale** text the accent stays grayscale -- just a shade
    closer to the background (e.g. white text becomes a light gray accent,
    black text becomes a dark gray accent).  This is achieved by blending
    30 % toward the background color.

    For **chromatic** text the accent keeps the same hue but is pushed to
    higher saturation and a mid-range lightness suitable for headings or
    links.

    Args:
        bg (tuple): Background color as an ``(R, G, B)`` tuple (0--255).
        text (tuple): Primary text color as an ``(R, G, B)`` tuple (0--255).
        is_dark (bool): ``True`` if the background is dark, which
            influences the target lightness of the accent.

    Returns:
        tuple: An ``(R, G, B)`` accent color tuple (0--255).

    Example:
        >>> _derive_accent((39, 40, 34), (248, 248, 242), True)
        (185, 186, 180)
    """
    if _is_grayscale(text):
        # Grayscale path: blend 30% toward bg
        return blend(text, bg, 0.30)

    text_h, text_s, text_l = _rgb_to_hsl(text)
    accent_h = text_h
    accent_s = max(0.75, min(0.95, text_s + 0.55))
    if is_dark:
        accent_l = max(0.45, min(0.60, 0.55))
    else:
        accent_l = max(0.35, min(0.50, 0.42))
    return _hsl_to_rgb(accent_h, accent_s, accent_l)


def _derive_highlight(bg, text, is_dark, accent):
    """Generate a highlight color from background, text, and accent colors.

    The highlight is a subtler, brighter variant of the accent color used
    for emphasis elements such as hover states or secondary headings.

    For **grayscale** text the highlight is a smaller step toward the
    background than the accent (15 % blend vs. 30 %), keeping it closer
    to the original font color.

    For **chromatic** text the highlight takes the accent's hue and
    increases its lightness and saturation slightly.

    Args:
        bg (tuple): Background color as an ``(R, G, B)`` tuple (0--255).
        text (tuple): Primary text color as an ``(R, G, B)`` tuple (0--255).
        is_dark (bool): ``True`` if the background is dark, which
            influences the target lightness of the highlight.
        accent (tuple): The previously derived accent color as an
            ``(R, G, B)`` tuple (0--255).

    Returns:
        tuple: An ``(R, G, B)`` highlight color tuple (0--255).

    Example:
        >>> accent = _derive_accent((39, 40, 34), (248, 248, 242), True)
        >>> _derive_highlight((39, 40, 34), (248, 248, 242), True, accent)
        (217, 217, 211)
    """
    if _is_grayscale(text):
        # Grayscale path: blend 15% toward bg (subtler than accent)
        return blend(text, bg, 0.15)

    acc_h, acc_s, acc_l = _rgb_to_hsl(accent)
    highlight_h = acc_h
    highlight_s = min(1.0, acc_s + 0.08)
    if is_dark:
        highlight_l = min(0.78, acc_l + 0.18)
    else:
        highlight_l = min(0.62, acc_l + 0.14)
    return _hsl_to_rgb(highlight_h, highlight_s, highlight_l)


# ---------------------------------------------------------------------------
#  Theme class
# ---------------------------------------------------------------------------
[docs] class Theme: """Resolved color theme for geeViz visualizations. A ``Theme`` holds all of the resolved color values needed to style charts, thumbnails, reports, and other geeViz outputs consistently. All colors are stored internally as ``(R, G, B)`` tuples with integer components in the 0--255 range. Convenience properties provide hex-string, RGB-tuple, and RGBA-string formats for direct use in Plotly layouts, HTML/CSS, and Pillow operations. Colors that are not explicitly provided to the constructor are automatically derived from the ``bg`` and ``text`` colors using perceptually reasonable blending and HSL manipulation. Attributes: bg (tuple): Background color as an ``(R, G, B)`` tuple. text (tuple): Primary text/foreground color as an ``(R, G, B)`` tuple. accent (tuple): Accent color for headings and links as an ``(R, G, B)`` tuple. Derived from ``text`` if not provided. highlight (tuple): Highlight/emphasis color as an ``(R, G, B)`` tuple. Derived from ``accent`` if not provided. surface (tuple): Card or panel background color, slightly offset from ``bg``, as an ``(R, G, B)`` tuple. border (tuple): Border and table-line color as an ``(R, G, B)`` tuple. divider (tuple): Subtle separator/divider color as an ``(R, G, B)`` tuple. swatch_outline (tuple): Legend swatch outline color as an ``(R, G, B)`` tuple. muted_text (tuple): Secondary/caption text color as an ``(R, G, B)`` tuple. is_dark (bool): ``True`` if this is a dark-background theme (background luminance < 128). Example: >>> from geeViz.outputLib.themes import Theme >>> t = Theme(bg=(39, 40, 34), text=(248, 248, 242)) >>> t.is_dark True >>> t.bg_hex '#272822' """ __slots__ = ( "bg", "text", "accent", "highlight", "surface", "border", "divider", "swatch_outline", "muted_text", "is_dark", "title_font_size", "label_font_size", "font_family", ) def __init__(self, bg, text, accent=None, highlight=None, surface=None, border=None, divider=None, swatch_outline=None, muted_text=None, is_dark=None, title_font_size=18, label_font_size=12, font_family="Roboto Condensed"): """Initialize a Theme with explicit or auto-derived colors. Any color parameter that is ``None`` will be automatically derived from ``bg`` and ``text`` using perceptually reasonable defaults (blending, HSL shifts, etc.). Args: bg (tuple): Background color as an ``(R, G, B)`` tuple or list with integer values 0--255. text (tuple): Primary text color as an ``(R, G, B)`` tuple or list with integer values 0--255. accent (tuple, optional): Accent color for headings/links. Defaults to ``None`` (auto-derived from ``text``). highlight (tuple, optional): Highlight/emphasis color. Defaults to ``None`` (auto-derived from ``accent``). surface (tuple, optional): Card/panel background color. Defaults to ``None`` (auto-derived by blending ``bg`` slightly toward white or black). border (tuple, optional): Border/table-line color. Defaults to ``None`` (30 % blend of ``bg`` toward ``text``). divider (tuple, optional): Subtle separator color. Defaults to ``None`` (15 % blend of ``bg`` toward ``text``). swatch_outline (tuple, optional): Legend swatch outline color. Defaults to ``None`` (25 % blend of ``bg`` toward ``text``). muted_text (tuple, optional): Secondary/caption text color. Defaults to ``None`` (55 % blend of ``bg`` toward ``text``). is_dark (bool, optional): Force dark/light classification. Defaults to ``None`` (auto-detected from ``bg`` luminance). Returns: Theme: A fully resolved theme instance. Example: >>> t = Theme(bg=(0, 0, 0), text=(255, 255, 255)) >>> t.is_dark True >>> t.border # auto-derived (77, 77, 77) """ self.bg = tuple(bg) self.text = tuple(text) self.is_dark = luminance(self.bg) < 128 if is_dark is None else is_dark # Defaults derived from bg/text white = (255, 255, 255) black = (0, 0, 0) if accent is not None: self.accent = tuple(accent) else: self.accent = _derive_accent(self.bg, self.text, self.is_dark) if highlight is not None: self.highlight = tuple(highlight) else: self.highlight = _derive_highlight(self.bg, self.text, self.is_dark, self.accent) if surface is not None: self.surface = tuple(surface) else: # Slightly offset from bg self.surface = blend(self.bg, white, 0.06) if self.is_dark else blend(self.bg, black, 0.04) if border is not None: self.border = tuple(border) else: self.border = blend(self.bg, self.text, 0.3) if divider is not None: self.divider = tuple(divider) else: self.divider = blend(self.bg, self.text, 0.15) if swatch_outline is not None: self.swatch_outline = tuple(swatch_outline) else: self.swatch_outline = blend(self.bg, self.text, 0.25) if muted_text is not None: self.muted_text = tuple(muted_text) else: self.muted_text = blend(self.bg, self.text, 0.55) # Font sizing self.title_font_size = title_font_size self.label_font_size = label_font_size self.font_family = font_family # --- Font convenience properties -------------------------------------- @property def legend_title_font_size(self): """Legend title font size (1.15x label size).""" return max(self.label_font_size, int(self.label_font_size * 1.15)) # --- Hex properties --------------------------------------------------- @property def bg_hex(self): """Return the background color as a hex string. Returns: str: Background color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(39, 40, 34), text=(248, 248, 242)).bg_hex '#272822' """ return to_hex(self.bg) @property def text_hex(self): """Return the text color as a hex string. Returns: str: Text color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(39, 40, 34), text=(248, 248, 242)).text_hex '#f8f8f2' """ return to_hex(self.text) @property def accent_hex(self): """Return the accent color as a hex string. Returns: str: Accent color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255), accent=(0, 191, 165)).accent_hex '#00bfa5' """ return to_hex(self.accent) @property def highlight_hex(self): """Return the highlight color as a hex string. Returns: str: Highlight color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255), highlight=(255, 131, 76)).highlight_hex '#ff834c' """ return to_hex(self.highlight) @property def surface_hex(self): """Return the surface/panel color as a hex string. Returns: str: Surface color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(255, 255, 255), text=(0, 0, 0)).surface_hex '#f5f5f5' """ return to_hex(self.surface) @property def border_hex(self): """Return the border color as a hex string. Returns: str: Border color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).border_hex '#4d4d4d' """ return to_hex(self.border) @property def divider_hex(self): """Return the divider color as a hex string. Returns: str: Divider color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).divider_hex '#262626' """ return to_hex(self.divider) @property def muted_text_hex(self): """Return the muted text color as a hex string. Returns: str: Muted text color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).muted_text_hex '#8c8c8c' """ return to_hex(self.muted_text) @property def swatch_outline_hex(self): """Return the swatch outline color as a hex string. Returns: str: Swatch outline color in ``'#RRGGBB'`` format. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).swatch_outline_hex '#404040' """ return to_hex(self.swatch_outline) # --- RGB aliases (explicit for readability) ---------------------------- @property def bg_rgb(self): """Return the background color as an RGB tuple. This is an alias for the :attr:`bg` attribute, provided for symmetry with the ``*_hex`` properties. Returns: tuple: Background color as ``(R, G, B)`` with values 0--255. Example: >>> Theme(bg=(39, 40, 34), text=(248, 248, 242)).bg_rgb (39, 40, 34) """ return self.bg @property def text_rgb(self): """Return the text color as an RGB tuple. This is an alias for the :attr:`text` attribute, provided for symmetry with the ``*_hex`` properties. Returns: tuple: Text color as ``(R, G, B)`` with values 0--255. Example: >>> Theme(bg=(39, 40, 34), text=(248, 248, 242)).text_rgb (248, 248, 242) """ return self.text # --- RGBA convenience -------------------------------------------------- @property def grid_rgba(self): """Return a chart gridline color with appropriate alpha. Uses an alpha of 0.15 for dark themes and 0.1 for light themes to keep gridlines subtle against the background. Returns: str: RGBA color string, e.g. ``'rgba(248,248,242,0.15)'``. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).grid_rgba 'rgba(255,255,255,0.15)' """ a = 0.15 if self.is_dark else 0.1 return to_rgba(self.text, a) @property def line_rgba(self): """Return a chart axis line color with appropriate alpha. Uses an alpha of 0.25 for dark themes and 0.2 for light themes. Returns: str: RGBA color string, e.g. ``'rgba(248,248,242,0.25)'``. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).line_rgba 'rgba(255,255,255,0.25)' """ a = 0.25 if self.is_dark else 0.2 return to_rgba(self.text, a) @property def zeroline_rgba(self): """Return a chart zero-line color with appropriate alpha. Uses an alpha of 0.2 for dark themes and 0.15 for light themes. Returns: str: RGBA color string, e.g. ``'rgba(248,248,242,0.2)'``. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).zeroline_rgba 'rgba(255,255,255,0.2)' """ a = 0.2 if self.is_dark else 0.15 return to_rgba(self.text, a) @property def surface_rgba(self): """Return the surface color with alpha for table row striping. Uses an alpha of 0.5 for dark themes and 0.3 for light themes so that alternating rows are visible but not overpowering. Returns: str: RGBA color string for the surface, e.g. ``'rgba(55,46,44,0.5)'``. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).surface_rgba 'rgba(15,15,15,0.5)' """ a = 0.5 if self.is_dark else 0.3 return to_rgba(self.surface, a) @property def error_bg_rgba(self): """Return an error-box background color with appropriate alpha. Uses the highlight color with an alpha of 0.1 for dark themes and 0.08 for light themes, producing a tinted but unobtrusive error background. Returns: str: RGBA color string for the error background. Example: >>> t = Theme(bg=(0, 0, 0), text=(255, 255, 255), ... highlight=(255, 0, 0)) >>> t.error_bg_rgba 'rgba(255,0,0,0.1)' """ a = 0.1 if self.is_dark else 0.08 return to_rgba(self.highlight, a) @property def tooltip_bg_rgba(self): """Return a tooltip background as a semi-transparent inverse of bg. Dark themes get a near-black tooltip (``rgba(0,0,0,0.85)``); light themes get a near-white tooltip (``rgba(255,255,255,0.92)``). Returns: str: RGBA color string for tooltip backgrounds. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).tooltip_bg_rgba 'rgba(0,0,0,0.85)' """ if self.is_dark: return "rgba(0,0,0,0.85)" return "rgba(255,255,255,0.92)" @property def button_bg_rgba(self): """Return a toolbar button background color. Uses the muted text color at 15 % opacity. Returns: str: RGBA color string for button backgrounds. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).button_bg_rgba 'rgba(140,140,140,0.15)' """ return to_rgba(self.muted_text, 0.15) @property def button_hover_rgba(self): """Return a toolbar button hover background color. Uses the muted text color at 30 % opacity. Returns: str: RGBA color string for button hover backgrounds. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).button_hover_rgba 'rgba(140,140,140,0.3)' """ return to_rgba(self.muted_text, 0.3) @property def button_border_rgba(self): """Return a toolbar button border color. Uses the muted text color at 30 % opacity. Returns: str: RGBA color string for button borders. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).button_border_rgba 'rgba(140,140,140,0.3)' """ return to_rgba(self.muted_text, 0.3) @property def link_stroke_rgba(self): """Return a Sankey link stroke color for gradient edges. Dark themes use a faint white stroke (``rgba(255,255,255,0.15)``); light themes use a faint black stroke (``rgba(0,0,0,0.08)``). Returns: str: RGBA color string for Sankey link strokes. Example: >>> Theme(bg=(0, 0, 0), text=(255, 255, 255)).link_stroke_rgba 'rgba(255,255,255,0.15)' """ if self.is_dark: return "rgba(255,255,255,0.15)" return "rgba(0,0,0,0.08)" def __repr__(self): """Return a human-readable string representation of the theme. Returns: str: A string of the form ``Theme(bg='#272822', text='#f8f8f2', is_dark=True)``. Example: >>> repr(Theme(bg=(39, 40, 34), text=(248, 248, 242))) "Theme(bg='#272822', text='#f8f8f2', is_dark=True)" """ return f"Theme(bg={self.bg_hex!r}, text={self.text_hex!r}, is_dark={self.is_dark})"
# --------------------------------------------------------------------------- # Preset themes # --------------------------------------------------------------------------- _PRESETS = {}
[docs] def register_preset(name, theme): """Register a named theme preset for later retrieval via :func:`get_theme`. Presets are stored in a module-level dictionary keyed by the lower-cased name. Calling this function with a name that already exists will overwrite the previous preset. Args: name (str): Preset name (e.g. ``"dark"``, ``"ocean"``). Stored in lower case. theme (Theme): A :class:`Theme` instance to register. Returns: None Example: >>> t = Theme(bg=(0, 0, 0), text=(255, 255, 255)) >>> register_preset("midnight", t) >>> get_theme("midnight").bg_hex '#000000' """ _PRESETS[name.lower()] = theme
# Monokai dark (matches original geeViz dark theme) register_preset("dark", Theme( bg=(39, 40, 34), # #272822 text=(248, 248, 242), # #f8f8f2 accent=(0, 191, 165), # teal_80 highlight=(255, 131, 76), # orange surface=(55, 46, 44), # brown_100 border=(111, 98, 89), # brown_80 divider=(64, 64, 58), swatch_outline=(90, 90, 82), muted_text=(150, 139, 131), # brown_50 )) # Clean light register_preset("light", Theme( bg=(255, 255, 255), # #ffffff text=(55, 46, 44), # #372e2c (brown_100) accent=(0, 137, 123), # teal_100 highlight=(255, 103, 0), # orange_100 surface=(245, 243, 240), # #f5f3f0 border=(214, 209, 202), # brown_10 divider=(210, 210, 210), swatch_outline=(180, 180, 180), muted_text=(150, 139, 131), # brown_50 )) # Teal dark register_preset("teal", Theme( bg=(0, 51, 43), # deep teal text=(178, 236, 228), # teal_30 accent=(128, 223, 210), # teal_50 highlight=(255, 146, 72), # orange_80 surface=(0, 77, 64), # darker teal border=(0, 120, 100), divider=(0, 90, 75), swatch_outline=(0, 110, 92), muted_text=(100, 180, 168), )) # --------------------------------------------------------------------------- # Auto-derive a theme from a single color # --------------------------------------------------------------------------- def _auto_theme(bg_color=None, font_color=None): """Build a Theme by auto-deriving missing colors from those provided. This function implements the automatic color derivation logic used when :func:`get_theme` is called without a preset name. It follows these rules: - **Both given**: Use ``bg_color`` and ``font_color`` as-is; derive accent, highlight, surface, border, etc. via the :class:`Theme` constructor defaults. - **Only** ``bg_color``: Pick dark or light text based on the background luminance. - **Only** ``font_color``: Pick a dark or light background based on the font luminance. - **Neither**: Return a fresh :class:`Theme` using the ``"dark"`` preset's background and text (accent/highlight are re-derived). Args: bg_color (str or tuple, optional): Background color in any format accepted by :func:`resolve_color` (hex string, color name, or ``(R, G, B)`` tuple). Defaults to ``None``. font_color (str or tuple, optional): Text/font color in any format accepted by :func:`resolve_color`. Defaults to ``None``. Returns: Theme: A fully resolved :class:`Theme` instance. Example: >>> t = _auto_theme(bg_color="#1a1a2e") >>> t.is_dark True >>> t = _auto_theme(font_color="yellow") >>> t.is_dark True """ if bg_color is None and font_color is None: # Default dark — derive accent/highlight from the default text p = _PRESETS["dark"] return Theme(bg=p.bg, text=p.text) if bg_color is not None: bg = resolve_color(bg_color) else: # Derive bg from font_color: opposite luminance fc = resolve_color(font_color) bg = (20, 20, 20) if luminance(fc) >= 128 else (250, 250, 250) if font_color is not None: text = resolve_color(font_color) else: # Derive text from bg: high contrast text = (248, 248, 242) if luminance(bg) < 128 else (55, 46, 44) return Theme(bg=bg, text=text) # --------------------------------------------------------------------------- # Main entry point # ---------------------------------------------------------------------------
[docs] def get_theme(theme=None, bg_color=None, font_color=None): """Resolve a :class:`Theme` from a preset name, instance, or custom colors. This is the main entry point for obtaining a theme. It accepts a preset name (``"dark"``, ``"light"``, ``"teal"``), a :class:`Theme` instance (pass-through), a color string or tuple (treated as ``bg_color``), or ``None``. When ``bg_color`` and/or ``font_color`` are also supplied alongside a preset, they override the preset's background and text colors respectively, and accent/highlight are re-derived from the new colors. Args: theme (str or Theme or tuple or None, optional): A preset name (``"dark"``, ``"light"``, ``"teal"``), a :class:`Theme` instance (returned as-is unless overrides are given), a color string or ``(R, G, B)`` tuple (treated as ``bg_color``), or ``None``. Defaults to ``None``. bg_color (str or tuple, optional): Background color override in any format accepted by :func:`resolve_color`. Overrides the preset's background when combined with ``theme``. Defaults to ``None``. font_color (str or tuple, optional): Text/font color override in any format accepted by :func:`resolve_color`. Overrides the preset's text when combined with ``theme``. Defaults to ``None``. Returns: Theme: A fully resolved :class:`Theme` instance. Example: >>> get_theme("dark").bg_hex '#272822' >>> get_theme("light").is_dark False >>> get_theme(bg_color="#F00").is_dark True >>> get_theme("dark", font_color="yellow").text_hex '#ffff00' """ # If theme is already a Theme instance, optionally override colors if isinstance(theme, Theme): base = theme elif isinstance(theme, str): key = theme.lower() if key in _PRESETS: base = _PRESETS[key] else: # Treat as a bg_color string return _auto_theme(bg_color=theme, font_color=font_color) elif isinstance(theme, (list, tuple)): # Treat as bg_color tuple return _auto_theme(bg_color=theme, font_color=font_color) elif theme is None: return _auto_theme(bg_color=bg_color, font_color=font_color) else: return _auto_theme(bg_color=bg_color, font_color=font_color) # Apply overrides to a preset — re-derive accent/highlight from new colors if bg_color is not None or font_color is not None: bg = resolve_color(bg_color) if bg_color is not None else base.bg text = resolve_color(font_color) if font_color is not None else base.text return Theme(bg=bg, text=text) return base
# --------------------------------------------------------------------------- # Plotly integration # ---------------------------------------------------------------------------
[docs] def apply_plotly_theme(fig, theme=None, bg_color=None, font_color=None): """Apply a theme's colors to a Plotly figure in-place. Resolves a :class:`Theme` from the provided arguments (using :func:`get_theme`) and then updates the figure's layout -- including paper/plot background, font colors, axis grid/line/zeroline colors, title, legend, and annotation colors -- to match the theme. Args: fig (plotly.graph_objects.Figure): The Plotly figure to style. Modified in-place. theme (str or Theme or tuple or None, optional): Preset name, :class:`Theme` instance, or color string/tuple. Passed through to :func:`get_theme`. Defaults to ``None``. bg_color (str or tuple, optional): Background color override. Defaults to ``None``. font_color (str or tuple, optional): Font color override. Defaults to ``None``. Returns: plotly.graph_objects.Figure: The same figure, modified in-place, for method chaining convenience. Example: >>> import plotly.graph_objects as go >>> fig = go.Figure(data=[go.Bar(x=[1, 2], y=[3, 4])]) >>> fig = apply_plotly_theme(fig, "dark") >>> fig.layout.paper_bgcolor '#272822' """ t = get_theme(theme, bg_color=bg_color, font_color=font_color) fig.update_layout( paper_bgcolor=t.bg_hex, plot_bgcolor=t.bg_hex, font=dict(color=t.text_hex), ) axis_kwargs = dict( gridcolor=t.grid_rgba, linecolor=t.line_rgba, zerolinecolor=t.zeroline_rgba, tickfont=dict(color=t.text_hex), title_font=dict(color=t.text_hex), ) fig.update_xaxes(**axis_kwargs) fig.update_yaxes(**axis_kwargs) # Update title, legend, and annotations if fig.layout.title and fig.layout.title.text: fig.update_layout(title_font_color=t.text_hex) fig.update_layout(legend=dict(font=dict(color=t.text_hex))) for ann in fig.layout.annotations or []: ann.font.color = t.text_hex return fig