390 lines
15 KiB
Python
390 lines
15 KiB
Python
|
|
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 (<SHADE SUFFIX, LUMINOSITY DELTA>)
|
||
|
|
"""
|
||
|
|
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
|