`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
969 lines
48 KiB
Python
969 lines
48 KiB
Python
"""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()
|