"""Shared Rich theme and console factory for a consistent, modern look. Two palettes, one vocabulary: every style *name* here exists in both :data:`MESH_THEME` (the regular platform's truecolor look) and :data:`MESH_THEME_16` (the PicoCalc console's 16-slot look), so screens never know which one is active — they ask for ``"warn"`` or ``"snr.good"`` and the platform decides what that means. The active theme, the name-colouring rule, the icon funnel and the render-boundary fold are all **bound at platform-switch time** through :func:`meshterm.platforms.on_platform`: the hot render paths read module globals and never re-derive platform state per call. """ from __future__ import annotations import colorsys import re as _re from collections.abc import Callable from functools import lru_cache from rich.cells import cell_len from rich.console import Console from rich.theme import Theme from ..platforms import Platform, on_platform from .fontset import FONT_CODEPOINTS MESH_THEME = Theme( { "brand": "bold #5eead4", "accent": "bold #818cf8", # THE reverse-video chip — a dialog's committing button, a running action's Abort, # the composer's insertion slot where there is no chip fill to carry it. A *grey* # block with the page's own ink showing through, because the cyan it used to be # was the wordmark's teal doing a third job (identity, and a node hue, and now a # selection), and a chip is chrome: it marks where a press lands, it does not # claim a colour. The slate is ``muted``'s, light enough that the reversed ink — # whatever the terminal's background happens to be — reads on it. It must be a # *single* theme name: Rich silently drops a style string that mixes a theme name # with an attribute (e.g. "reverse muted" renders as plain text), so the reverse is # baked into the definition here rather than tacked on at the call site. "selected": "reverse bold #94a3b8", # THE ink of "this is the one you picked" — the row the ❯ points at, in every # select list and in every screen that draws its own rows (feed, routes, records, # walk, composer), and the lit column heading one axis over where a list is # sorted. White, and deliberately *not* ``brand``: the highlight used to borrow # the wordmark's teal, which put the app's identity ink and "the row you're on" # on the same hue, and worse, teal/cyan is itself a node hue — a cyan-keyed # node's name vanished into its own highlight. White is outside the per-node # spectrum (see node_style) so it can never collide with an identity. It does # share ink with ``you``, which is the accepted cost. Spans that set their own # colour keep it over the base — the heat of an age, an SNR reading, a red badge # — with the one exception the highlight is *for*: a node's own key-derived hue # folds to this white on the cursor row (see theme.is_identity_style and # ui.tui.render._whiten_identities), so the row the reader is on reads as one # thing rather than as a name competing with its own selection. Bold like # ``brand`` was, so the dim-slot ``not bold`` pins that protect a span inside a # highlighted row (see MESH_THEME_16) go on meaning what they meant. "cursor": "bold #ffffff", # THE mark a path line puts around its own hops (PathLine._render), so the cursor # row's identity fold can see where a route starts and stops # (ui.tui.render._whiten_identities): inside a path line a node's hue is not # decoration on a name, it is what tells one hop from the next — and what the # graph drawn above the row cross-references, since a graph label is only a # marker and one byte of hash. Folding those to white made a picked route read as # one long white smear with no way back to the picture. Chips were never affected # (their fills sit outside the fold's vocabulary); this is what makes the arrow # form — every path line on the PicoCalc, and on any terminal without the # powerline glyphs — say the same thing. Renders as nothing: it exists to be read # at the render boundary, not seen. "pathline": "none", # 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-node hue spectrum (see node_style) 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", # A node we can't identify — its ``○`` ring, and its label wherever a bare key or # hash stands in for a name (see name_style's keyless return). Its own name rather # than plain ``muted`` because the two must not track each other: ``muted`` is # chrome and may sit a step down from body text, while an unidentified node is # *content* you can still act on. On the console that difference is the whole # ladder — chrome takes the dark grey slot, this one the light grey. "node.unknown": "#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: a filled block, not four thin dots. Every cell is a # lit foreground over its own darker ground of the same hue — light green on green # over half, yellow on brown over a quarter, light red on red below — so the gauge # reads as a *block* at a glance and the braille fill inside it says how much of the # pack is left. ``batt.flash`` and ``batt.flash.off`` are the two beats of the # last-percent alarm: black dots on red, alternating with the light red on the bare # page. Losing the ground for a beat is a bigger change than any hue swap could be, # which is what makes it read across a room. "batt.full": "bold #4ade80 on #15803d", "batt.mid": "bold #fbbf24 on #a16207", "batt.low": "bold #f87171 on #991b1b", "batt.flash": "not bold #000000 on #dc2626", "batt.flash.off": "bold #f87171", # The node-type *marks* — THE colours behind ● ▲ ■ ◉ wherever a typed node is # drawn (see ui.widgets._NODE_GLYPHS), and the map's own marker language: clients # the loud pink, repeaters the calmer violet, rooms a white square, sensors an # orange ringed dot. Named styles rather than raw hex so the 16-slot console picks # its slot *deliberately* — a naive downsample of the violet lands on grey, which # would make a repeater read as an unknown node. "type.node": "#f472b6", "type.repeater": "#a78bfa", "type.room": "#ffffff", "type.sensor": "#fb923c", # The basemap features whose *hue* is the information — water is blue, parks are # green, a highway is the warm one — and which a naive downsample therefore ruins # (the dark blue lands on grey, the dark green on black, the amber on bright red). # Named for the same reason as ``type.*``: the console picks the slot itself. The # rest of the basemap is grey by design and quantizes honestly, so minor roads, # rail, boundaries and labels stay literal hex in ui.map_render. "map.water": "#153b56", "map.river": "#49b0ec", "map.stream": "#3f8fbf", "map.ditch": "#3a7ba6", "map.park": "#173a29", "map.highway": "#f2a13d", # The heard-age heat scale's steps, named for how old the node they colour is: # a node heard eight minutes ago is ``heat.minutes``, one heard eight days ago is # ``heat.days``. Seven steps from white-hot to cold ash (JP's spec) — a cooling # ember, then what's left of it. ``heat.never`` is both "over a year" and "never # heard": past a year the distinction stops being worth a colour. # The regular platform interpolates a continuous gradient over the same anchors # rather than stepping (see ui.widgets._recency_style), so these are its ladder # spelled out — every style name resolves on both platforms either way. "heat.now": "#ffffff", # under 5 minutes "heat.minutes": "#facc15", # 5 minutes "heat.hours": "#f87171", # 1 hour "heat.days": "#b45309", # 1 day "heat.weeks": "#dc2626", # 1 week "heat.months": "#94a3b8", # 1 month "heat.never": "#64748b", # 1 year, and never heard # The PicoCalc F-key lane's chip fills (see ui.tui.fkeys): gray for the plain # F1-F5 bank, green while the Shift watcher reports F6-F10. White text on both — # defined here too so the style names resolve on every platform, even though # only PicoCalc's footer_fkeys ever asks for them. "fkey.chip": "bold #ffffff on #475569", "fkey.chip.shift": "bold #ffffff on #16a34a", # The prose voices — what a markdown page's inline marks are drawn in (see # ui.markdown). Headings borrow the styles the rest of the app already heads # sections with (``brand``, ``accent``), so only the *body* marks need names of # their own: emphasis brighter than the page, an aside dimmer than it, code and # links in the two hues nothing else in a body claims, a struck-out run receding # to the placeholder grey, and the bullet/rail chrome a list or a quote hangs on. "md.strong": "bold #e2e8f0", "md.em": "italic #cbd5e1", "md.code": "#7dd3fc", "md.link": "underline #a5b4fc", "md.quote": "italic #cbd5e1", "md.strike": "strike #64748b", "md.bullet": "#818cf8", "md.rail": "#475569", } ) #: The PicoCalc console's 16 palette slots — the **standard kernel VT palette**, by JP's #: decision (2026-08-01): the console is not remapped, so other software looks stock and #: the black background stays black. :data:`MESH_THEME_16` is designed against these #: RGBs, and the fold's stray-truecolor quantizer matches against them. Rules that still #: bind: #: #: * The kernel VT renders **bold as brightness**: ``bold`` on a 0–7 foreground jumps it #: to slot N+8. So a dim-slot style must say what it means about bold — it cannot leave #: the question open, because Rich merges a *base* style into every span it wraps and a #: selected row's ``bold`` would then recolour the span outright (a light-grey unknown #: hash arriving as white "you", a purple repeater as pink). Every dim-slot style is #: therefore either explicitly ``not bold`` (the colour is load-bearing; keep it) or #: explicitly ``bold`` (the promotion *is* the intent — only ``title.muted``). Never #: silent, and never bold on 5/6, whose partners are claimed by *different* semantics #: here (5 purple = the repeater mark, 13 pink = the node mark; 6 cyan = hint.brand, #: 14 bright cyan = brand). #: * Backgrounds can only address slots 0–7 (SGR 40–47). #: * The six *chromatic bright* slots (9-14) are the node-name spectrum's landing zone — #: the console's rendering of the key-derived hue wheel (see :data:`_NODE_SLOT_HEXES`). _VT_SLOTS: tuple[tuple[int, str, str], ...] = ( (0, "background (black)", "#000000"), (1, "red (hint.err)", "#aa0000"), (2, "green (hint.ok)", "#00aa00"), (3, "brown/orange (hint.warn, batt.mid, heat.cool, the sensor mark)", "#aa5500"), (4, "blue (hint.accent, bluetooth bg)", "#0000aa"), (5, "purple (the repeater mark)", "#aa00aa"), (6, "cyan (hint.brand)", "#00aaaa"), (7, "light grey (default text, heat.cold)", "#aaaaaa"), (8, "dark grey (muted, faint, track)", "#555555"), (9, "bright red (err; node hue 0°)", "#ff5555"), (10, "bright green (ok; node hue 120°)", "#55ff55"), (11, "bright yellow (warn, heat.warm; node hue 60°)", "#ffff55"), (12, "bright blue (accent; node hue 240°)", "#5555ff"), (13, "bright pink (the node mark — the map's pink, for free; node hue 300°)", "#ff55ff"), (14, "bright cyan (brand; node hue 180°)", "#55ffff"), (15, "white (you, heat.hot, the room mark)", "#ffffff"), ) #: The custom 16-slot remap P3 originally shipped (tailwind-family RGBs programmed via #: ``setvtrgb``) — **archived, not installed**: JP chose the standard palette but asked #: to keep this in case he changes his mind. Reinstall with #: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/calculinux-console-font.sh`` (whose opt-in #: block a test keeps byte-identical to :func:`vtrgb_lines`); MESH_THEME_16 would then #: want re-tuning against it (see git history at c6485c6 for the matching theme). _VT_SLOTS_CUSTOM: tuple[tuple[int, str, str], ...] = ( (0, "background slate", "#0f172a"), (1, "red (hint.err)", "#ef4444"), (2, "green (hint.ok)", "#22c55e"), (3, "orange", "#f59e0b"), (4, "indigo (hint.accent)", "#6366f1"), (5, "track slate (was magenta)", "#334155"), (6, "faint slate (was dim cyan)", "#64748b"), (7, "light grey", "#cbd5e1"), (8, "muted grey", "#94a3b8"), (9, "err red", "#f87171"), (10, "ok green", "#4ade80"), (11, "warn amber", "#fbbf24"), (12, "accent indigo", "#818cf8"), (13, "lavender", "#a5b4fc"), (14, "brand teal", "#5eead4"), (15, "white", "#ffffff"), ) def vtrgb_lines() -> str: """The ``setvtrgb`` file content for the **archived custom** palette (three CSV lines). Not installed by default — see :data:`_VT_SLOTS_CUSTOM`. The deploy script's opt-in block carries this same content literally (a test keeps the two in sync), so the remap stays one environment variable away without running Python. """ channels = [] for shift in (16, 8, 0): values = [(int(hex_.lstrip("#"), 16) >> shift) & 0xFF for _, _, hex_ in _VT_SLOTS_CUSTOM] channels.append(",".join(str(v) for v in values)) return "\n".join(channels) + "\n" #: The 16-slot palette theme: the same style names as :data:`MESH_THEME`, expressed as #: ``color(N)`` references into :data:`_VT_SLOTS` — the **standard** VT palette. Kept #: literal (rather than derived) so a slot choice is reviewable next to its meaning; the #: bold-brightness and background rules it must obey are documented on #: :data:`_VT_SLOTS` and pinned by tests. Notable stock-palette wins: bright pink (13) #: is the map's client colour for free, and dim purple (5) stands in for the map's #: repeater violet. The cost: the three grey depths (muted/faint/track) all collapse #: onto slot 8 — the stock palette has exactly one dark grey. MESH_THEME_16 = Theme( { "brand": "bold color(14)", "accent": "bold color(12)", # Slot 7 is the only grey the VT can put *behind* a reverse: backgrounds stop at # the dim bank, and slot 8's dark grey lands on black there. It is the same light # grey the F-key lane already fills its chips with, which is the point — one chip # look on the device. ``not bold`` because 7 is a dim slot (see the bold rule). "selected": "reverse not bold color(7)", # Slot 15, the top of the bright bank — so the row's bold cannot promote it # further, and every dim-slot span inside it behaves exactly as it did under the # old bold slot-14 highlight. "cursor": "bold color(15)", # The path line's extent mark — see the regular theme. Nothing to draw, but both # themes name the same styles, and the fold that reads it runs on both. "pathline": "none", "err.reverse": "reverse bold color(9)", "you": "bold color(15)", "device.known": "bold color(15)", "bluetooth": "bold color(15) on color(4)", "bluetooth.edge": "not bold color(4)", "ok": "bold color(10)", "warn": "bold color(11)", "err": "bold color(9)", "muted": "color(8)", # Light grey, a step *above* muted's dark grey: the console has exactly two greys, # and an unidentified node's hash is content, not chrome. "node.unknown": "not bold color(7)", "title.accent": "bold color(12)", # The one deliberate promotion: bold takes slot 7 to 15, and near-white is exactly # what this title wants (the regular theme spells it #cbd5e1). "title.muted": "bold color(7)", "title.warn": "bold color(11)", "title.err": "bold color(9)", "title.ok": "bold color(10)", "title.brand": "bold color(14)", "hint.accent": "not bold color(4)", "hint.muted": "color(8)", "hint.warn": "not bold color(3)", "hint.err": "not bold color(1)", "hint.ok": "not bold color(2)", "hint.brand": "not bold color(6)", "faint": "color(8)", "track": "color(8)", "snr.good": "bold color(10)", "snr.ok": "bold color(11)", "snr.bad": "bold color(9)", # Each band is its bright slot over its own dim one — the console has exactly one # pair per hue, and backgrounds stop at the dim bank, so the block look is the # palette's natural shape rather than a compromise with it. "batt.full": "bold color(10) on color(2)", "batt.mid": "bold color(11) on color(3)", "batt.low": "bold color(9) on color(1)", "batt.flash": "not bold color(0) on color(1)", "batt.flash.off": "bold color(9)", "type.node": "color(13)", "type.repeater": "not bold color(5)", "type.room": "color(15)", "type.sensor": "not bold color(3)", # The basemap on the console: a water body is the dim blue, every watercourse # crossing it the bright blue one rung up (the palette has exactly one of each — # a river's depth of shade is a truecolour luxury, and all three waterway shades # land on the same rung here), parks the dim green, highways brown. Backgrounds # never come into it: the canvas paints these as braille dots in the foreground. "map.water": "not bold color(4)", "map.river": "color(12)", "map.stream": "color(12)", "map.ditch": "color(12)", "map.park": "not bold color(2)", "map.highway": "not bold color(3)", # JP's ladder, slot by slot: white-hot, yellow, the ember's light red, brown as it # chars, dark red as it dies, then ash — light grey, and cold grey for what may as # well never have been heard. Six of the sixteen slots do the whole scale; the # three in the dim bank say ``not bold`` or a selected row would jump them a rung. "heat.now": "bold color(15)", "heat.minutes": "color(11)", "heat.hours": "color(9)", "heat.days": "not bold color(3)", "heat.weeks": "not bold color(1)", "heat.months": "not bold color(7)", "heat.never": "color(8)", # The F-key lane's chip fills: light-grey background (the palette's only "gray" # addressable as a background — see _VT_SLOTS) for the plain bank, green for the # Shift bank, white text on both. "fkey.chip": "bold color(15) on color(7)", "fkey.chip.shift": "bold color(15) on color(2)", # Prose on the console. The page's body text is the default light grey (slot 7), # so emphasis is the one place the VT's bold-is-brightness rule *is* the design: # ``md.strong`` says bold on purpose and lands on white, exactly the step up the # truecolor theme spells out. Its opposite, ``md.em``, keeps slot 7 by saying so. # Code takes the dim cyan (never bold — slot 6's bright partner is the brand), # links the bright blue, and the chrome (bullets, rails, a struck run) the two # greys, so a page reads as text with landmarks rather than as a colour chart. # No underline anywhere: the VT renders it by swapping in the console's own # underline colour, which would take the link's hue away from it. "md.strong": "bold color(7)", "md.em": "not bold color(7)", "md.code": "not bold color(6)", "md.link": "color(12)", "md.quote": "not bold color(7)", "md.strike": "color(8)", "md.bullet": "color(12)", "md.rail": "color(8)", } ) def active_theme() -> Theme: """The platform's theme — :data:`MESH_THEME`, or :data:`MESH_THEME_16` on PicoCalc.""" return _ACTIVE_THEME def mark_rgb(colour: str) -> tuple[int, int, int]: """A marker colour as an RGB triple — for the braille rasters. A canvas paints in RGB, not in styles (see :mod:`~meshterm.ui.mapcanvas`), so a marker that wants to agree with its Rich-drawn twin resolves the *same colour* through here. Takes either encoding the marker language uses: a literal ``#rrggbb`` (the fixed map marks in :mod:`~meshterm.ui.marks`) or a theme style name (the node-type marks, which are named so each platform picks its own — see the theme's ``type.*``). On the 16-slot theme a ``color(N)`` entry resolves to that slot's own :data:`_VT_SLOTS` RGB rather than Rich's stock triple: Rich's palette is a shade off ours and the fold's quantizer matches against ours, so the round trip would otherwise land the marker on a neighbouring slot. Memoized (cleared on a platform switch); a raster asks per node. Args: colour: ``#rrggbb``, or a style name defined in both themes. Returns: The ``(r, g, b)`` triple; mid-grey for a style with no colour of its own. """ cached = _STYLE_RGB.get(colour) if cached is None: if colour.startswith("#"): cached = (int(colour[1:3], 16), int(colour[3:5], 16), int(colour[5:7], 16)) else: color = _ACTIVE_THEME.styles[colour].color if color is None: cached = (0xAA, 0xAA, 0xAA) elif color.number is not None and color.number < len(_SLOT_RGBS): cached = _SLOT_RGBS[color.number] else: triplet = color.get_truecolor() cached = (triplet.red, triplet.green, triplet.blue) _STYLE_RGB[colour] = cached return cached 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 platform's 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=_ACTIVE_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" #: The per-node hue *spectrum*: a node's first key byte maps straight onto the HSV colour #: wheel — ``0x00`` is red, sweeping the full spectrum round to ``0xff`` — at a fixed #: saturation and value tuned to stay vivid and readable on the dark theme across every hue. #: So all 256 first-byte values give 256 distinct hues on a truecolor terminal. The app-wide #: rule holds on every platform: a node's *name* is always coloured — by this wheel, 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. _NODE_HUE_SAT = 0.65 _NODE_HUE_VAL = 0.95 #: The same wheel at the PicoCalc console's resolution: the six *chromatic bright* palette #: slots, listed in hue order from red so index ``round(byte / 256 * 6) % 6`` is the sector #: the byte's hue falls in — 60° apart, an even sixth of the wheel each. Written as the #: slots' own :data:`_VT_SLOTS` RGBs rather than as ``color(N)`` so the value is still a #: hex a caller can parse into an RGB (the map canvas and the mesh walk read the hue back #: out of the style string), and so every downsample on the way out — Rich's, and the #: fold's :func:`_quantize_sgr` — lands on that exact slot rather than guessing. # fmt: off _NODE_SLOT_HEXES: tuple[str, ...] = ( "#ff5555", # 9 red — 0° "#ffff55", # 11 yellow — 60° "#55ff55", # 10 green — 120° "#55ffff", # 14 cyan — 180° "#5555ff", # 12 blue — 240° "#ff55ff", # 13 magenta — 300° ) # fmt: on def node_style(key: str) -> str: """The stable spectrum hue a node's *key* selects — the hash-derived node colour. The node's first key byte is mapped straight onto the HSV colour wheel (``0x00`` red, sweeping round to ``0xff``), so the colour is the node's identity: it survives a rename, and only the first byte picks it, so any prefix 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. The *rule* is the platform's only constant here; its resolution is not. The regular platform spends the full spectrum (:func:`_node_style_spectrum`, 256 distinct hues at a fixed saturation/value); PicoCalc snaps the same hue to the nearest of the console's six chromatic slots (:func:`_node_style_quantized`), exactly as the heard-age heat scale quantizes its gradient there. Bound at platform-switch time. Args: key: The node's key/hash as hex (any length ≥ 1 byte, ``0x``/mixed-case tolerated). Returns: A ``"bold #rrggbb"`` style string. """ return _node_impl(key) def _key_byte(key: str) -> int: """The node's first key byte — the only slice any hue derives from.""" raw = key.lower().removeprefix("0x") try: return int(raw[:2], 16) except ValueError: # not hex — fall back to the character sum so *something* stable shows return sum(map(ord, raw)) % 256 def _node_style_spectrum(key: str) -> str: """Regular platform: the full 256-hue wheel at :data:`_NODE_HUE_SAT`/``_VAL``.""" r, g, b = colorsys.hsv_to_rgb(_key_byte(key) / 256, _NODE_HUE_SAT, _NODE_HUE_VAL) return f"bold #{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}" def _node_style_quantized(key: str) -> str: """PicoCalc: the same hue, snapped to its sixth of the wheel (:data:`_NODE_SLOT_HEXES`). Deliberate rather than left to a nearest-RGB downsample: the spectrum's pastels sit close enough to white that a naive match would drain the loudest hues toward grey, and grey is ``muted``'s — an unkeyed sender. Snapping by hue keeps all six families saturated and evenly populated (~43 of the 256 first bytes each). """ sector = round(_key_byte(key) / 256 * len(_NODE_SLOT_HEXES)) % len(_NODE_SLOT_HEXES) return f"bold {_NODE_SLOT_HEXES[sector]}" #: Every style string a node's key can mint, on *either* platform: the 256-hue spectrum #: and the console's six chromatic slots. A surface that has to *recognise* identity ink #: rather than produce it matches against this — today that is the cursor row, where a #: name gives way to the highlight's white. Both vocabularies live in the one set so the #: answer never depends on which platform happens to be bound, and they cannot collide: #: the spectrum's channels top out at ``0xf2`` (value 0.95) while every console slot #: spells ``0xff``. _IDENTITY_STYLES: frozenset[str] = frozenset( [_node_style_spectrum(f"{byte:02x}") for byte in range(256)] + [f"bold {hex_}" for hex_ in _NODE_SLOT_HEXES] ) def is_identity_style(style: str) -> bool: """Is ``style`` a hue some node's key minted — a *name's* ink rather than the page's? :func:`node_style` read backwards, for the one surface that needs the vocabulary as a question: the cursor row, whose node names are drawn in its white instead of their own hue (see :func:`~meshterm.ui.tui.render.render_to_ansi`). Deliberately narrow — only a key-derived hue answers yes, so ``node.unknown``'s grey (a node we *cannot* identify, which the highlight has no business claiming to know), the ``you`` white, and every context colouring keep exactly what they were given. Args: style: A style string as it appears on a span. Returns: ``True`` if it is one of the hues :func:`node_style` mints. """ return style in _IDENTITY_STYLES def name_style(name: str, key: str | None = None) -> str: """The stable colour a node or sender name is drawn in — keyed on the node's key. One rule on every platform: the hue is :func:`node_style`'s key-derived spectrum, so a rename keeps the colour and every surface that knows any prefix of the key agrees. Only the *resolution* changes with the platform (see :func:`node_style`). Args: name: The display name (unused for the hue; kept so every call site reads ``name_style(name, key)`` and the pair stays greppable). key: Any known prefix of the node's key/hash. ``None``/empty marks a sender whose key we couldn't resolve — drawn ``node.unknown``, because colour is reserved for keyed identities (callers with only a name resolve it first via :func:`~meshterm.services.trace_runner.make_name_key_resolver`). Returns: A style for the name; the same node always maps to the same style on a given platform, so it keeps its colour across screens and sessions. """ return node_style(key) if key else "node.unknown" # -- the compact icon language (PicoCalc) --------------------------------------------- #: Emoji → single console-font character: the PicoCalc icon language. One glyph per #: concept, chosen from what the 512-glyph font actually holds (see ui.fontset); #: concepts that only ever share a *family* (outbound ↑, route ∟) share deliberately. #: The node-type marks (★●▲■◉○) and status marks (✓ ✗ ⚠ ● ○) are already font-native #: and never appear here. ``glyph()`` consumes this table at explicit icon call sites #: (a 1-cell lane the screen composes); the render-boundary fold consumes it for #: everything else, padding to the emoji's measured width so layout survives. # fmt: off _GLYPH_MAP: dict[str, str] = { # Packet classes (KIND_ICONS) "📢": "☼", # advert — a node radiating its presence "📊": "≈", # telemetry — a waveform of readings "📦": "▬", # packet — a plain slab of payload "💬": "¶", # message — text "✅": "✓", # ack (the ok-family check) "❔": "·", # unknown class — a neutral dot # Raw payload classes (PAYLOAD_ICONS); raw ADVERT/ACK reuse ☼/✓ above "📥": "↑", # REQ — a question going out "📮": "↓", # RESPONSE — the answer coming back "📩": "→", # TEXT_MSG (overheard direct message) — text in flight "📻": "#", # GRP_TXT — channel text (the # channel mark) "💽": "§", # GRP_DATA — a data section on a channel "🎭": "?", # ANON_REQ — a request from an unproven identity "🧭": "∟", # PATH — a route with a bend in it "🎯": "⌖", # TRACE — the crosshair (also the map's position mark) "🧩": "▒", # MULTIPART — a frame in fragments "🧰": "↨", # CONTROL — adjustment up-and-down # Channel openness (widgets.channel_glyph) "#": "#", # name-derived channel "🌐": "@", # well-known public channel "🔒": "⚿", # private channel (the padlock mark) # Concept icons (menu/list rows) "📡": "☼", # advert tool — same concept as the advert class "🕒": "◷", # clock/sync (the clock-face mark) "🔄": "°", # reboot — the power dot "💾": "⌂", # backup — put it somewhere safe "📂": "^", # restore — bring it back up "🔑": "*", # channel/credential key — masked-secret asterisk "🔐": "*", # identity/auth secret — same secret-material mark "🗑": "✗", # clear/delete — the destructive mark "✎": "~", # compose/edit — a scribble "⚡": "!", # explore/probe "⭐": "+", # watch — added to the watchlist "📤": "↑", # send now (outbound family) "📨": "=", # courier/queue — stacked letters "🔔": "•", # notify — the badge dot "🔕": "·", # mute — the hollowed-out dot "📱": "▓", # QR — a dense block "🔗": "&", # link — the joining glyph "🏆": "★", # trophy case — the best/winner star "⌨": "❯", # command line — the prompt cursor "📖": "¶", # read/about — a page of prose (the text mark, as ¶ is for a message) "💰": "$", # support/donate — the plainest possible money mark "🚪": "", # quit — no icon; the word carries it "🌍": "@", # map/world (globe family) "🕸": "∟", # mesh walk (route family) "🚨": "⚠", # watchtower alert "🛣": "∟", # longest-haul route (route family) "🏹": "►", # longest leg — the arrow in flight (▶ is the run *action*, not this) "🧳": "→", # trip/journey "🔆": "°", # brightest sighting "📶": "≥", # TX power sweep — the power ramp "🔧": "⚙", # config (parameter concept) "🔨": "!", # device actions (probe family) "📋": "i", # info "📰": "…", # live feed — a stream of items "🎧": "≈", # monitor — listening to the waveform "🗼": "▲", # repeater admin — the repeater mark itself "🔌": "~", # serial port — the cable "👤": "%", # a person (two-circle silhouette) "👥": "%", # contacts — people "👋": "", # a wave in prose — the words carry it "⏳": "…", # pending/waiting "+": "+", # fullwidth plus (channels' add row) } # fmt: on def glyph(icon: str) -> str: """The platform's rendering of an icon: the emoji itself, or its compact glyph. On the regular platform this is the identity — emoji icons render as themselves. On PicoCalc every icon funnels to a single console-font character via :data:`_GLYPH_MAP` (an unmapped icon passes through and is caught by the glyph whitelist test / render-boundary fold, not silently invented here). Note the compact form is *one cell* where the emoji was two: call sites compose their lanes from the returned glyph, so the lane simply tightens on PicoCalc. Args: icon: An emoji, a node/status mark, or any literal character. Returns: The character(s) to render for it on the active platform. """ return _glyph_impl(icon) def _glyph_identity(icon: str) -> str: """Regular platform: icons render as themselves.""" return icon def _glyph_compact(icon: str) -> str: """PicoCalc: icons collapse to their single-cell console-font glyph.""" return _GLYPH_MAP.get(icon, icon) # -- the render-boundary fold (PicoCalc) ---------------------------------------------- #: What may survive the fold: every font codepoint, plus the C0 controls the rendered #: ANSI itself is built from (ESC in its sequences, the newlines between lines). _FOLD_ALLOWED: frozenset[int] = FONT_CODEPOINTS.union(range(0x00, 0x20)) #: Width-1 characters outside the font with a natural width-1 stand-in. Applied by the #: fold's translation table (storage is never touched). Characters *in* the font — #: ``— … ⋯ ⚠ ⌫ ⇧ ⚙ ↻ ◷ ⌖ ⚿ ← ↑ → ↓ ↔ ↕ • ·`` and the Cyrillic block — never appear #: here: they pass through untranslated. # fmt: off _FOLD_SINGLES: dict[str, str] = { "–": "-", "−": "-", "‒": "-", "―": "—", "‘": "'", "’": "'", "‚": "'", "“": '"', "”": '"', "„": '"', "‹": "<", "›": ">", "«": "<", "»": ">", "×": "x", "÷": "/", "⁄": "/", "∙": "·", "∘": "·", "⇒": "→", "⇐": "←", "⇣": "↓", "⇡": "↑", "↩": "←", "↪": "→", "⇄": "↔", "⟷": "↔", "⟺": "↔", "⟲": "↻", "⟳": "↻", "✕": "✗", "✖": "✗", "✔": "✓", "ᛒ": "B", # the Bluetooth badge rune "œ": "o", "Œ": "O", "æ": "a", "Æ": "A", "ø": "o", "Ø": "O", "ß": "s", "þ": "p", "Þ": "P", "ð": "d", "Ð": "D", "đ": "d", "Đ": "D", "ł": "l", "Ł": "L", "ı": "i", # Powerline path-pill chrome (private-use): caps become half-blocks, the separator # a plain wedge. "": ">", "": "▌", "": "▐", # Zero-width machinery folds away entirely (width 0 → empty keeps cell math exact): # VS16, ZWJ, ZWSP. "️": "", "‍": "", "​": "", } # fmt: on #: Built lazily on first fold: ``str.translate`` table = accent folds (NFKD, computed #: over the Latin ranges once) + :data:`_FOLD_SINGLES` + the emoji map padded to each #: emoji's measured cell width. _FOLD_TABLE: dict[int, str] | None = None #: Truecolor / 256-colour SGR sequences embedded in *pre-rendered* ANSI. The rasterizer #: console downsamples everything it renders itself, but the braille canvases #: (``ui.mapcanvas``) emit their own truecolor escapes which pass through Rich verbatim #: — the fold quantizes those to the 16 slots so the contract holds for every byte out. _SGR_RGB = _re.compile(r"\x1b\[([34])8;2;(\d+);(\d+);(\d+)m") _SGR_256 = _re.compile(r"\x1b\[([34])8;5;(\d+)m") _SLOT_RGBS: tuple[tuple[int, int, int], ...] = tuple( (int(h.lstrip("#")[0:2], 16), int(h.lstrip("#")[2:4], 16), int(h.lstrip("#")[4:6], 16)) for _, _, h in _VT_SLOTS ) #: (is_background, r, g, b) → the replacement SGR string. The app uses a few dozen #: distinct colours; this stays tiny. _SLOT_CACHE: dict[tuple[bool, int, int, int], str] = {} def _nearest_slot_sgr(background: bool, r: int, g: int, b: int) -> str: """The plain 16-colour SGR closest to ``(r, g, b)`` in the :data:`_VT_SLOTS` palette. Foregrounds may land on any slot (30–37 / 90–97); backgrounds only on 0–7 (the VT has no bright backgrounds), so a bright colour used as a fill picks its dim-bank cousin. A **dim-bank foreground states its intent** (``22;3N``, normal intensity) rather than emitting a bare ``3N``. Bold is brightness on the VT, and ``9N`` *is* how the console spells bright — so a bare ``31`` immediately after a ``90`` inherits the intensity bit and silently renders as ``91``. Quantized art is a run of adjacent colour spans with no style boundaries to reset between them (the wordmark's every-other-span slate bevel; a map raster's neighbouring cells), so the promotion lands mid-row and colours half a row wrong: the narrow wordmark's fourth row — its only dim-slot row — came out part red, part light red, split at each span that happened to follow the bevel. """ key = (background, r, g, b) cached = _SLOT_CACHE.get(key) if cached is None: candidates = _SLOT_RGBS[:8] if background else _SLOT_RGBS slot = min( range(len(candidates)), key=lambda i: ( (candidates[i][0] - r) ** 2 + (candidates[i][1] - g) ** 2 + (candidates[i][2] - b) ** 2 ), ) if background: sgr = f"\x1b[{40 + slot}m" elif slot < 8: sgr = f"\x1b[22;{30 + slot}m" else: sgr = f"\x1b[{90 + slot - 8}m" cached = _SLOT_CACHE[key] = sgr return cached def _rgb_of_256(index: int) -> tuple[int, int, int]: """The canonical RGB of xterm-256 ``index`` (cube and grayscale ramps).""" if index < 16: return _SLOT_RGBS[index] if index < 232: index -= 16 steps = (0, 95, 135, 175, 215, 255) return (steps[index // 36], steps[index // 6 % 6], steps[index % 6]) grey = 8 + (index - 232) * 10 return (grey, grey, grey) def _quantize_sgr(text: str) -> str: """Fold any embedded truecolor / 256-colour SGR down to the 16 palette slots.""" if "[38;2;" not in text and "[48;2;" not in text and "8;5;" not in text: return text text = _SGR_RGB.sub( lambda m: _nearest_slot_sgr( m.group(1) == "4", int(m.group(2)), int(m.group(3)), int(m.group(4)) ), text, ) return _SGR_256.sub( lambda m: _nearest_slot_sgr(m.group(1) == "4", *_rgb_of_256(int(m.group(2)))), text, ) def _build_fold_table() -> dict[int, str]: """Compose the full translation table (see :data:`_FOLD_TABLE`).""" import unicodedata table: dict[int, str] = {} # Accented Latin (the font's base table is Cyrillic-coverage Terminus: *no* accented # Latin at all) plus fullwidth forms: NFKD-decompose, drop combining marks, keep a # clean single ASCII survivor. é→e, Å→A, #→#, fi→(skipped: two chars). for first, last in ((0x00A1, 0x024F), (0x1E00, 0x1EFF), (0xFF01, 0xFF5E)): for cp in range(first, last + 1): if cp in FONT_CODEPOINTS: continue decomposed = unicodedata.normalize("NFKD", chr(cp)) base = "".join(c for c in decomposed if unicodedata.category(c) != "Mn") if len(base) == 1 and base.isascii() and base.isprintable(): table[cp] = base for char, replacement in _FOLD_SINGLES.items(): table[ord(char)] = replacement for emoji, compact in _GLYPH_MAP.items(): pad = max(0, cell_len(emoji) - cell_len(compact)) table[ord(emoji)] = compact + " " * pad return table @lru_cache(maxsize=4096) def _fold_to_font(text: str) -> str: """Fold ``text`` down to the console font's inventory, cell widths preserved. Three stages, cheapest first: the translation table (accents, symbol stand-ins, the emoji map — one C-level pass), then only if something non-ASCII survives, a per-character sweep replacing anything still outside the font with ``?`` at the character's own cell width. The sweep is the safety net that makes the platform's no-wide-glyphs guarantee (see ``session._has_wide_glyph``) true *by construction*: an emoji this module has never heard of still leaves as narrow ``?``s, never as a tofu box that breaks the frame's cell math. Cached — render output repeats heavily frame to frame, and the fold is platform-independent once this impl is bound. """ global _FOLD_TABLE if _FOLD_TABLE is None: _FOLD_TABLE = _build_fold_table() folded = _quantize_sgr(text).translate(_FOLD_TABLE) if folded.isascii(): return folded if all(ord(ch) in _FOLD_ALLOWED for ch in folded): return folded return "".join(ch if ord(ch) in _FOLD_ALLOWED else "?" * max(0, cell_len(ch)) for ch in folded) def fold_text(text: str) -> str: """The render-boundary text filter for the active platform. Identity on the regular platform. On PicoCalc, folds any string that is about to be drawn — names, message bodies, whole rendered ANSI lines — down to characters the console font can shape (see :func:`_fold_to_font`); storage is never touched. ANSI escape sequences pass through untouched (they are pure ASCII, and the fold never rewrites ASCII). Applied once, centrally, in :func:`meshterm.ui.tui.render.render_to_ansi` — individual screens should not need to call it. Args: text: The text to fold (or pass through). Returns: The folded text; cell widths are preserved (wide emoji become glyph + pad). """ return _fold_impl(text) def _no_fold(text: str) -> str: """Regular platform: text renders as stored.""" return text 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" # -- platform binding ------------------------------------------------------------------ _ACTIVE_THEME: Theme = MESH_THEME _node_impl: Callable[[str], str] = _node_style_spectrum _glyph_impl: Callable[[str], str] = _glyph_identity _fold_impl: Callable[[str], str] = _no_fold #: Marker colour → RGB, filled by :func:`mark_rgb` and dropped on a platform switch (the #: answer is the *active* theme's). _STYLE_RGB: dict[str, tuple[int, int, int]] = {} @on_platform def _bind(platform: Platform) -> None: """Bind the theme's platform-dependent choices (runs now and on every switch).""" global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE _ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16 _node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized _glyph_impl = _glyph_identity if platform.emoji else _glyph_compact _fold_impl = _fold_to_font if platform.ascii_fold else _no_fold _STYLE_RGB.clear() # The fold table's emoji pads are computed with cell_len at build time. Cell widths # can be re-measured/patched (the emoji-width calibration on the regular platform), # so a platform switch drops the table and cache rather than trusting stale pads. _FOLD_TABLE = None _fold_to_font.cache_clear()