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