Blueforce-Linux/bfos/ui_theme.py
emirhanmamak-blueforce b6a14ca8e0 refine(operators): wave code back in the fleet row; drop a dead helper
The fleet row named the wave by its short name only ("PILOT-2") while the
detail view and the filter carry the full label ("P2 — PILOT-2"). An operator
matching a row against a work order speaks in codes, so the row carries the
full label now -- still named once, which the row test asserts.

Also takes the console agent's leftover: a dead state_style helper removed and
the unit convention on ALL_TOKENS documented (lengths are unitless; Tk padding
is already pixels, the parity test strips the CSS unit).
2026-09-16 22:47:12 +03:00

743 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).
#: Lengths are stored WITHOUT their unit: the unit is the token's (px at
#: 96 dpi), and the console passes the number straight to Tk padding, which is
#: already in pixels. The parity test strips the CSS unit to compare.
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)
#: 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