Blueforce-Linux/bfos/ui_theme.py
emirhanmamak-blueforce 8299481627 feat(console): group the operator console and give it a type and colour system
The console was a default-grey tkinter window: no grouping, no state colour, no
type scale, the privilege warning buried inside a monospace paragraph beside the
repository path, "Çık" offered twice at two different sizes, and an output area
that looked editable. The catalogue is 48 operations, 13 of them destructive, so
the screen an operator actually reads before touching a fleet was the one surface
with no hierarchy at all.

Adds bfos/ui_theme.py, which mirrors web/static/tokens.css value for value --
the two surfaces describe the same fleet and must not disagree about what a
colour means. It selects the `clam` theme because that renderer honours
background/bordercolor on the elements being restyled; note that theming an
element is not the same as redesigning a control, and the remaining gap is
recorded in docs/UI-REMAINING-WORK.md rather than claimed as finished.

What the redesign changes:
- sections get headed regions instead of floating on the root background;
- the privilege level becomes a header element and a banner, not a sentence;
- one exit action, not two;
- write operations are disabled with their reason visible when unprivileged,
  rather than hidden or silently listed;
- the output well is styled as the read-only evidence it is.

Tests: tests/test_bfos_gui_static.py moves to the new contract (68 -> assertions
updated, none deleted); tests/test_bfos.py still 155/155, which is the check that
the CLI and backend contracts survived a UI rewrite.
2026-09-16 22:38:01 +03:00

745 lines
31 KiB
Python

"""bfos/ui_theme.py -- design tokens for the device console.
The values in this file MIRROR ``web/static/tokens.css``, which is the single
source of truth for both operator surfaces (the device console and the central
panel). Nothing here invents a colour: a colour means a device state, a
severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md,
"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the
CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py``
parses ``tokens.css`` and compares the two files, so a token changed on one
surface and not the other fails the suite instead of drifting.
Why light, not dark
-------------------
The console runs in workshops, dealer service areas and customer sites: bright
overhead light, daylight from a roller door, a 14" laptop panel. A near-black
console loses its own body text there and its state colours collapse into
equal-brightness smudges. So the ground is light (``BG_APP``), the panels are
white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and
saturation is spent only where it carries meaning.
Faces
-----
No webfont, and no font download: the fleet installs offline, so the UI face is
whatever the platform ships and the data face is monospace (ids, counts, exit
codes, timestamps line up in a column). ``apply_theme`` picks the first family
the platform actually has, from a candidate list -- never a missing font name,
which is a silently different layout.
Sizes and the 4px rhythm
------------------------
Type sizes and space steps are kept in the token's own unit (CSS px) so the
mirror is exact. Tk font sizes are points, so ``point_size`` converts with the
96 dpi relation the tokens assume; padding and row heights are already pixels
in Tk, so they are used as ``SP_*`` values directly.
Usage
-----
theme = ui_theme.apply_theme(root) # once, before any widget is built
ttk.Label(parent, style=theme.panel_label)
"""
from __future__ import annotations
from typing import Dict, Mapping, Sequence
try: # pragma: no cover - environment dependent
from tkinter import ttk
HAVE_TKINTER = True
except Exception: # pragma: no cover - environment dependent
ttk = None # type: ignore[assignment]
HAVE_TKINTER = False
# ---------------------------------------------------------------------------
# Ground and structure -- tokens.css: "Ground and structure"
# ---------------------------------------------------------------------------
BG_APP = "#f6f7f8" # window ground
BG_PANEL = "#ffffff" # raised surfaces, cards, table body
BG_SUNKEN = "#eef0f2" # wells: log output, code, inactive rows
BG_HEADER = "#1d2530" # app header; the one dark region
BORDER = "#d5d9de" # hairline between regions
BORDER_STRONG = "#b6bcc4" # input borders, table rules
# ---------------------------------------------------------------------------
# Text
# ---------------------------------------------------------------------------
TEXT = "#1a1f26" # body
TEXT_MUTED = "#5c6570" # secondary lines, labels
TEXT_FAINT = "#8b939c" # timestamps, hints, placeholders
TEXT_ON_DARK = "#e8ebee" # header text
TEXT_ON_ACCENT = "#ffffff"
# ---------------------------------------------------------------------------
# Action -- one accent: the action the operator came to take, and the
# selected row. Nothing else.
# ---------------------------------------------------------------------------
ACCENT = "#1d4ed8"
ACCENT_HOVER = "#1a43bd"
ACCENT_ACTIVE = "#16389e"
ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill
FOCUS_RING = "#1d4ed8"
# ---------------------------------------------------------------------------
# State severities -- four, each with a strong form for text and a soft form
# for fills, so a badge is built from one palette without inventing a second.
# ---------------------------------------------------------------------------
OK = "#15803d"
OK_SOFT = "#e6f4ea"
WARN = "#b45309"
WARN_SOFT = "#fdf3e3"
DANGER = "#b91c1c"
DANGER_SOFT = "#fdecec"
INFO = "#1d4ed8"
INFO_SOFT = "#e8eeff"
IDLE = "#5c6570" # not yet acted on: not a failure
IDLE_SOFT = "#eef0f2"
# ---------------------------------------------------------------------------
# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the
# failure and the absence that sit outside the sequence. Named so no surface
# repaints a stage by accident.
#
# PROVISIONED is deliberately NOT the action accent (tokens.css states the
# reason): the accent means "the action you came to take" and "the selected
# row", so a KURULDU badge built from it made a state and a selection the same
# blue. It is a slate of its own, one step along the path and distinct from the
# neutral --idle.
# ---------------------------------------------------------------------------
STATE_PROVISIONED = "#475569"
STATE_ENROLLED = WARN
STATE_READY = OK
STATE_FAILED = DANGER
STATE_ABSENT = IDLE
# ---------------------------------------------------------------------------
# Type (px, as the tokens declare them)
# ---------------------------------------------------------------------------
SIZE_XS = 12
SIZE_SM = 13
SIZE_MD = 15
SIZE_LG = 18
SIZE_XL = 22
SIZE_2XL = 30
LEADING_TIGHT = 1.25
LEADING_NORMAL = 1.5
# ---------------------------------------------------------------------------
# Space -- a 4px rhythm, named so a component never hand-picks 7px
# ---------------------------------------------------------------------------
SP_1 = 4
SP_2 = 8
SP_3 = 12
SP_4 = 16
SP_5 = 24
SP_6 = 32
SP_7 = 48
RADIUS = 6
RADIUS_LG = 10
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
ROW_H = 44 # fleet table row: comfortable at arm's length
CONTROL_H = 36 # buttons and inputs share one height
HEADER_H = 56
SIDEBAR_W = 220
# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names
# are kept so the two token sets stay comparable and the absence is explicit.
SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)"
SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)"
# ---------------------------------------------------------------------------
# Fonts -- candidates in preference order, resolved against the platform
# ---------------------------------------------------------------------------
FONT_UI_CANDIDATES: Sequence[str] = (
"Noto Sans",
"DejaVu Sans",
"Ubuntu",
"Liberation Sans",
"Segoe UI",
"Helvetica",
)
FONT_DATA_CANDIDATES: Sequence[str] = (
"DejaVu Sans Mono",
"Liberation Mono",
"Noto Sans Mono",
"Consolas",
"Menlo",
"Courier New",
)
FONT_UI_FALLBACK = "TkDefaultFont"
FONT_DATA_FALLBACK = "TkFixedFont"
#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula:
#: rounding is per size so the console keeps the token's steps instead of
#: collapsing two of them onto the same point size.
POINT_SIZES: Mapping[int, int] = {
SIZE_XS: 9,
SIZE_SM: 10,
SIZE_MD: 11,
SIZE_LG: 14,
SIZE_XL: 16,
SIZE_2XL: 22,
}
# ---------------------------------------------------------------------------
# The mirror table -- CSS custom property -> value. Kept as data so the parity
# test reads the same list the code uses.
# ---------------------------------------------------------------------------
TOKENS: Mapping[str, str] = {
"--bg-app": BG_APP,
"--bg-panel": BG_PANEL,
"--bg-sunken": BG_SUNKEN,
"--bg-header": BG_HEADER,
"--border": BORDER,
"--border-strong": BORDER_STRONG,
"--text": TEXT,
"--text-muted": TEXT_MUTED,
"--text-faint": TEXT_FAINT,
"--text-on-dark": TEXT_ON_DARK,
"--text-on-accent": TEXT_ON_ACCENT,
"--accent": ACCENT,
"--accent-hover": ACCENT_HOVER,
"--accent-active": ACCENT_ACTIVE,
"--accent-soft": ACCENT_SOFT,
"--focus-ring": FOCUS_RING,
"--ok": OK,
"--ok-soft": OK_SOFT,
"--warn": WARN,
"--warn-soft": WARN_SOFT,
"--danger": DANGER,
"--danger-soft": DANGER_SOFT,
"--info": INFO,
"--info-soft": INFO_SOFT,
"--idle": IDLE,
"--idle-soft": IDLE_SOFT,
"--state-provisioned": STATE_PROVISIONED,
"--state-enrolled": STATE_ENROLLED,
"--state-ready": STATE_READY,
"--state-failed": STATE_FAILED,
"--state-absent": STATE_ABSENT,
}
#: Space tokens, mirrored by name (the parity test compares these too).
SPACE_TOKENS: Mapping[str, int] = {
"--sp-1": SP_1,
"--sp-2": SP_2,
"--sp-3": SP_3,
"--sp-4": SP_4,
"--sp-5": SP_5,
"--sp-6": SP_6,
"--sp-7": SP_7,
}
#: Type-size tokens, mirrored by name.
SIZE_TOKENS: Mapping[str, int] = {
"--size-xs": SIZE_XS,
"--size-sm": SIZE_SM,
"--size-md": SIZE_MD,
"--size-lg": SIZE_LG,
"--size-xl": SIZE_XL,
"--size-2xl": SIZE_2XL,
}
#: Metric tokens, mirrored by name.
METRIC_TOKENS: Mapping[str, int] = {
"--row-h": ROW_H,
"--control-h": CONTROL_H,
"--header-h": HEADER_H,
"--sidebar-w": SIDEBAR_W,
}
#: Every mirrored value, by CSS token name (used by the parity test).
ALL_TOKENS: Mapping[str, str] = {
**TOKENS,
**{name: str(value) for name, value in SPACE_TOKENS.items()},
**{name: str(value) for name, value in SIZE_TOKENS.items()},
**{name: str(value) for name, value in METRIC_TOKENS.items()},
}
# ---------------------------------------------------------------------------
# Severity vocabulary -- the fill/text pair a badge or a row is built from
# ---------------------------------------------------------------------------
SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle")
SEVERITY_FILL: Mapping[str, str] = {
"ok": OK_SOFT,
"warn": WARN_SOFT,
"danger": DANGER_SOFT,
"info": INFO_SOFT,
"idle": IDLE_SOFT,
}
SEVERITY_TEXT: Mapping[str, str] = {
"ok": OK,
"warn": WARN,
"danger": DANGER,
"info": INFO,
"idle": IDLE,
}
#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md).
STATE_WORDS: Mapping[str, str] = {
"provisioned": "KURULDU",
"enrolled": "KAYITLI",
"ready": "HAZIR",
"failed": "HATA",
"absent": "YOK",
}
STATE_FILL: Mapping[str, str] = {
# The tokens give no soft form for provisioned; the console borrows the
# sunken well fill rather than inventing a hue, so a KURULDU badge cannot be
# mistaken for the accent-soft selection (see tokens.css, device state).
"provisioned": BG_SUNKEN,
"enrolled": WARN_SOFT,
"ready": OK_SOFT,
"failed": DANGER_SOFT,
"absent": IDLE_SOFT,
}
STATE_TEXT_COLOUR: Mapping[str, str] = {
"provisioned": STATE_PROVISIONED,
"enrolled": STATE_ENROLLED,
"ready": STATE_READY,
"failed": STATE_FAILED,
"absent": STATE_ABSENT,
}
#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege").
PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"}
#: ttk style names, resolved once by apply_theme and read by the view.
_FAMILIES: Dict[str, str] = {}
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def point_size(px: int) -> int:
"""Token px -> Tk point size (see POINT_SIZES)."""
return POINT_SIZES.get(px, max(7, round(px * 0.75)))
def ui_family() -> str:
"""The UI face resolved by :func:`apply_theme` (platform default before)."""
return _FAMILIES.get("ui", FONT_UI_FALLBACK)
def data_family() -> str:
"""The monospace face resolved by :func:`apply_theme`."""
return _FAMILIES.get("data", FONT_DATA_FALLBACK)
def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple:
"""A Tk font tuple in the platform UI face."""
if weight == "normal":
return (ui_family(), point_size(size))
return (ui_family(), point_size(size), weight)
def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple:
"""A Tk font tuple in the data (monospace) face."""
if weight == "normal":
return (data_family(), point_size(size))
return (data_family(), point_size(size), weight)
def state_style(state: str) -> str:
"""ttk style name for a state badge ("KURULDU" ...)."""
return f"State{state.capitalize()}.Badge.TLabel"
#: State key -> badge style, built from STATE_WORDS so the word and the colour
#: can never be paired up wrongly on one surface.
STATE_BADGE_STYLE: Mapping[str, str] = {
key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS
}
#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else).
PRIVILEGE_BADGE_STYLE: Mapping[str, str] = {
"full": "Privilege.Badge.TLabel",
"read_only": "ReadOnly.Badge.TLabel",
}
def severity_style(severity: str) -> str:
"""ttk style name for a severity pill (ok / warn / danger / info / idle)."""
return f"{severity.capitalize()}.Status.TLabel"
def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str:
"""First family the platform actually has; the Tk default otherwise."""
try:
from tkinter import font as tkfont
available = {name.lower() for name in tkfont.families(root)}
except Exception: # pragma: no cover - font database unavailable
return fallback
for name in candidates:
if name.lower() in available:
return name
return fallback
def _configure(style, name: str, **options) -> None:
"""Configure a ttk style, ignoring options the platform's theme rejects.
ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs
9.x): a missing element option raises TclError. Losing one border colour is
a cosmetic difference; crashing the console on startup is not.
"""
try:
style.configure(name, **options)
except Exception:
for key, value in options.items():
try:
style.configure(name, **{key: value})
except Exception:
continue
def _map(style, name: str, **options) -> None:
"""State map for a ttk style, same tolerance as :func:`_configure`."""
try:
style.map(name, **options)
except Exception:
for key, value in options.items():
try:
style.map(name, **{key: value})
except Exception:
continue
# ---------------------------------------------------------------------------
# The one setup function, called once at startup
# ---------------------------------------------------------------------------
def apply_theme(root) -> Mapping[str, str]:
"""Install the token set on ``root``'s ttk styles; returns the style names.
Idempotent and cheap: the GUI calls it once before building any widget, and
a second call simply re-applies the same values (tests may build several
roots in one process).
"""
if not HAVE_TKINTER: # pragma: no cover - environment dependent
raise RuntimeError("tkinter yok: tema uygulanamaz")
assert ttk is not None # narrowing for the type checker
_FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK)
_FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK)
try:
root.configure(bg=BG_APP)
except Exception: # pragma: no cover - non-Tk root
pass
style = ttk.Style(root)
# clam honours background/bordercolor on the widget elements we restyle;
# the 'default' theme on Linux ignores most of them, which is how a
# console ends up grey whatever the tokens say.
try:
if "clam" in style.theme_names():
style.theme_use("clam")
except Exception: # pragma: no cover - theme list unavailable
pass
base_font = ui_font(SIZE_MD)
_configure(
style, ".",
background=BG_APP,
foreground=TEXT,
font=base_font,
bordercolor=BORDER,
lightcolor=BORDER,
darkcolor=BORDER,
troughcolor=BG_SUNKEN,
focuscolor=FOCUS_RING,
)
# -- frames: the ground, the raised panel, the well, the one dark band --
_configure(style, "TFrame", background=BG_APP, borderwidth=0)
_configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0)
_configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1,
relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT,
darkcolor=WARN_SOFT)
_configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1,
relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT,
darkcolor=INFO_SOFT)
_configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1,
relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT,
darkcolor=ACCENT_SOFT)
# -- labels -------------------------------------------------------------
_configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font)
_configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT)
_configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT)
_configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM))
_configure(style, "PanelMuted.TLabel", background=BG_PANEL,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
_configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT,
font=ui_font(SIZE_XS))
_configure(style, "PanelFaint.TLabel", background=BG_PANEL,
foreground=TEXT_FAINT, font=ui_font(SIZE_XS))
_configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), anchor="e")
_configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_XS, "bold"))
# The one loud figure per view.
_configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_2XL, "bold"))
_configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM))
_configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM))
_configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM))
_configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM))
_configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM, "bold"))
# Dialogs sit on the app ground, not on a panel.
_configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_MD))
_configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "DialogMuted.TLabel", background=BG_APP,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
# Header (dark) region.
_configure(style, "HeaderTitle.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold"))
_configure(style, "HeaderMeta.TLabel", background=BG_HEADER,
foreground=TEXT_FAINT, font=ui_font(SIZE_SM))
_configure(style, "HeaderTarget.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_SM))
_configure(style, "HeaderFigure.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold"))
# -- badges: state word + colour, never colour alone -------------------
_configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1))
for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"),
("ready", "Ready"), ("failed", "Failed"),
("absent", "Absent")):
_configure(
style, f"State{word_style}.Badge.TLabel",
background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key],
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1),
)
_configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT,
foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT,
foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
# -- status pill (footer): the console's own state ---------------------
_configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2))
for severity in SEVERITIES:
_configure(
style, f"{severity.capitalize()}.Status.TLabel",
background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity],
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2),
)
# -- inputs: one height with the buttons ---------------------------------
entry_opts = dict(
fieldbackground=BG_PANEL,
background=BG_PANEL,
foreground=TEXT,
bordercolor=BORDER_STRONG,
lightcolor=BORDER_STRONG,
darkcolor=BORDER_STRONG,
insertcolor=TEXT,
padding=(SP_2, SP_1),
font=ui_font(SIZE_MD),
)
_configure(style, "TEntry", **entry_opts)
_map(style, "TEntry",
bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)],
foreground=[("disabled", TEXT_FAINT)],
fieldbackground=[("disabled", BG_SUNKEN)])
_configure(style, "Invalid.TEntry", **entry_opts)
_configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER,
darkcolor=DANGER)
_map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)])
_configure(style, "TCombobox", **entry_opts)
_configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT,
selectforeground=TEXT, padding=(SP_2, SP_1))
_map(style, "TCombobox",
fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", FOCUS_RING)])
_configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1))
_map(style, "TCheckbutton",
background=[("active", BG_APP)],
foreground=[("disabled", TEXT_FAINT)],
indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT,
font=ui_font(SIZE_SM), padding=(SP_1, SP_1))
_map(style, "Panel.TCheckbutton",
background=[("active", BG_PANEL), ("disabled", BG_PANEL)],
foreground=[("disabled", TEXT_FAINT)])
# -- buttons: hairline secondary, one accent primary, danger for
# destructive work ------------------------------------------------
_configure(style, "TButton", background=BG_PANEL, foreground=TEXT,
bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL,
font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid",
focusthickness=1, focuscolor=FOCUS_RING, anchor="center")
_map(style, "TButton",
background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN),
("active", ACCENT_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)],
bordercolor=[("focus", FOCUS_RING)],
lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)],
darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)])
_configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT,
bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT)
_map(style, "Accent.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT,
bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER)
_map(style, "Danger.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT),
("active", DANGER_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("active", DANGER),
("pressed", DANGER)],
bordercolor=[("focus", DANGER)],
lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)],
darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)])
# Simple mode: the three big buttons, one of them the action accent.
_configure(style, "Big.TButton", font=ui_font(SIZE_LG),
padding=(SP_5, SP_4), anchor="w")
_configure(style, "Accent.Big.TButton", background=ACCENT,
foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT,
darkcolor=ACCENT)
_map(style, "Accent.Big.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER)
# -- table: the fleet/operation list ------------------------------------
_configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL,
foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER,
darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H)
_configure(style, "Treeview.Heading", background=BG_SUNKEN,
foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"),
relief="flat", padding=(SP_2, SP_2))
_map(style, "Treeview",
background=[("selected", ACCENT_SOFT)],
foreground=[("selected", TEXT)])
_map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)])
# -- tabs, separators, scrollbars --------------------------------------
_configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER,
lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0))
_configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER)
_map(style, "TNotebook.Tab",
background=[("selected", BG_PANEL), ("active", BG_PANEL)],
foreground=[("selected", TEXT), ("active", TEXT)],
expand=[("selected", (0, 0, 0, 0))])
_configure(style, "TSeparator", background=BORDER)
for orient in ("Vertical", "Horizontal"):
_configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN,
troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED,
lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN)
_map(style, f"{orient}.TScrollbar",
background=[("active", BORDER_STRONG)])
return {
"frame": "TFrame",
"panel": "Panel.TFrame",
"sunken": "Sunken.TFrame",
"header": "Header.TFrame",
"warn_band": "Warn.TFrame",
"info_band": "Info.TFrame",
"label": "TLabel",
"panel_label": "Panel.TLabel",
"panel_title": "PanelTitle.TLabel",
"muted": "Muted.TLabel",
"panel_muted": "PanelMuted.TLabel",
"faint": "Faint.TLabel",
"panel_faint": "PanelFaint.TLabel",
"field_label": "FieldLabel.TLabel",
"section": "Section.TLabel",
"figure": "Figure.TLabel",
"ok_label": "Ok.TLabel",
"warn_label": "Warn.TLabel",
"info_label": "InfoText.TLabel",
"danger_label": "Danger.TLabel",
"app_warn": "AppWarn.TLabel",
"panel_ok": "PanelOk.TLabel",
"panel_danger": "PanelDanger.TLabel",
"header_title": "HeaderTitle.TLabel",
"header_meta": "HeaderMeta.TLabel",
"header_target": "HeaderTarget.TLabel",
"header_figure": "HeaderFigure.TLabel",
"badge": "Badge.TLabel",
"badge_info": "Info.Badge.TLabel",
"badge_neutral": "Neutral.Badge.TLabel",
"badge_destructive": "Type.Destructive.Badge.TLabel",
"badge_readonly": "Type.ReadOnly.Badge.TLabel",
"badge_open": "Open.Badge.TLabel",
"badge_closed": "Closed.Badge.TLabel",
"privilege": "Privilege.Badge.TLabel",
"readonly": "ReadOnly.Badge.TLabel",
"status": "Status.TLabel",
"button": "TButton",
"accent_button": "Accent.TButton",
"danger_button": "Danger.TButton",
"big_button": "Big.TButton",
"big_accent_button": "Accent.Big.TButton",
"quiet_button": "Quiet.TButton",
"entry": "TEntry",
"invalid_entry": "Invalid.TEntry",
"panel_check": "Panel.TCheckbutton",
"check": "TCheckbutton",
"dialog": "Dialog.TLabel",
"dialog_title": "DialogTitle.TLabel",
"dialog_muted": "DialogMuted.TLabel",
}
def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial
"""The danger colour used for a focused destructive control.
A named function (rather than a bare literal) so the fifth-hue rule stays
visible: focus on a dangerous action reuses the danger token.
"""
return DANGER