Files
MeshTerm/meshterm/ui/theme.py
T
Jean-Pierre MartineauandClaude Fable 5 79d00defdd Node colour: the hue now rides the key, and heat moves to the age
The per-node palette hue was keyed on the display name's characters; it
is now derived from the node's key — the first byte picks the colour, so
a 2-hex path hop, the stored 12-hex id, and the full 64-hex public key
all agree, and a rename keeps its colour. name_style grows a key
parameter (theme.node_style is the raw derivation); the name-character
fallback remains only for genuinely keyless senders (a channel message's
inline name). Keys are threaded through the path widget, the route
graph's labels, the atlas lists, the trophy case's far point and area
pins, and the packet viewer's node lane.

In the Nodes screen and the Time Machine (the shared node list, the
static CLI table, and the mesh page's arrivals), names drop their
recency-heat colouring for the node's palette hue; the heat now glows on
the HEARD/first-heard age lane instead, where the recency actually
lives. A nameless node's "unknown" placeholder stays muted — colour
marks a name, and the hash lane already carries the identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:37:10 -04:00

209 lines
8.8 KiB
Python

"""Shared Rich theme and console factory for a consistent, modern look."""
from __future__ import annotations
from typing import Optional
from rich.console import Console
from rich.theme import Theme
MESH_THEME = Theme(
{
"brand": "bold #5eead4",
"accent": "bold #818cf8",
# The highlighted-button fill (cyan block, dark text) shared by every popup dialog.
# It must be a *single* theme name: Rich silently drops a style string that mixes a
# theme name with an attribute (e.g. "reverse brand" renders as plain text), so the
# reverse is baked into the definition here rather than tacked on at the call site.
"selected": "reverse bold #5eead4",
# Reversed error (the text-editor cursor sitting on an over-budget character). Baked
# in for the same reason as ``selected`` — "reverse err" would render as plain text.
"err.reverse": "reverse bold #f87171",
# Our own node, anywhere it is named: pure white, deliberately outside the
# per-name hue palette (see NAME_COLORS below) so "you" is always easy to spot.
"you": "bold #ffffff",
# A confirmed companion's name on the startup device picker: pure white so the
# devices we've actually talked to before jump out above the merely-detected ports.
"device.known": "bold #ffffff",
# The picker's Bluetooth TYPE badge: a white rune on the official Bluetooth blue
# (Pantone 300, #0057b8), echoing the real logo so BLE reads at a glance.
"bluetooth": "bold #ffffff on #0057b8",
# The badge's tapered edges: half-block glyphs drawn in the same blue as the
# foreground (over the terminal's own background), so only their inner half fills and
# the badge reads a touch wider than the single rune cell without a hard rectangle.
"bluetooth.edge": "#0057b8",
"ok": "bold #4ade80",
"warn": "bold #fbbf24",
"err": "bold #f87171",
"muted": "#94a3b8",
# Panel/dialog titles: the same hue as the border they sit in, one shade brighter,
# so the title reads as part of its frame while still standing out from it. One
# entry per border style the frame compositor is given (see theme.title_style).
"title.accent": "bold #a5b4fc",
"title.muted": "bold #cbd5e1",
"title.warn": "bold #fcd34d",
"title.err": "bold #fca5a5",
"title.ok": "bold #86efac",
"title.brand": "bold #99f6e4",
# Panel/dialog footer hints: the border's hue one shade *darker* (and not bold), the
# mirror image of the ``title.*`` brightening — the hint reads as part of the frame
# while receding behind it. One entry per border style (see theme.hint_style).
"hint.accent": "#6366f1",
"hint.muted": "#64748b",
"hint.warn": "#f59e0b",
"hint.err": "#ef4444",
"hint.ok": "#22c55e",
"hint.brand": "#2dd4bf",
# A step darker than ``muted`` for placeholder dashes (a node's missing packet count /
# age) that should recede below the real, muted values around them.
"faint": "#64748b",
# A further step darker than ``faint``, for a meter's unlit track (the SNR quality
# bars): dark enough to read as background, not as a dimmer version of the reading.
"track": "#334155",
"snr.good": "bold #4ade80",
"snr.ok": "bold #fbbf24",
"snr.bad": "bold #f87171",
# The status-bar battery gauge, as a fuel-gauge palette: green healthy, orange at
# a quarter, red near empty. ``batt.dim`` is the off-beat of the critically-low
# blink — a dark slate the red glyph flickers to, so it pulses without vanishing.
"batt.high": "bold #4ade80",
"batt.mid": "bold #fb923c",
"batt.low": "bold #f87171",
"batt.dim": "#475569",
}
)
def make_console() -> Console:
"""Create the application's themed Rich console.
On legacy Windows consoles the standard streams default to ``cp1252``, which cannot
encode the box-drawing and marker glyphs (``◆ ● ★``) the UI uses; this reconfigures
them to UTF-8 where the runtime supports it so output never raises ``UnicodeEncodeError``.
Returns:
A :class:`rich.console.Console` configured with the MeshTerm theme.
"""
import sys
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
pass
return Console(theme=MESH_THEME)
def title_style(border_style: str) -> str:
"""Return the title style matching a panel's border: the same hue, brighter.
Args:
border_style: The theme name the panel's border is drawn in (``"accent"``,
``"warn"``, ...).
Returns:
The matching ``title.*`` theme name, or ``border_style`` itself when no brighter
variant is defined (so an unknown border still gets a consistently-tinted title).
"""
name = f"title.{border_style}"
return name if name in MESH_THEME.styles else border_style
def hint_style(border_style: str) -> str:
"""Return the footer-hint style matching a panel's border: the same hue, muted.
The bottom-border counterpart of :func:`title_style` — where the title brightens the
border's hue, the hint darkens it, so both read as part of the frame with the right
emphasis.
Args:
border_style: The theme name the panel's border is drawn in (``"accent"``,
``"warn"``, ...).
Returns:
The matching ``hint.*`` theme name, or ``"muted"`` when no variant is defined (so
an unknown border keeps the old neutral hint rather than a loud one).
"""
name = f"hint.{border_style}"
return name if name in MESH_THEME.styles else "muted"
#: Palette of distinct, dark-theme-friendly colors that give each node its own stable hue
#: (red is reserved for errors, so it's excluded). The app-wide rule: a node's *name* is
#: always coloured — by this palette, keyed on the node's key (see :func:`node_style`) so
#: the colour is the node's identity, surviving renames and colouring every surface that
#: knows any prefix of the key identically — and our own node is always the pure-white
#: ``you`` style instead, so "us" never blends into the crowd. Shared by the node lists,
#: the chat transcript, the dashboard feed, and the packet viewer, so one node reads as
#: one colour everywhere.
NAME_COLORS = (
"bold #f472b6", # pink
"bold #60a5fa", # blue
"bold #34d399", # green
"bold #a78bfa", # violet
"bold #fb923c", # orange
"bold #22d3ee", # cyan
"bold #a3e635", # lime
"bold #e879f9", # fuchsia
)
def node_style(key: str) -> str:
"""The stable palette hue a node's *key* selects — the hash-derived node colour.
Only the first byte picks the colour, so any prefix of the key a surface happens to
hold — a 2-hex path hop, the stored 12-hex id, the full 64-hex public key — lands on
the same hue: one node, one colour, however it was learned.
Args:
key: The node's key/hash as hex (any length ≥ 1 byte, ``0x``/mixed-case tolerated).
Returns:
A style string from :data:`NAME_COLORS`.
"""
raw = key.lower().removeprefix("0x")
try:
return NAME_COLORS[int(raw[:2], 16) % len(NAME_COLORS)]
except ValueError: # not hex — fall back to the character sum so *something* stable shows
return NAME_COLORS[sum(map(ord, raw)) % len(NAME_COLORS)]
def name_style(name: str, key: Optional[str] = None) -> str:
"""The stable colour a node or sender name is drawn in — keyed on the node's key.
Args:
name: The display name.
key: Any known prefix of the node's key/hash; when given, the hue is
:func:`node_style`'s — hash-derived, so a rename keeps the colour and every
surface that knows the key agrees. ``None``/empty marks a genuinely keyless
sender (a channel message's inline name), coloured by the name's characters
as the stable fallback.
Returns:
A style string from :data:`NAME_COLORS`; the same node always maps to the
same hue, so it keeps its colour across screens and sessions.
"""
if key:
return node_style(key)
return NAME_COLORS[sum(map(ord, name)) % len(NAME_COLORS)]
def snr_style(snr: float | None) -> str:
"""Return a theme style name describing an SNR value's quality.
Args:
snr: An SNR reading in dB, or ``None``.
Returns:
``"snr.good"``, ``"snr.ok"``, ``"snr.bad"``, or ``"muted"`` for ``None``.
"""
if snr is None:
return "muted"
if snr >= 5:
return "snr.good"
if snr >= -5:
return "snr.ok"
return "snr.bad"