ai-station/.venv/lib/python3.12/site-packages/textual/design.py

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