from __future__ import annotations from typing import Iterable import rich.repr from rich.console import group from rich.padding import Padding from rich.table import Table from rich.text import Text from textual.color import WHITE, Color NUMBER_OF_SHADES = 3 # Where no content exists DEFAULT_DARK_BACKGROUND = "#121212" # What text usually goes on top off DEFAULT_DARK_SURFACE = "#1e1e1e" DEFAULT_LIGHT_SURFACE = "#f5f5f5" DEFAULT_LIGHT_BACKGROUND = "#efefef" @rich.repr.auto class ColorSystem: """Defines a standard set of colors and variations for building a UI. Primary is the main theme color Secondary is a second theme color """ COLOR_NAMES = [ "primary", "secondary", "background", "primary-background", "secondary-background", "surface", "panel", "boost", "warning", "error", "success", "accent", ] def __init__( self, primary: str, secondary: str | None = None, warning: str | None = None, error: str | None = None, success: str | None = None, accent: str | None = None, foreground: str | None = None, background: str | None = None, surface: str | None = None, panel: str | None = None, boost: str | None = None, dark: bool = False, luminosity_spread: float = 0.15, text_alpha: float = 0.95, variables: dict[str, str] | None = None, ): def parse(color: str | None) -> Color | None: if color is None: return None return Color.parse(color) self.primary = Color.parse(primary) self.secondary = parse(secondary) self.warning = parse(warning) self.error = parse(error) self.success = parse(success) self.accent = parse(accent) self.foreground = parse(foreground) self.background = parse(background) self.surface = parse(surface) self.panel = parse(panel) self.boost = parse(boost) self.dark = dark self.luminosity_spread = luminosity_spread self.text_alpha = text_alpha self.variables = variables or {} """Overrides for specific variables.""" @property def shades(self) -> Iterable[str]: """The names of the colors and derived shades.""" for color in self.COLOR_NAMES: for shade_number in range(-NUMBER_OF_SHADES, NUMBER_OF_SHADES + 1): if shade_number < 0: yield f"{color}-darken-{abs(shade_number)}" elif shade_number > 0: yield f"{color}-lighten-{shade_number}" else: yield color def get_or_default(self, name: str, default: str) -> str: """Get the value of a color variable, or the default value if not set.""" return self.variables.get(name, default) def generate(self) -> dict[str, str]: """Generate a mapping of color name on to a CSS color. Returns: A mapping of color name on to a CSS-style encoded color """ primary = self.primary secondary = self.secondary or primary warning = self.warning or primary error = self.error or secondary success = self.success or secondary accent = self.accent or primary dark = self.dark luminosity_spread = self.luminosity_spread colors: dict[str, str] = {} if dark: background = self.background or Color.parse(DEFAULT_DARK_BACKGROUND) surface = self.surface or Color.parse(DEFAULT_DARK_SURFACE) else: background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND) surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE) foreground = self.foreground or (background.inverse) contrast_text = background.get_contrast_text(1.0) boost = self.boost or contrast_text.with_alpha(0.04) # Colored text colors["text-primary"] = contrast_text.tint(primary.with_alpha(0.66)).hex colors["text-secondary"] = contrast_text.tint(secondary.with_alpha(0.66)).hex colors["text-warning"] = contrast_text.tint(warning.with_alpha(0.66)).hex colors["text-error"] = contrast_text.tint(error.with_alpha(0.66)).hex colors["text-success"] = contrast_text.tint(success.with_alpha(0.66)).hex colors["text-accent"] = contrast_text.tint(accent.with_alpha(0.66)).hex if self.panel is None: panel = surface.blend(primary, 0.1, alpha=1) if dark: panel += boost else: panel = self.panel def luminosity_range(spread: float) -> Iterable[tuple[str, float]]: """Get the range of shades from darken2 to lighten2. Returns: Iterable of tuples () """ luminosity_step = spread / 2 for n in range(-NUMBER_OF_SHADES, +NUMBER_OF_SHADES + 1): if n < 0: label = "-darken" elif n > 0: label = "-lighten" else: label = "" yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step # Color names and color COLORS: list[tuple[str, Color]] = [ ("primary", primary), ("secondary", secondary), ("primary-background", primary), ("secondary-background", secondary), ("background", background), ("foreground", foreground), ("panel", panel), ("boost", boost), ("surface", surface), ("warning", warning), ("error", error), ("success", success), ("accent", accent), ] # Colors names that have a dark variant DARK_SHADES = {"primary-background", "secondary-background"} get = self.get_or_default for name, color in COLORS: is_dark_shade = dark and name in DARK_SHADES spread = luminosity_spread for shade_name, luminosity_delta in luminosity_range(spread): key = f"{name}{shade_name}" if color.ansi is not None: colors[key] = color.hex elif is_dark_shade: dark_background = background.blend(color, 0.15, alpha=1.0) if key not in self.variables: shade_color = dark_background.blend( WHITE, spread + luminosity_delta, alpha=1.0 ).clamped colors[key] = shade_color.hex else: colors[key] = self.variables[key] else: colors[key] = get(key, color.lighten(luminosity_delta).hex) if foreground.ansi is None: colors["text"] = get("text", "auto 87%") colors["text-muted"] = get("text-muted", "auto 60%") colors["text-disabled"] = get("text-disabled", "auto 38%") else: colors["text"] = "ansi_default" colors["text-muted"] = "ansi_default" colors["text-disabled"] = "ansi_default" # Muted variants of base colors colors["primary-muted"] = get( "primary-muted", primary.blend(background, 0.7).hex ) colors["secondary-muted"] = get( "secondary-muted", secondary.blend(background, 0.7).hex ) colors["accent-muted"] = get("accent-muted", accent.blend(background, 0.7).hex) colors["warning-muted"] = get( "warning-muted", warning.blend(background, 0.7).hex ) colors["error-muted"] = get("error-muted", error.blend(background, 0.7).hex) colors["success-muted"] = get( "success-muted", success.blend(background, 0.7).hex ) # Foreground colors colors["foreground-muted"] = get( "foreground-muted", foreground.with_alpha(0.6).hex ) colors["foreground-disabled"] = get( "foreground-disabled", foreground.with_alpha(0.38).hex ) # The cursor color for widgets such as OptionList, DataTable, etc. colors["block-cursor-foreground"] = get( "block-cursor-foreground", colors["text"] ) colors["block-cursor-background"] = get("block-cursor-background", primary.hex) colors["block-cursor-text-style"] = get("block-cursor-text-style", "bold") colors["block-cursor-blurred-foreground"] = get( "block-cursor-blurred-foreground", foreground.hex ) colors["block-cursor-blurred-background"] = get( "block-cursor-blurred-background", primary.with_alpha(0.3).hex ) colors["block-cursor-blurred-text-style"] = get( "block-cursor-blurred-text-style", "none" ) colors["block-hover-background"] = get( "block-hover-background", boost.with_alpha(0.1).hex ) # The border color for focused widgets which have a border. colors["border"] = get("border", primary.hex) colors["border-blurred"] = get("border-blurred", surface.darken(0.025).hex) # The surface color for builtin focused widgets colors["surface-active"] = get( "surface-active", surface.lighten(self.luminosity_spread / 2.5).hex ) # The scrollbar colors colors["scrollbar"] = get( "scrollbar", (Color.parse(colors["background-darken-1"]) + primary.with_alpha(0.4)).hex, ) colors["scrollbar-hover"] = get( "scrollbar-hover", (Color.parse(colors["background-darken-1"]) + primary.with_alpha(0.5)).hex, ) # colors["scrollbar-active"] = get("scrollbar-active", colors["panel-lighten-2"]) colors["scrollbar-active"] = get("scrollbar-active", primary.hex) colors["scrollbar-background"] = get( "scrollbar-background", colors["background-darken-1"] ) colors["scrollbar-corner-color"] = get( "scrollbar-corner-color", colors["scrollbar-background"] ) colors["scrollbar-background-hover"] = get( "scrollbar-background-hover", colors["scrollbar-background"] ) colors["scrollbar-background-active"] = get( "scrollbar-background-active", colors["scrollbar-background"] ) # Links colors["link-background"] = get("link-background", "initial") colors["link-background-hover"] = get("link-background-hover", primary.hex) colors["link-color"] = get("link-color", colors["text"]) colors["link-style"] = get("link-style", "underline") colors["link-color-hover"] = get("link-color-hover", colors["text"]) colors["link-style-hover"] = get("link-style-hover", "bold not underline") colors["footer-foreground"] = get("footer-foreground", foreground.hex) colors["footer-background"] = get("footer-background", panel.hex) colors["footer-key-foreground"] = get("footer-key-foreground", accent.hex) colors["footer-key-background"] = get("footer-key-background", "transparent") colors["footer-description-foreground"] = get( "footer-description-foreground", foreground.hex ) colors["footer-description-background"] = get( "footer-description-background", "transparent" ) colors["footer-item-background"] = get("footer-item-background", "transparent") colors["input-cursor-background"] = get( "input-cursor-background", foreground.hex ) colors["input-cursor-foreground"] = get( "input-cursor-foreground", background.hex ) colors["input-cursor-text-style"] = get("input-cursor-text-style", "none") colors["input-selection-background"] = get( "input-selection-background", Color.parse(colors["primary-lighten-1"]).with_alpha(0.4).hex, ) # Markdown header styles colors["markdown-h1-color"] = get("markdown-h1-color", primary.hex) colors["markdown-h1-background"] = get("markdown-h1-background", "transparent") colors["markdown-h1-text-style"] = get("markdown-h1-text-style", "bold") colors["markdown-h2-color"] = get("markdown-h2-color", primary.hex) colors["markdown-h2-background"] = get("markdown-h2-background", "transparent") colors["markdown-h2-text-style"] = get("markdown-h2-text-style", "underline") colors["markdown-h3-color"] = get("markdown-h3-color", primary.hex) colors["markdown-h3-background"] = get("markdown-h3-background", "transparent") colors["markdown-h3-text-style"] = get("markdown-h3-text-style", "bold") colors["markdown-h4-color"] = get("markdown-h4-color", foreground.hex) colors["markdown-h4-background"] = get("markdown-h4-background", "transparent") colors["markdown-h4-text-style"] = get( "markdown-h4-text-style", "bold underline" ) colors["markdown-h5-color"] = get("markdown-h5-color", foreground.hex) colors["markdown-h5-background"] = get("markdown-h5-background", "transparent") colors["markdown-h5-text-style"] = get("markdown-h5-text-style", "bold") colors["markdown-h6-color"] = get( "markdown-h6-color", colors["foreground-muted"] ) colors["markdown-h6-background"] = get("markdown-h6-background", "transparent") colors["markdown-h6-text-style"] = get("markdown-h6-text-style", "bold") colors["button-foreground"] = get("button-foreground", foreground.hex) colors["button-color-foreground"] = get( "button-color-foreground", colors["text"] ) colors["button-focus-text-style"] = get("button-focus-text-style", "b reverse") return colors def show_design(light: ColorSystem, dark: ColorSystem) -> Table: """Generate a renderable to show color systems. Args: light: Light ColorSystem. dark: Dark ColorSystem Returns: Table showing all colors. """ @group() def make_shades(system: ColorSystem): colors = system.generate() for name in system.shades: background = Color.parse(colors[name]).with_alpha(1.0) foreground = background + background.get_contrast_text(0.9) text = Text(f"${name}") yield Padding(text, 1, style=f"{foreground.hex6} on {background.hex6}") table = Table(box=None, expand=True) table.add_column("Light", justify="center") table.add_column("Dark", justify="center") table.add_row(make_shades(light), make_shades(dark)) return table