Module textual.design

Expand source code
from __future__ import annotations

from typing import Iterable

from rich.console import group
from rich.padding import Padding
from rich.table import Table
from rich.text import Text

from .color import Color, WHITE


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"


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,
        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,
    ):
        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.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

    @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 generate(self) -> dict[str, str]:
        """Generate a mapping of color name on to a CSS color.

        Args:
            dark (bool, optional): Enable dark mode. Defaults to False.
            luminosity_spread (float, optional): Amount of luminosity to subtract and add to generate
                shades. Defaults to 0.2.
            text_alpha (float, optional): Alpha value for text. Defaults to 0.9.

        Returns:
            dict[str, str]: 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

        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 = background.inverse

        boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04)

        if self.panel is None:
            panel = surface.blend(primary, 0.1, alpha=1)
            if dark:
                panel += boost
        else:
            panel = self.panel

        colors: dict[str, str] = {}

        def luminosity_range(spread) -> 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"}

        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):
                if is_dark_shade:
                    dark_background = background.blend(color, 0.15, alpha=1.0)
                    shade_color = dark_background.blend(
                        WHITE, spread + luminosity_delta, alpha=1.0
                    ).clamped
                    colors[f"{name}{shade_name}"] = shade_color.hex
                else:
                    shade_color = color.lighten(luminosity_delta)
                    colors[f"{name}{shade_name}"] = shade_color.hex

        colors["text"] = "auto 87%"
        colors["text-muted"] = "auto 60%"
        colors["text-disabled"] = "auto 38%"

        return colors


def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
    """Generate a renderable to show color systems.

    Args:
        light (ColorSystem): Light ColorSystem.
        dark (ColorSystem): Dark ColorSystem

    Returns:
        Table: 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(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


if __name__ == "__main__":
    from .app import DEFAULT_COLORS

    from rich import print

    print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"]))

Functions

def show_design(light: ColorSystem, dark: ColorSystem) ‑> rich.table.Table

Generate a renderable to show color systems.

Args

light : ColorSystem
Light ColorSystem.
dark : ColorSystem
Dark ColorSystem

Returns

Table
Table showing all colors.
Expand source code
def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
    """Generate a renderable to show color systems.

    Args:
        light (ColorSystem): Light ColorSystem.
        dark (ColorSystem): Dark ColorSystem

    Returns:
        Table: 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(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

Classes

class ColorSystem (primary: str, secondary: str | None = None, warning: str | None = None, error: str | None = None, success: str | None = None, accent: 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)

Defines a standard set of colors and variations for building a UI.

Primary is the main theme color Secondary is a second theme color

Expand source code
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,
        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,
    ):
        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.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

    @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 generate(self) -> dict[str, str]:
        """Generate a mapping of color name on to a CSS color.

        Args:
            dark (bool, optional): Enable dark mode. Defaults to False.
            luminosity_spread (float, optional): Amount of luminosity to subtract and add to generate
                shades. Defaults to 0.2.
            text_alpha (float, optional): Alpha value for text. Defaults to 0.9.

        Returns:
            dict[str, str]: 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

        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 = background.inverse

        boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04)

        if self.panel is None:
            panel = surface.blend(primary, 0.1, alpha=1)
            if dark:
                panel += boost
        else:
            panel = self.panel

        colors: dict[str, str] = {}

        def luminosity_range(spread) -> 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"}

        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):
                if is_dark_shade:
                    dark_background = background.blend(color, 0.15, alpha=1.0)
                    shade_color = dark_background.blend(
                        WHITE, spread + luminosity_delta, alpha=1.0
                    ).clamped
                    colors[f"{name}{shade_name}"] = shade_color.hex
                else:
                    shade_color = color.lighten(luminosity_delta)
                    colors[f"{name}{shade_name}"] = shade_color.hex

        colors["text"] = "auto 87%"
        colors["text-muted"] = "auto 60%"
        colors["text-disabled"] = "auto 38%"

        return colors

Class variables

var COLOR_NAMES

Instance variables

var shades : Iterable[str]

The names of the colors and derived shades.

Expand source code
@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

Methods

def generate(self) ‑> dict[str, str]

Generate a mapping of color name on to a CSS color.

Args

dark : bool, optional
Enable dark mode. Defaults to False.
luminosity_spread : float, optional
Amount of luminosity to subtract and add to generate shades. Defaults to 0.2.
text_alpha : float, optional
Alpha value for text. Defaults to 0.9.

Returns

dict[str, str]
A mapping of color name on to a CSS-style encoded color
Expand source code
def generate(self) -> dict[str, str]:
    """Generate a mapping of color name on to a CSS color.

    Args:
        dark (bool, optional): Enable dark mode. Defaults to False.
        luminosity_spread (float, optional): Amount of luminosity to subtract and add to generate
            shades. Defaults to 0.2.
        text_alpha (float, optional): Alpha value for text. Defaults to 0.9.

    Returns:
        dict[str, str]: 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

    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 = background.inverse

    boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04)

    if self.panel is None:
        panel = surface.blend(primary, 0.1, alpha=1)
        if dark:
            panel += boost
    else:
        panel = self.panel

    colors: dict[str, str] = {}

    def luminosity_range(spread) -> 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"}

    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):
            if is_dark_shade:
                dark_background = background.blend(color, 0.15, alpha=1.0)
                shade_color = dark_background.blend(
                    WHITE, spread + luminosity_delta, alpha=1.0
                ).clamped
                colors[f"{name}{shade_name}"] = shade_color.hex
            else:
                shade_color = color.lighten(luminosity_delta)
                colors[f"{name}{shade_name}"] = shade_color.hex

    colors["text"] = "auto 87%"
    colors["text-muted"] = "auto 60%"
    colors["text-disabled"] = "auto 38%"

    return colors