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).
743 lines
31 KiB
Python
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
|