Source code for cadrumo.entrypoints.tui.components.theme

"""The Cadrumo terminal design system: one palette, two appearances.

This module owns the presentation tokens and stylesheet shared by every
full-screen surface.  It does not read application settings or retain screen
state; callers resolve an appearance and pass it to the installation helper.
Reusable Textual widgets that consume these tokens live in
:mod:`cadrumo.entrypoints.tui.components.widgets`.
"""

from __future__ import annotations

import re
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Protocol

from textual.theme import Theme

from ....core.errors.hierarchy import CadrumoError

if TYPE_CHECKING:
    from textual.app import App


CADRUMO_LIGHT_THEME_NAME: Final[str] = "cadrumo-light"
CADRUMO_DARK_THEME_NAME: Final[str] = "cadrumo-dark"


[docs] class AppearanceHost(Protocol): """Minimal mutable theme surface needed by the appearance toggle.""" theme: str
CADRUMO_LIGHT: Final[Theme] = Theme( name=CADRUMO_LIGHT_THEME_NAME, # Warm paper and near-black ink, verbatim from the frontend :root. background="#faf8f4", surface="#f1eee7", panel="#e9e3da", foreground="#1c1a17", primary="#c4553b", secondary="#6b655c", accent="#a8452f", # Sage and amber darkened from the brand's dark-terminal values. success="#3f6f5b", warning="#845d1d", error="#a33322", dark=False, variables={ "block-cursor-text-style": "none", "footer-key-foreground": "#c4553b", "input-selection-background": "#c4553b 25%", # Declared, not inherited. Anything left undeclared is derived by # Textual from its own defaults, which is how a near-black element # appeared on the light page. "input-cursor-background": "#c4553b", "input-cursor-foreground": "#faf8f4", "scrollbar": "#d8cec0", "scrollbar-hover": "#c9bcaa", "scrollbar-active": "#c4553b", "scrollbar-background": "#f1eee7", "scrollbar-corner-color": "#f1eee7", "border": "#c4553b", "border-blurred": "#d8cec0", }, ) CADRUMO_DARK: Final[Theme] = Theme( name=CADRUMO_DARK_THEME_NAME, # A warm near-black rather than pure black, so the rust and sage keep hue. background="#1a1815", surface="#232019", panel="#2e2a22", foreground="#ece7dd", primary="#d9694e", secondary="#a89e90", accent="#e07d5f", success="#7fb096", warning="#d9a441", error="#f26c52", dark=True, variables={ "block-cursor-text-style": "none", "footer-key-foreground": "#e07d5f", "input-selection-background": "#d9694e 30%", # Same declaration set as the light appearance; the two differ in # colour only, never in which variables exist. "input-cursor-background": "#d9694e", "input-cursor-foreground": "#1a1815", "scrollbar": "#3d382e", "scrollbar-hover": "#4d4739", "scrollbar-active": "#d9694e", "scrollbar-background": "#232019", "scrollbar-corner-color": "#232019", "border": "#d9694e", "border-blurred": "#3d382e", }, ) CADRUMO_THEMES: Final[tuple[Theme, ...]] = (CADRUMO_LIGHT, CADRUMO_DARK) CADRUMO_CSS_TOKENS: Final[Mapping[str, str]] = MappingProxyType( { # -- Shape --------------------------------------------------------- # ONE corner treatment for every bordered thing on screen. A panel # drawn `round`, a field drawn `tall` and a button drawn with no # border at all is three shapes claiming to be one system, and the # eye reads the inconsistency long before it can name it. "cadrumo-radius": "round", # A terminal has no shadow, so border WEIGHT is the only elevation # channel there is. Overlays therefore keep a deliberately heavier # edge: it is the one signal that a dialog floats above the page # rather than sitting in it. Five different border styles were in use # before this -- round, thick, tall, solid and none -- and only this # one distinction among them was carrying meaning. "cadrumo-radius-overlay": "thick", # A single edge is a RULE, not a box. `round` draws corners, so # spending it on one edge asks for a corner that has nowhere to # go. Separators and the bar that marks an aside share this. "cadrumo-rule": "solid", # -- Fixed measures -------------------------------------------------- "cadrumo-band-height": "1", "cadrumo-modal-width": "80%", "cadrumo-modal-height": "80%", "cadrumo-log-max-height": "12", # -- Spacing scale, in terminal cells ------------------------------- # Cells, not rem: the unit here is a character, so the useful scale is # tiny and every step has to earn itself. A web 4-point scale has no # meaning on a grid where one step is already the smallest visible # distance. "cadrumo-space-0": "0", "cadrumo-space-1": "1", "cadrumo-space-2": "2", "cadrumo-space-3": "3", # -- Semantic roles ------------------------------------------------- # Call sites name the ROLE, never the number, so re-tuning density is # one edit here rather than a sweep of eighty literals. # Spacing has to express GROUPING, not a single uniform gap. One # value repeated everywhere gives every relationship equal weight, # which is the same as having no grouping at all. So the scale is a # rhythm, tight inside a group and generous between them: # # tight 0 parts of one thought (a label and its own hint) # stack 1 sibling blocks inside one group # section 2 between logical groups, and between panels # # gutter/gutter-y: breathing between a panel border and its content. # gutter-y is the axis that did not exist before: panel content sat # directly on the border row, top and bottom. # indent: how far a dependent line sits under its parent. "cadrumo-tight": "0", "cadrumo-stack": "1", "cadrumo-section": "2", "cadrumo-gutter": "2", "cadrumo-gutter-y": "1", "cadrumo-indent": "2", # -- Controls ------------------------------------------------------- # A button is a filled slab, not a line box: `block` edges are drawn # as half-blocks in the border colour and merge with the fill, which # is how Textual builds one. Height is deliberately absent -- the # widget sizes itself, and pinning a number is what pushed content # out of its own box. "cadrumo-control-edge": "block", # Horizontal breathing inside a control. Spent on `line-pad`, the # property Textual gives buttons for exactly this; `padding` insets # the content box instead and leaves a ring inside the border. # # Held at 1, which is Textual's own default, because `line-pad` is NOT # counted in a button's auto-width: at 2 the widest label loses four # cells, wraps to a second line, and that button alone grows a row -- # leaving a ragged action row. Short labels still get their breathing # from `control-min-width`. "cadrumo-control-pad-x": "1", # Textual's own default is 16. The previous 14 made buttons SMALLER # than the library intends, while the complaint was that they were # small. "cadrumo-control-min-width": "16", # Wide enough that no declared action label wraps. Derived, not # chosen: the longest is 43 cells ("Obtener todas las declaraciones # presentadas" in es, and the Catalan of the same length), and a button # spends two on its border and two on `line-pad`, so 47 is the floor # and this is the next even value. # # The old 28 wrapped almost every action label in every locale -- # measured, only one of the four AEAT actions fits it in any language. # A wrapped label costs a row and makes that button alone taller, # producing the ragged action row the `control-pad-x` note above # already warns against; two buttons in one column then measured 3 and # 5 rows tall. It also had a consequence beyond looks: the taller # neighbour reached far enough to consume a simulated press aimed at # the button above it. "cadrumo-control-max-width": "48", # The gap between sibling action buttons. Nine stylesheets each # repeated this as a literal; two bordered boxes one cell apart # read as one smeared control, so the role gets a name and a # value that actually separates them. "cadrumo-control-gap": "2", # Table density. One number for every table in the product, because a # row's leading is what the eye uses to find the column edge: Home's # lists sat flush at column 0 while every other table was inset by one # cell, so two surfaces of the same product disagreed about where a row # begins. Tables also inset their FIRST column by this much, so the # heading above them takes the same indent and the group shares one # left edge. "cadrumo-cell-padding": "1", # -- Chrome --------------------------------------------------------- "cadrumo-scrollbar": "1", }, ) """The canonical presentation tokens every Cadrumo surface is built from. Delivered through :func:`tokenised` before each Cadrumo stylesheet is handed to Textual. Pre-expansion reaches app-level ``CSS`` and each widget's ``DEFAULT_CSS`` alike. Theme ``variables`` do not: they are resolved after widget default styles are parsed, so a token spent there raises ``UnresolvedVariableError`` in exactly the places a design system most needs to reach. Spatial tokens are deliberately outside the two ``Theme`` objects. Light and dark differ in colour, never in measure; duplicating the scale into both themes would create two places for one fact to drift apart. """
[docs] class UnknownDesignTokenError(CadrumoError): """A stylesheet referenced a ``$cadrumo-`` token that is not declared."""
_TOKEN_REFERENCE: Final = re.compile(r"\$(cadrumo-[a-z0-9-]+)")
[docs] def tokenised(css: str) -> str: """Resolve every ``$cadrumo-`` token in ``css`` to its declared value. Substitution happens HERE, at import time, rather than through Textual's ``get_css_variables`` hook. The hook works, but it makes a widget's own ``DEFAULT_CSS`` depend on the application that happens to host it: mount a Cadrumo component inside any app that does not implement the hook -- a test host, an embedder, a future surface someone forgets to wire -- and the widget fails to mount on an undefined variable. A component's styling should not be a contract with its host. Resolving into the string keeps one canonical table and one place to edit, and leaves the shipped stylesheets self-contained. Textual's own ``$`` variables (``$primary``, ``$text-muted``, the theme colours) are untouched and still resolve at mount, which is right: those DO change at runtime when the operator flips appearance, and these measures never do. Raises: UnknownDesignTokenError: When the stylesheet names a token that is not declared. A silent pass-through would reach Textual as an undefined variable and fail far from the typo. """ def _resolve(match: re.Match[str]) -> str: name = match.group(1) value = CADRUMO_CSS_TOKENS.get(name) if value is None: declared = ", ".join(sorted(CADRUMO_CSS_TOKENS)) message = f"unknown design token ${name}; declared: {declared}" raise UnknownDesignTokenError(message) return value return _TOKEN_REFERENCE.sub(_resolve, css)
BASE_CSS: Final[str] = tokenised(""" Screen { background: $background; color: $foreground; align: left top; } .cadrumo-column { width: 100%; height: auto; } .cadrumo-scroll { width: 100%; height: 1fr; align-horizontal: left; scrollbar-size-vertical: $cadrumo-scrollbar; scrollbar-gutter: auto; padding: $cadrumo-space-0; } .cadrumo-scroll DataTable { height: auto; overflow-y: hidden; scrollbar-size-vertical: $cadrumo-space-0; } /* A section heading and the content it owns. The two gaps are deliberately asymmetric: the SECTION gap above separates this group from the previous one, the smaller STACK gap below binds the heading to its own rows. A heading equidistant from both reads as floating between them; a heading with no gap below reads as fused to its content, which is how a screen becomes one continuous run of data. */ .cadrumo-heading { height: auto; text-style: bold; /* The same inset a table gives its first column, so a heading and the rows it owns share one left edge. Without it the heading starts a cell to the left of its own data and the group reads as ragged. */ padding-left: $cadrumo-cell-padding; margin-top: $cadrumo-section; margin-bottom: $cadrumo-stack; } /* The heading that OPENS a scroll region takes the SMALLER gap. It has no previous group to separate from, so the section gap buys nothing there -- and on Home it actively costs: the leading heading is only painted at this value. At `0` it lands on the row the session line occupies and is overdrawn; at the section gap it disappears from the frame entirely. Both are measurable at 100x40 and neither is a rhythm problem: the enclosing `height: auto` container mis-places its first child. Until that is fixed, this value is a workaround, not a design choice. */ .cadrumo-heading.cadrumo-heading-lead { margin-top: $cadrumo-stack; /* Restated, not inherited: a rule that sets one margin edge here replaces the whole box, so omitting this silently zeroes the gap the heading needs BELOW it and refuses the rhythm entirely. */ margin-bottom: $cadrumo-stack; } .cadrumo-banner { dock: top; height: $cadrumo-band-height; width: 100%; background: $primary; color: $text; text-style: bold; padding: $cadrumo-space-0 $cadrumo-gutter; } .cadrumo-panel { border: $cadrumo-radius $primary; border-title-color: $accent; border-title-style: bold; background: $surface; /* Both axes. Content used to sit directly on the border row, so a panel read as a box crushed around its text. */ padding: $cadrumo-gutter-y $cadrumo-gutter; /* Panels are separate logical groups, so they get the SECTION gap, not the sibling one: two panels a single row apart still read as one smeared container. */ margin: $cadrumo-space-0 $cadrumo-space-0 $cadrumo-section $cadrumo-space-0; width: 100%; height: auto; } .cadrumo-subtle { color: $text-muted; } .cadrumo-note { color: $text-muted; text-style: italic; } /* Textual's own button model, rather than a box drawn over the top of it. The widget sizes itself (`height: auto`), pads its label with `line-pad`, and edges itself with a `block` border -- half-block glyphs in the border colour, which merge with the fill into one solid slab. Setting `padding` and a contrasting line border instead is what produced an outline sitting inside a filled button: an inner border, which is not a thing a button has. */ Button { height: auto; min-width: $cadrumo-control-min-width; line-pad: $cadrumo-control-pad-x; border: $cadrumo-control-edge $panel; background: $panel; color: $foreground; } Button:hover { border: $cadrumo-control-edge $panel-lighten-1; background: $panel-lighten-1; } /* The edge always matches its own fill, so the slab stays seamless and state is carried by the fill colour, never by a second outline. */ Button:focus { border: $cadrumo-control-edge $primary; background: $primary; color: $text; text-style: bold; } Button.-primary { border: $cadrumo-control-edge $primary; background: $primary; color: $text; } Button.-primary:focus { border: $cadrumo-control-edge $accent; background: $accent; text-style: bold; } Input { width: 100%; height: auto; /* A field is a line box, unlike a button: `round` is right here, and matches the panels it sits inside. */ border: $cadrumo-radius $accent; padding: $cadrumo-space-0 $cadrumo-space-1; background: $background; } Input:focus { border: $cadrumo-radius $primary; } """) """Chrome and layout shared by every Cadrumo full-screen surface. Every measure and every corner resolves from :data:`CADRUMO_CSS_TOKENS`, so re-tuning the system's density or shape is an edit to the token table rather than a sweep through this stylesheet and the eighteen others beside it. """ NOTICE_BAND_CSS: Final[str] = tokenised(""" NoticeBand { height: auto; } .cadrumo-notice { margin: $cadrumo-space-0 $cadrumo-space-0 $cadrumo-stack $cadrumo-space-0; } .cadrumo-notice-info { color: $text; } .cadrumo-notice-warning { color: $warning; text-style: bold; } .cadrumo-notice-action { color: $text-muted; margin: $cadrumo-space-0 $cadrumo-space-0 $cadrumo-stack $cadrumo-indent; } """)
[docs] def resolve_theme_name(appearance: str, *, host_prefers_dark: bool = True) -> str: """Return the registered theme name for an operator appearance choice.""" if appearance == "light": return CADRUMO_LIGHT_THEME_NAME if appearance == "dark": return CADRUMO_DARK_THEME_NAME return CADRUMO_DARK_THEME_NAME if host_prefers_dark else CADRUMO_LIGHT_THEME_NAME
[docs] def install_cadrumo_themes[ReturnT]( app: App[ReturnT], *, appearance: str | None = None, ) -> None: """Register both themes on ``app`` and activate the resolved one. The component has no settings or application-state authority. A caller that omits an appearance receives the neutral ``AUTO`` resolution. """ selected = "auto" if appearance is None else appearance for theme in CADRUMO_THEMES: app.register_theme(theme) app.theme = resolve_theme_name(selected)
[docs] def toggle_appearance(app: AppearanceHost) -> str: """Flip the active surface between the light and dark appearance.""" app.theme = CADRUMO_LIGHT_THEME_NAME if app.theme == CADRUMO_DARK_THEME_NAME else CADRUMO_DARK_THEME_NAME return str(app.theme)
__all__ = [ "BASE_CSS", "CADRUMO_CSS_TOKENS", "CADRUMO_DARK", "CADRUMO_DARK_THEME_NAME", "CADRUMO_LIGHT", "CADRUMO_LIGHT_THEME_NAME", "CADRUMO_THEMES", "NOTICE_BAND_CSS", "AppearanceHost", "install_cadrumo_themes", "resolve_theme_name", "toggle_appearance", "tokenised", ]