P3 rework: the PicoCalc visual language, done properly

The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:

- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
  families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
  rewritten against color(0..15) with the VT's bold-as-brightness and
  dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
  install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
  so theme, rasterizer and widgets swap implementations once per switch
  and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
  (NFKD accent folds - the Terminus base has no accented Latin - plus
  symbol stand-ins and the width-padded emoji map), an embedded-SGR
  quantizer for canvas truecolor, and a ?-at-own-width catch-all that
  makes the no-wide-glyph short-circuit true by construction. Applied at
  the only two places every visible byte passes: render_to_ansi and
  MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
  the gallery now asserts rendered picocalc ANSI carries no truecolor/
  256-colour SGR and no character outside it - the parity gate the
  first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
  gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
  donor misses now fatal and a keeper canary (donating pi once erased
  Cyrillic pe - shared slot). Table re-dumped on-device: identical to
  fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
  no longer import ui), fed from monitor observations, heard_nodes and
  merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
  drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
  funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
  marks, not the banned emoji pair.

Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-08-01 12:48:07 -04:00
1 parent 66d728a3b3
commit 84caae53f8
21 files changed
+1362 -310

No files matched your search

+16
View File
@@ -182,6 +182,22 @@ def platform_command() -> None:
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
@app.command(name="specimen")
def specimen_command() -> None:
"""Print the visual-language specimen for the active platform.
Every mark, icon funnel, colour scale and fold on one card, drawn through the same
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
previews that flavour. See :mod:`meshterm.ui.specimen`.
"""
from .ui.specimen import specimen_lines
console = make_console()
for line in specimen_lines():
console.print(line)
def run_tool_command(tool: Tool, params: dict) -> None:
"""Execute a tool from a CLI subcommand and render its result.
+5 -1
View File
@@ -33,6 +33,7 @@ from pathlib import Path
from typing import Optional
from .models import Contact, advert_time
from .nodetypes import register_node_type
def _norm(pubkey: str) -> str:
@@ -284,4 +285,7 @@ def merge_contacts(
for remembered in store.contacts(device_pubkey)
if remembered.public_key and remembered.public_key not in present
]
return list(live) + extra
merged = list(live) + extra
for contact in merged: # every contact read passes through here — feed the type registry
register_node_type(contact.public_key or contact.key_prefix, contact.node_type)
return merged
+70
View File
@@ -0,0 +1,70 @@
"""A process-wide map from a node's key prefix to its advert type.
PicoCalc's 16-slot console palette can't afford the regular platform's 256-hue key-derived
name spectrum, so there a name is coloured by its node's *type* (client / repeater / room /
sensor) instead — see ``name_colour`` on :class:`~meshterm.platforms.Platform`. The theme's
type-based ``name_style`` needs the type for a bare key at render time, long after the
advert that carried it; this registry is that lookup.
It is fed by the reception layer wherever typed nodes materialize — live observations
(:class:`~meshterm.services.monitor_service.MonitorService`), heard nodes read back from
the history DB (:meth:`~meshterm.persistence.repository.Repository.heard_nodes`), and the
device's contact list (:func:`~meshterm.core.contact_store.merge_contacts`) — so both a
fresh session and a long-running one resolve the same nodes to the same types.
Keys are reduced to their **first byte**, the same slice every key-prefix surface agrees
on (see :func:`~meshterm.ui.theme.node_style`): any prefix of a key a caller happens to
hold — a 2-hex path hop, the stored 12-hex id, the full public key — lands on the same
entry. Two nodes sharing a first byte with different types will colour by whichever was
registered last; colour is a hint, not a guarantee, and the collision odds mirror the
hue-collision odds the regular platform already accepts.
"""
from __future__ import annotations
from typing import Optional
from .models import NODE_TYPE_LABELS
#: first key byte (two lowercase hex chars) → type name ("node"/"repeater"/"room"/"sensor").
_TYPES_BY_PREFIX: dict[str, str] = {}
def _prefix(key: str) -> Optional[str]:
"""The registry key for ``key``: its first byte as two lowercase hex chars."""
raw = key.lower().removeprefix("0x")
return raw[:2] if len(raw) >= 2 else None
def register_node_type(key: Optional[str], node_type: Optional[int]) -> None:
"""Record that the node ``key`` belongs to advertises as ``node_type``.
A no-op for a missing key, a too-short prefix, or a type outside the known
``NODE_TYPE_*`` set, so reception paths can call it unconditionally.
Args:
key: The node's public key or any hex prefix of it (first byte is used).
node_type: The advert type (``NODE_TYPE_CHAT``/``REPEATER``/``ROOM``/``SENSOR``),
or ``None`` when the packet didn't carry one.
"""
if not key or node_type is None:
return
name = NODE_TYPE_LABELS.get(node_type)
prefix = _prefix(key)
if name and prefix:
_TYPES_BY_PREFIX[prefix] = name
def node_type_name(key: Optional[str]) -> Optional[str]:
"""The registered type name for ``key``'s node, or ``None`` when never registered.
Args:
key: The node's key/hash as hex (any length ≥ 1 byte).
Returns:
``"node"``, ``"repeater"``, ``"room"``, ``"sensor"``, or ``None``.
"""
if not key:
return None
prefix = _prefix(key)
return _TYPES_BY_PREFIX.get(prefix) if prefix else None
+3
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Optional
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
from ..core.nodetypes import register_node_type
from ..core.models import (
PATH_TRACE_TARGET,
ChatMessage,
@@ -1539,6 +1540,8 @@ class Repository:
grouped.setdefault(row["node"], []).append(obs)
nodes = [HeardNode.from_observations(node, obs) for node, obs in grouped.items()]
for heard in nodes: # backfill the type registry from history (PicoCalc colour)
register_node_type(heard.public_key or heard.node, heard.node_type)
return sorted(nodes, key=lambda n: n.last_seen, reverse=True)
# -- chat messages ----------------------------------------------------------
+32 -2
View File
@@ -20,7 +20,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Callable, Optional
@dataclass(frozen=True, slots=True)
@@ -175,8 +175,35 @@ def get_platform() -> Platform:
return PLATFORM
#: Callbacks re-run on every :func:`set_platform`, so a module can bind platform-derived
#: state (a chosen theme, a swapped function implementation) once per *switch* instead of
#: re-deriving it per call in a render loop. Registered via :func:`on_platform`.
_BINDINGS: list[Callable[[Platform], None]] = []
def on_platform(binding: Callable[[Platform], None]) -> Callable[[Platform], None]:
"""Register (and immediately run) a platform binding.
The seam's principle is *bind at construction time, never per frame*: a consumer that
must swap behaviour with the platform (the theme's ``name_style`` impl, the
rasterizer's console cache) registers a binding here at its own import time. The
binding runs once right away — against the platform active *now* — and again on every
later :func:`set_platform`, so tests that swap platforms rebind automatically and the
consumer's hot path reads a plain module global.
Args:
binding: Called with the active :class:`Platform`; must be idempotent.
Returns:
``binding`` unchanged, so it can be used as a decorator.
"""
_BINDINGS.append(binding)
binding(PLATFORM)
return binding
def set_platform(platform: Platform) -> None:
"""Install ``platform`` as the active one.
"""Install ``platform`` as the active one and re-run every registered binding.
Called exactly once by the CLI callback, before :func:`~meshterm.ui.theme.make_console`
— every other read of :func:`get_platform` in the same process happens after this.
@@ -189,6 +216,8 @@ def set_platform(platform: Platform) -> None:
"""
global PLATFORM
PLATFORM = platform
for binding in _BINDINGS:
binding(platform)
@dataclass(frozen=True, slots=True)
@@ -295,6 +324,7 @@ __all__ = [
"PLATFORM",
"get_platform",
"set_platform",
"on_platform",
"Resolution",
"resolve",
]
+1 -2
View File
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Optional
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import utcnow
from ..core.nodetypes import register_node_type
if TYPE_CHECKING:
from ..context import AppContext
@@ -204,8 +205,6 @@ class MonitorService:
"monitor", {"mode": "background"}, self._ctx.profile_name
)
self._ctx.repo.record_observation(self._run_id, obs)
# Register the node's type for PicoCalc name colouring (P3)
from meshterm.ui.theme import register_node_type
register_node_type(obs.node, obs.node_type)
except Exception as exc: # noqa: BLE001 - never let logging break capture
self._ctx.log.debug("monitor: failed to record observation: %s", exc)
+1 -1
View File
@@ -270,7 +270,7 @@ class ChatTool(Tool):
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
message = await ctx.chat.send_direct(contact, text)
state = "[ok]✅ delivered[/ok]" if message.acked else "[warn]❌ no ack[/warn]"
state = "[ok]✓ delivered[/ok]" if message.acked else "[warn]? no ack[/warn]"
ctx.ui.note(f"[ok]✓[/ok] sent to [brand]{contact.name}[/brand] — {state}")
return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)})
+59
View File
@@ -0,0 +1,59 @@
"""The PicoCalc console font's codepoint inventory — the glyph contract.
The PicoCalc panel draws through the kernel console with one 512-glyph PSF font
(``meshterm.psf.gz``, built by ``scripts/calculinux-console-font.sh``): the stock
Terminus 6×12 base (a Cyrillic-coverage table — note there are **no accented Latin
letters**), the full braille block, MeshTerm's own marks drawn into donor slots, and a
few aliases. A character outside this set renders as a blank on the panel, so *nothing
outside it may ever be emitted* on the picocalc platform — the render boundary folds
text down to it (:func:`meshterm.ui.theme.fold_text`), and the dual-platform gallery
asserts the fold held.
This table is the **single source of truth** for that contract, shared by the runtime
fold and the tests. It mirrors the font actually installed on the device: the base
inventory was dumped live from the PSF's Unicode table (2026-08-01), minus the donor
codepoints the build script now repurposes, plus the marks it draws in their place.
If the build script's ``MARKS``/``DONORS``/``ALIASES`` change, this table must move in
the same commit — and vice versa.
"""
from __future__ import annotations
#: Inclusive ``(first, last)`` codepoint ranges present in the font. 519 codepoints over
#: 512 glyphs — aliases (the rounded corners onto the square ones, ``⋯`` onto ``…``) and
#: shared slots give a few glyphs more than one codepoint. Verified against a live table
#: dump of the installed font (2026-08-01, post-P3 build).
FONT_RANGES: tuple[tuple[int, int], ...] = (
(0x0020, 0x007F), (0x00A0, 0x00A0), (0x00A7, 0x00A7), (0x00A9, 0x00A9),
(0x00B0, 0x00B0), (0x00B2, 0x00B2), (0x00B6, 0x00B7), (0x03C0, 0x03C0),
(0x0401, 0x0401),
(0x0404, 0x0404), (0x0406, 0x0407), (0x0410, 0x044F), (0x0451, 0x0451),
(0x0454, 0x0454), (0x0456, 0x0457), (0x0490, 0x0491), (0x2014, 0x2014),
(0x2022, 0x2022), (0x2026, 0x2026), (0x2190, 0x2195), (0x21A8, 0x21A8),
(0x21BB, 0x21BB), (0x21E7, 0x21E7), (0x221A, 0x221A), (0x221F, 0x221F),
(0x2248, 0x2248), (0x2264, 0x2265), (0x22EF, 0x22EF), (0x2302, 0x2302),
(0x2316, 0x2316), (0x232B, 0x232B), (0x2500, 0x2500), (0x2502, 0x2502),
(0x250C, 0x250C), (0x2510, 0x2510), (0x2514, 0x2514), (0x2518, 0x2518),
(0x251C, 0x251C), (0x2524, 0x2524), (0x252C, 0x252C), (0x2534, 0x2534),
(0x253C, 0x253C), (0x2550, 0x2551), (0x2554, 0x2554), (0x2557, 0x2557),
(0x2559, 0x255B),
(0x255D, 0x2561), (0x2563, 0x2563), (0x2566, 0x256A), (0x256C, 0x2570),
(0x2580, 0x2580), (0x2584, 0x2584), (0x2588, 0x2588), (0x258C, 0x258C),
(0x2590, 0x2593), (0x25A0, 0x25A0), (0x25AC, 0x25AC), (0x25B2, 0x25B2),
(0x25B6, 0x25B6), (0x25B8, 0x25B8), (0x25BA, 0x25BA), (0x25BC, 0x25BC),
(0x25C0, 0x25C0), (0x25C4, 0x25C4), (0x25C9, 0x25C9), (0x25CB, 0x25CB),
(0x25CF, 0x25CF), (0x25F7, 0x25F7), (0x2605, 0x2605), (0x263C, 0x263C),
(0x2699, 0x2699), (0x26A0, 0x26A0), (0x26BF, 0x26BF), (0x2713, 0x2713),
(0x2717, 0x2717), (0x276F, 0x276F), (0x2800, 0x28FF), (0xFFFD, 0xFFFD),
)
#: Every codepoint the console font can draw, as one frozen set (built once at import;
#: 520 members, so the set is small and the per-character membership test is a dict hit).
FONT_CODEPOINTS: frozenset[int] = frozenset(
cp for first, last in FONT_RANGES for cp in range(first, last + 1)
)
def in_font(char: str) -> bool:
"""Whether the console font has a glyph for ``char``."""
return ord(char) in FONT_CODEPOINTS
+8 -10
View File
@@ -20,7 +20,8 @@ from typing import Optional
from ..core.geo import Viewport
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from .mapcanvas import RGB, MapCanvas, parse_hex
from .mapcanvas import MapCanvas
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
# -- node markers -------------------------------------------------------------
@@ -53,15 +54,12 @@ class MapMarker:
return 2 if self.is_self else (1 if self.is_repeater else 0)
# Marker palette, shared across the whole app. The basemap is blue (water), green (parks) and
# warm amber (roads), so markers use the hues a map never contains — pink/violet — to stand
# out against it. Ordinary nodes are the loudest (bright pink); repeaters recede a step (a
# calmer violet); our own node stays the fixed yellow star; a node heard of but never
# identified is a muted grey ring.
_SELF = ("★", "#facc15")
_REPEATER = ("▲", "#a78bfa")
_NODE = ("●", "#f472b6")
_UNKNOWN = ("○", "#94a3b8")
# Marker palette, shared across the whole app — canonical tuples in ui.marks; the old
# private names stay bound here for this module and its existing importers.
_SELF = SELF_MARK
_REPEATER = REPEATER_MARK
_NODE = NODE_MARK
_UNKNOWN = UNKNOWN_MARK
# -- basemap styling ----------------------------------------------------------
+14 -9
View File
@@ -17,6 +17,8 @@ from __future__ import annotations
import unicodedata
from typing import Optional
from .marks import RGB, parse_hex # noqa: F401 - canonical home; re-exported for importers
#: Unicode braille pattern base; add a dot bitmask to get the glyph.
_BRAILLE_BASE = 0x2800
@@ -53,13 +55,6 @@ _DOT_BITS = (
(0x08, 0x10, 0x20, 0x80), # right column, rows 0..3
)
RGB = tuple[int, int, int]
def parse_hex(color: str) -> RGB:
"""Convert ``"#rrggbb"`` (or ``"rrggbb"``) to an ``(r, g, b)`` tuple."""
c = color.lstrip("#")
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
class MapCanvas:
@@ -303,7 +298,17 @@ class MapCanvas:
# -- output -----------------------------------------------------------------
def to_ansi_lines(self) -> list[str]:
"""Render the canvas to one truecolour ANSI string per row."""
"""Render the canvas to one truecolour ANSI string per row.
The canvas is a rasterizer of its own — it emits escape codes directly rather
than going through the themed Rich console — so, like
:func:`meshterm.ui.tui.render.render_to_ansi`, its output leaves through the
platform's render-boundary fold: on PicoCalc the truecolour SGR quantizes to
the 16 palette slots (and any stray glyph folds to the console font) right
here, wherever the lines end up embedded.
"""
from .theme import fold_text
reset = "\x1b[0m"
lines: list[str] = []
for cy in range(self.cell_h):
@@ -332,5 +337,5 @@ class MapCanvas:
parts.append(ch)
if cur is not None:
parts.append(reset)
lines.append("".join(parts))
lines.append(fold_text("".join(parts)))
return lines
+49
View File
@@ -0,0 +1,49 @@
"""The shared mark constants: node-type glyphs, their colours, and path sentinels.
The app's spatial surfaces — the map, the path graph, the braille rasters, the node
rows — all pin nodes with the same marks in the same hues, and several of them need the
constants *without* the machinery around them. Before this module, ``ui.widgets`` pulled
``map_render``, ``mapcanvas`` and ``pathgraph`` onto the boot path for a handful of
tuples and type aliases (~60 ms of import on the PicoCalc); now the constants live here,
dependency-free, and the heavy modules import *from* the constants instead of hosting
them. ``map_render``/``mapcanvas``/``pathgraph`` re-export their old names, so existing
importers of those modules are untouched.
"""
from __future__ import annotations
from typing import Callable, Optional
#: An ``(r, g, b)`` colour triple, 0–255 per channel (the raster/canvas colour type).
RGB = tuple[int, int, int]
def parse_hex(color: str) -> RGB:
"""Convert ``"#rrggbb"`` (or ``"rrggbb"``) to an ``(r, g, b)`` tuple."""
c = color.lstrip("#")
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
# Marker palette, shared across the whole app. The basemap is blue (water), green (parks)
# and warm amber (roads), so markers use the hues a map never contains — pink/violet — to
# stand out against it. Ordinary nodes are the loudest (bright pink); repeaters recede a
# step (a calmer violet); our own node stays the fixed yellow star; a node heard of but
# never identified is a muted grey ring. Every glyph is in the PicoCalc console font.
SELF_MARK = ("★", "#facc15")
REPEATER_MARK = ("▲", "#a78bfa")
NODE_MARK = ("●", "#f472b6")
UNKNOWN_MARK = ("○", "#94a3b8")
#: Sentinels naming a path's endpoints on the route graph. ``\x00`` never appears in a
#: node hash, so they can share the node-id namespace without colliding with one.
SRC_NODE = "\x00src"
DST_NODE = "\x00dst"
#: Maps a node id to its graph marker: a ``(glyph, colour)`` pair.
GlyphOf = Callable[[str], tuple[str, str]]
#: A node's graph label, or ``None``/``""`` to leave the marker bare.
LabelOf = Callable[[str], Optional[str]]
#: The colour a node's label is drawn in (usually the node's name hue).
LabelRgbOf = Callable[[str], RGB]
+10 -17
View File
@@ -99,12 +99,16 @@ from itertools import permutations, product
from math import ceil
from typing import Callable, Optional, Sequence
from .mapcanvas import RGB, MapCanvas, parse_hex
#: Sentinel node ids for the graph's endpoints (NUL never collides with hex hops).
#: Callers key their glyph/label callbacks on these for the two ends of every path.
SRC_NODE = "\x00src"
DST_NODE = "\x00dst"
from .mapcanvas import MapCanvas
from .marks import ( # noqa: F401 - canonical home; re-exported for existing importers
DST_NODE,
RGB,
SRC_NODE,
GlyphOf,
LabelOf,
LabelRgbOf,
parse_hex,
)
#: Joins a hop id to its occurrence index when ``allow_duplicate_nodes`` splits a path's
#: revisits into their own markers. Leans on the same guarantee the endpoint sentinels do —
@@ -192,17 +196,6 @@ _ARROW_GLYPH = ""
_EMPHASIS_BOOST = 1_000_000
#: A node's graph marker: the glyph and its ``#rrggbb`` colour (the shared node-glyph
#: tuples from :mod:`~meshterm.ui.map_render` / :mod:`~meshterm.ui.widgets` fit as-is).
GlyphOf = Callable[[str], tuple[str, str]]
#: A node's graph label, or ``None``/``""`` to leave the marker bare.
LabelOf = Callable[[str], Optional[str]]
#: The colour a node's label is drawn in (usually the node's name hue).
LabelRgbOf = Callable[[str], RGB]
@dataclass(frozen=True)
class PathLayer:
"""One path drawn on the route graph.
+2 -2
View File
@@ -66,7 +66,7 @@ from rich.text import Text
from ..core.models import LOCAL_DEVICE_LABEL
from .termfont import powerline_enabled, powerline_full
from .theme import MESH_THEME, node_style
from .theme import active_theme, node_style
#: The powerline solid right-pointing triangle (U+E0B0) — the only glyph the widget
#: draws, seam and closing edge alike, deliberately from the *core* set so every
@@ -168,7 +168,7 @@ def _style_hex(style: str) -> Optional[str]:
for token in reversed(style.split()):
if token.startswith("#") and len(token) == 7:
return token
themed = MESH_THEME.styles.get(style)
themed = active_theme().styles.get(style)
if themed is not None and themed.color is not None:
try:
return "#" + themed.color.get_truecolor().hex.lstrip("#")
+130
View File
@@ -0,0 +1,130 @@
"""The visual-language specimen: every mark, icon, colour and fold on one screen.
``meshterm specimen`` prints this through the platform's real machinery — the active
theme, the ``glyph()`` icon funnel, the render-boundary fold, the heat and SNR scales —
so what it shows *is* what the platform draws, not a mock-up of it. On the PicoCalc
console it doubles as the font/palette acceptance card (all nine P3 marks, the node
glyphs, braille, the 16 programmed slots); on a desktop terminal it shows the same
content in the regular platform's emoji-and-truecolor dress. Sized to the PicoCalc
floor: every line ≤ 53 cells, the whole card ≤ 24 rows.
"""
from __future__ import annotations
from rich.console import RenderableType
from rich.text import Text
from ..platforms import get_platform
from .packet_viewer import KIND_ICONS, PAYLOAD_ICONS
from .theme import _VT_SLOTS, fold_text, glyph, name_style, snr_style
from .widgets import _NODE_GLYPHS, _recency_style, channel_glyph
#: Demo identities for the name-colour row: (name, key, node type registered for it).
#: Keys are synthetic and only ever registered inside the one-shot specimen process.
_DEMO_NAMES = (("Alice", "a1b2", 1), ("YUL-Poly", "3d63", 2), ("Lounge", "cc10", 3))
def _palette_row() -> Text:
"""The 16 slots as filled blocks, dim bank then bright bank.
On PicoCalc the blocks address the console's real slots (``color(N)``), so the row
shows exactly what ``setvtrgb`` programmed; on the regular platform they carry the
design's RGB values instead — a preview of the same palette in truecolor.
"""
row = Text()
for slot, _, hex_ in _VT_SLOTS:
style = f"color({slot})" if not get_platform().truecolor else hex_
row.append("██", style=style)
return row
def _ages_row() -> Text:
"""The heat scale over its own anchors, each age drawn in its heat colour."""
row = Text()
for label, secs in (("now", 0), ("5m", 300), ("3h", 10800), ("2d", 172800), ("never", None)):
if row.plain:
row.append(" ")
row.append(label, style=_recency_style(secs))
return row
def specimen_lines() -> list[RenderableType]:
"""The specimen card, one renderable per line (print through the themed console)."""
from meshterm.core.nodetypes import register_node_type
platform = get_platform()
lines: list[RenderableType] = []
lines.append(Text.assemble(("Specimen", "accent"), (" · ", "muted"), platform.name))
lines.append(Text())
lines.append(_palette_row())
legend = Text("0 bg · 5 track · 6 faint · 8 muted · 15 you", style="muted")
lines.append(legend)
lines.append(Text())
status = Text("status ")
for mark, style in (("✓", "ok"), ("✗", "err"), ("⚠", "warn"), ("●", "err"), ("○", "muted")):
status.append(mark + " ", style=style)
lines.append(status)
nodes = Text("nodes ")
nodes.append("★ ", style="you")
for node_type, (mark, colour) in _NODE_GLYPHS.items():
nodes.append(mark + " ", style=colour)
nodes.append("○", style="muted")
lines.append(nodes)
classes = Text("classes ")
for icon in KIND_ICONS.values():
classes.append(glyph(icon) + " ", style="brand")
classes.append(" payloads ", style="muted")
for typename in ("REQ", "RESPONSE", "TEXT_MSG", "GRP_TXT", "GRP_DATA", "ANON_REQ"):
classes.append(glyph(PAYLOAD_ICONS[typename]) + " ")
for typename in ("PATH", "TRACE", "MULTIPART", "CONTROL"):
classes.append(glyph(PAYLOAD_ICONS[typename]) + " ")
lines.append(classes)
channels = Text("channels ")
channels.append(channel_glyph("#public", None) + " name-derived ", style="brand")
channels.append(glyph("🌐") + " public ")
channels.append(glyph("🔒") + " private")
lines.append(channels)
concepts = Text("concepts ")
for icon in ("📡", "🕒", "🔄", "💾", "📂", "🔑", "🗑", "✎", "⚡", "⭐", "📤", "📨"):
concepts.append(glyph(icon) + " ")
for icon in ("🔔", "🔕", "📱", "🔗", "🏆", "⌨"):
concepts.append(glyph(icon) + " ")
lines.append(concepts)
lines.append(Text("keys ⌫ ⇧ … ↕ ↔ ↻ ⌖ ❯ ▸"))
lines.append(Text())
for name, key, node_type in _DEMO_NAMES:
register_node_type(key, node_type)
names = Text("names ")
for name, key, _ in _DEMO_NAMES:
names.append(name + " ", style=name_style(name, key))
names.append("me", style="you")
lines.append(names)
lines.append(Text("heard ").append_text(_ages_row()))
snr = Text("snr ")
for reading in (8.0, -2.0, -12.0):
snr.append(f"{reading:+.1f} ", style=snr_style(reading))
lines.append(snr)
# What the render boundary makes of stored text: accents fold on PicoCalc (the raw
# form would be tofu there — the console font has no accented Latin), Cyrillic is
# font-native and passes through on both platforms.
folded = fold_text("café Montréal")
lines.append(Text.assemble("text ", (folded, "brand"), (" · ", "muted"), "привет мир"))
lines.append(Text())
chart = Text("chart ")
chart.append("⡀⡄⡆⣆⣦⣶⣷⣿", style="snr.good")
chart.append("⣶⣤⣀", style="snr.ok")
chart.append("⡀⠄⠂", style="snr.bad")
chart.append(" braille", style="muted")
lines.append(chart)
return lines
+433 -225
View File
@@ -1,14 +1,29 @@
"""Shared Rich theme and console factory for a consistent, modern look."""
"""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 functools import lru_cache
from typing import Optional
from typing import Callable, Optional
from rich.cells import cell_len
from rich.console import Console
from rich.theme import Theme
from ..core.nodetypes import node_type_name
from ..platforms import Platform, on_platform
from .fontset import FONT_CODEPOINTS
MESH_THEME = Theme(
{
"brand": "bold #5eead4",
@@ -72,59 +87,134 @@ MESH_THEME = Theme(
"batt.mid": "bold #fb923c",
"batt.low": "bold #f87171",
"batt.dim": "#475569",
# Type colours (PicoCalc 16-slot palette): keyed by node type when name_colour="type".
"type.node": "bold #5eead4", # client — teal (brand)
"type.repeater": "#a5b4fc", # repeater — indigo
"type.room": "bold #4ade80", # room — green (ok)
"type.sensor": "bold #fbbf24", # sensor — amber (warn)
# Node-type colours, used where the platform colours names by *type* instead of by
# key (PicoCalc — see name_style). Defined in both themes so the names always
# resolve; the regular platform simply never asks for them. Hues follow the map's
# marker language where the 16-slot palette can: repeaters the calm violet, sensors
# the map's orange; rooms take the brand teal and plain client nodes a quiet light
# grey, so infrastructure pops while the crowd stays calm.
"type.node": "#cbd5e1",
"type.repeater": "#a5b4fc",
"type.room": "#5eead4",
"type.sensor": "#fb923c",
# The heard-age heat scale's quantized steps (hot → cold). The regular platform
# interpolates a continuous gradient instead (ui.widgets._recency_style); these
# exist in both themes so the quantized impl's names resolve everywhere.
"heat.hot": "#ffffff",
"heat.warm": "#facc15",
"heat.cool": "#fb923c",
"heat.cold": "#94a3b8",
"heat.never": "#64748b",
}
)
#: The 16-slot palette for PicoCalc (truecolor=False). Same style names as MESH_THEME so
#: the app's internal logic never changes; only the RGB values fit the console's limit.
#: The PicoCalc console's 16 palette slots and the RGB each is programmed to (via
#: ``setvtrgb``; see :func:`vtrgb_lines` and the boot oneshot installed by
#: ``scripts/calculinux-console-font.sh``). This table *is* the palette design:
#:
#: * Slots keep their conventional **hue families** (1/9 red, 2/10 green, 3/11
#: yellow-orange, 4/12 blue-indigo, 7/15 light/white, 8 grey) so other console
#: software — and Rich's own nearest-colour downsampling of any stray truecolor —
#: still lands somewhere sane; only the exact RGBs are retuned to MeshTerm's theme.
#: * Two slots are repurposed outright for the theme's dark slates (5 ``track``,
#: 6 ``faint``): the app never uses magenta or dim cyan, and three greys don't fit
#: in one "bright black" slot.
#: * The kernel VT renders **bold as brightness**: ``bold`` on a 0–7 foreground jumps
#: it to slot N+8. Styles below therefore only combine ``bold`` with a slot whose
#: +8 partner is the same hue family — and never with 5/6, whose partners (13/14)
#: are unrelated colours.
#: * Backgrounds can only address slots 0–7 (SGR 40–47).
_VT_SLOTS: tuple[tuple[int, str, str], ...] = (
(0, "background", "#0f172a"),
(1, "red (hint.err)", "#ef4444"),
(2, "green (hint.ok)", "#22c55e"),
(3, "orange (hint.warn, batt.mid, sensors)", "#f59e0b"),
(4, "indigo (hint.accent, bluetooth bg)", "#6366f1"),
(5, "track slate (was magenta)", "#334155"),
(6, "faint slate (was dim cyan)", "#64748b"),
(7, "light grey (title.muted, client nodes)", "#cbd5e1"),
(8, "muted grey", "#94a3b8"),
(9, "err red", "#f87171"),
(10, "ok green", "#4ade80"),
(11, "warn amber", "#fbbf24"),
(12, "accent indigo", "#818cf8"),
(13, "lavender (title.accent, repeaters)", "#a5b4fc"),
(14, "brand teal", "#5eead4"),
(15, "white (you)", "#ffffff"),
)
def vtrgb_lines() -> str:
"""The ``setvtrgb`` palette file content for :data:`_VT_SLOTS` (three CSV lines).
``setvtrgb`` takes one line of 16 decimal values per colour channel. The deploy
script carries this same content literally (a test keeps the two in sync), so a
fresh SD card gets the palette without running Python.
"""
channels = []
for shift in (16, 8, 0):
values = [(int(hex_.lstrip("#"), 16) >> shift) & 0xFF for _, _, hex_ in _VT_SLOTS]
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`. 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.
MESH_THEME_16 = Theme(
{
"brand": "bold color(201)", # slot 201: teal
"accent": "bold color(63)", # slot 63: indigo
"selected": "reverse bold color(201)",
"err.reverse": "reverse bold color(203)",
"you": "bold color(15)", # slot 15: white
"brand": "bold color(14)",
"accent": "bold color(12)",
"selected": "reverse bold color(14)",
"err.reverse": "reverse bold color(9)",
"you": "bold color(15)",
"device.known": "bold color(15)",
"bluetooth": "bold color(15) on color(4)",
"bluetooth.edge": "color(4)",
"ok": "bold color(34)", # slot 34: green
"warn": "bold color(214)", # slot 214: orange/amber
"err": "bold color(203)", # slot 203: red
"muted": "color(8)", # slot 8: muted grey
"title.accent": "bold color(63)",
"ok": "bold color(10)",
"warn": "bold color(11)",
"err": "bold color(9)",
"muted": "color(8)",
"title.accent": "bold color(13)",
"title.muted": "bold color(7)",
"title.warn": "bold color(214)",
"title.err": "bold color(203)",
"title.ok": "bold color(34)",
"title.brand": "bold color(201)",
"hint.accent": "color(63)",
"hint.muted": "color(8)",
"hint.warn": "color(214)",
"hint.err": "color(203)",
"hint.ok": "color(34)",
"hint.brand": "color(201)",
"faint": "color(8)",
"track": "color(0)",
"snr.good": "bold color(34)",
"snr.ok": "bold color(214)",
"snr.bad": "bold color(203)",
"batt.high": "bold color(34)",
"batt.mid": "bold color(214)",
"batt.low": "bold color(203)",
"batt.dim": "color(0)",
"type.node": "bold color(201)",
"type.repeater": "color(63)",
"type.room": "bold color(34)",
"type.sensor": "bold color(214)",
"title.warn": "bold color(11)",
"title.err": "bold color(9)",
"title.ok": "bold color(10)",
"title.brand": "bold color(14)",
"hint.accent": "color(4)",
"hint.muted": "color(6)",
"hint.warn": "color(3)",
"hint.err": "color(1)",
"hint.ok": "color(2)",
"hint.brand": "color(14)",
"faint": "color(6)",
"track": "color(5)",
"snr.good": "bold color(10)",
"snr.ok": "bold color(11)",
"snr.bad": "bold color(9)",
"batt.high": "bold color(10)",
"batt.mid": "color(3)",
"batt.low": "bold color(9)",
"batt.dim": "color(5)",
"type.node": "color(7)",
"type.repeater": "color(13)",
"type.room": "color(14)",
"type.sensor": "color(3)",
"heat.hot": "bold color(15)",
"heat.warm": "color(11)",
"heat.cool": "color(3)",
"heat.cold": "color(8)",
"heat.never": "color(6)",
}
)
def active_theme() -> Theme:
"""The platform's theme — :data:`MESH_THEME`, or :data:`MESH_THEME_16` on PicoCalc."""
return _ACTIVE_THEME
def make_console() -> Console:
"""Create the application's themed Rich console.
@@ -133,12 +223,10 @@ def make_console() -> Console:
them to UTF-8 where the runtime supports it so output never raises ``UnicodeEncodeError``.
Returns:
A :class:`rich.console.Console` configured with the MeshTerm theme.
A :class:`rich.console.Console` configured with the platform's theme.
"""
import sys
from meshterm.platforms import get_platform
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
@@ -146,8 +234,7 @@ def make_console() -> Console:
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
pass
theme = MESH_THEME if get_platform().truecolor else MESH_THEME_16
return Console(theme=theme)
return Console(theme=_ACTIVE_THEME)
def title_style(border_style: str) -> str:
@@ -226,230 +313,333 @@ def node_style(key: str) -> str:
def name_style(name: str, key: Optional[str] = None) -> str:
"""The stable colour a node or sender name is drawn in — keyed on the node's key.
Dispatches to either hash-derived colouring (REGULAR, ``name_colour="key"``) or
type-based colouring (PICOCALC, ``name_colour="type"``) based on the active platform.
On the regular platform the hue is :func:`node_style`'s hash-derived spectrum, so a
rename keeps the colour and every surface that knows the key agrees. On PicoCalc
(``name_colour="type"``) the 16-slot palette can't afford a hue spectrum on top of
the semantic colours, so the name takes its node's *type* colour instead
(``type.node``/``type.repeater``/``type.room``/``type.sensor``), looked up in the
:mod:`~meshterm.core.nodetypes` registry the reception layer feeds. The dispatch is
bound at platform-switch time — this wrapper stays importable by name everywhere.
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; when given, the hue is
:func:`node_style`'s (on REGULAR) — hash-derived, so a rename keeps the
colour and every surface that knows the key agrees. ``None``/empty marks a
sender whose key we couldn't resolve — drawn ``muted``, because colour is
reserved for keyed identities (callers with only a name resolve it first via
key: Any known prefix of the node's key/hash. ``None``/empty marks a sender whose
key we couldn't resolve — drawn ``muted``, 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 spectrum hue (or ``muted`` for the keyless); the same node always maps to the
same hue, so it keeps its colour across screens and sessions.
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.
"""
from meshterm.platforms import get_platform
return _name_impl(name, key)
if get_platform().name_colour == "type":
return _name_style_by_type(name, key)
def _name_style_by_key(name: str, key: Optional[str] = None) -> str:
"""Regular-platform name colouring: the hash-derived spectrum (``muted`` keyless)."""
if key:
return node_style(key)
return "muted"
def _name_style_by_type(name: str, key: Optional[str] = None) -> str:
"""Type-based node colouring (PicoCalc: 16-slot palette can't afford hash spectrum).
"""PicoCalc name colouring: the node's registered *type* picks a palette colour.
Looks up the node's first-byte prefix in a type registry and returns its colour;
unknown prefixes stay ``muted``. Called by :func:`name_style` when the platform
specifies ``name_colour="type"``.
An unknown type — never heard with one, or no key at all — stays ``muted``: colour
remains reserved for identities we actually know something about. A first-byte
prefix collision types by whichever node registered last (colour is a hint).
"""
if not key:
return "muted"
node_type = node_type_name(key)
return f"type.{node_type}" if node_type else "muted"
# -- 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.
_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 — inbound ask
"📮": "↑", # RESPONSE — outbound answer
"📩": "→", # 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
"🚪": "", # quit — no icon; the word carries it
"🌍": "@", # map/world (globe family)
"🕸": "∟", # mesh walk (route family)
"🚨": "⚠", # watchtower alert
"🛣": "∟", # longest-haul route (route family)
"🧳": "→", # 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)
}
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:
name: The display name (kept for signature compatibility with :func:`name_style`).
key: Any known prefix of the node's key/hash; typed by first-byte prefix lookup.
icon: An emoji, a node/status mark, or any literal character.
Returns:
A ``type.*`` style name or ``muted``.
The character(s) to render for it on the active platform.
"""
if not key:
return "muted"
node_type = _node_type_by_prefix(key)
if node_type is None:
return "muted"
return f"type.{node_type}"
return _glyph_impl(icon)
#: Process-wide registry: first-byte hex prefix → node type name (e.g., "a1" → "repeater").
#: Fed by the reception layer as nodes are first heard, so a unique prefix always types
#: consistently. Unknown prefixes stay out, defaulting to muted.
_TYPE_REGISTRY: dict[str, str] = {}
def _glyph_identity(icon: str) -> str:
"""Regular platform: icons render as themselves."""
return icon
def register_node_type(key: str, node_type: Optional[int]) -> None:
"""Register a node's first-byte prefix to its type for colour lookup.
def _glyph_compact(icon: str) -> str:
"""PicoCalc: icons collapse to their single-cell console-font glyph."""
return _GLYPH_MAP.get(icon, icon)
Called by the reception layer when a new node is first heard, ensuring every known
prefix types consistently. A prefix collision (two nodes with the same first byte but
different types) colours by whichever was registered last — acceptable since colour
is a hint, not a guarantee. On PicoCalc (16-slot palette), this is how
:func:`_name_style_by_type` maps a key to a colour without dedicating spectrum space.
Args:
key: The node's public key or any prefix (extracted to the first byte).
node_type: The node's type from the advert (NODE_TYPE_CHAT, NODE_TYPE_REPEATER, etc.),
or ``None`` if unknown.
# -- 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.
_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.
"️": "", "‍": "", "​": "",
}
#: 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: Optional[dict[int, str]] = 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.
"""
from meshterm.core.models import (
NODE_TYPE_CHAT, NODE_TYPE_REPEATER, NODE_TYPE_ROOM, NODE_TYPE_SENSOR,
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:
code = 40 + slot
else:
code = 30 + slot if slot < 8 else 90 + slot - 8
cached = _SLOT_CACHE[key] = f"\x1b[{code}m"
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,
)
if not key or node_type is None:
return
raw = key.lower().removeprefix("0x")
try:
prefix = raw[:2]
except (IndexError, ValueError):
return
type_name = {
NODE_TYPE_CHAT: "node",
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_SENSOR: "sensor",
}.get(node_type)
if type_name:
_TYPE_REGISTRY[prefix] = type_name
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
def _node_type_by_prefix(key: str) -> Optional[str]:
"""Look up a node's type by its first-byte prefix."""
if not key:
return None
raw = key.lower().removeprefix("0x")
try:
prefix = raw[:2]
except (IndexError, ValueError):
return None
return _TYPE_REGISTRY.get(prefix)
@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
)
@lru_cache(maxsize=1024)
def fold_text(text: str) -> str:
"""NFKD-fold accents and replace emoji on PicoCalc (storage untouched).
"""The render-boundary text filter for the active platform.
On REGULAR, returns text unchanged (emoji and accents are both drawn).
On PICOCALC, strips accents and replaces emoji with single-glyph placeholders so
the text fits the 512-glyph font contract. Called at the render boundary for names,
message bodies, and any user-facing text that might carry non-ASCII.
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, or the input unchanged on REGULAR.
The folded text; cell widths are preserved (wide emoji become glyph + pad).
"""
from meshterm.platforms import get_platform
return _fold_impl(text)
if get_platform().ascii_fold:
import unicodedata
# NFKD fold: decompose accents into separate combining marks, then drop them
folded = unicodedata.normalize("NFKD", text)
# Strip combining marks (category Mn = Mark, nonspacing)
folded = "".join(c for c in folded if unicodedata.category(c) != "Mn")
# Replace common emoji and brackets with ASCII equivalents
folded = _EMOJI_FOLD_TABLE.get(folded, folded)
return folded
def _no_fold(text: str) -> str:
"""Regular platform: text renders as stored."""
return text
#: Simple emoji→placeholder table for text that must fit the 512-glyph font.
_EMOJI_FOLD_TABLE = {
"🔐": "[key]",
"🔑": "[key]",
"📡": "[radio]",
"💬": "[msg]",
"✅": "[ok]",
"✗": "[err]",
"⚠": "[warn]",
"●": "[node]",
"▲": "[rep]",
"■": "[room]",
"◉": "[sens]",
}
#: The compact glyph table: emoji → single BMP character for PicoCalc's 512-glyph font.
#: Every icon used in the UI (KIND_ICONS, PAYLOAD_ICONS, concept glyphs) maps to a single
#: narrow character. The node-type glyphs (★●▲■◉○) pass through unchanged (they're in
#: the font). On REGULAR (emoji=True), glyph() is identity; on PICOCALC (emoji=False),
#: it looks up the emoji and returns the mapped glyph or the original if not mapped.
_GLYPH_MAP = {
"📢": "▶", # advert / kind icon
"📊": "╳", # telemetry
"📦": "□", # packet
"💬": "◇", # message
"✅": "✓", # ack / ok
"❔": "?", # unknown / fallback
"📥": "↓", # REQ / request
"📮": "◈", # RESPONSE
"📩": "◊", # TEXT_MSG
"📻": "~", # GRP_TXT / channel
"💽": "◐", # GRP_DATA
"🎭": "♫", # ANON_REQ
"🧭": "↗", # PATH
"🎯": "✕", # TRACE
"🧩": "◬", # MULTIPART
"🧰": "⚙", # CONTROL
"🕒": "⏰", # clock / time
"🔄": "◉", # reboot / cycle (but ◉ is sensor, use ○)
"💾": "⛐", # backup / save
"📂": "◇", # restore / open
"🔑": "◆", # key / credential
"🔐": "◆", # auth / secret
"🗑": "✕", # delete / trash
"✎": "╳", # edit / compose
"⚙": "⚙", # parameter / gear
"#": "#", # count (ASCII)
"▶": "▶", # run / play
"⚡": "◆", # explore / probe
"★": "★", # best / winner (kept)
"⭐": "★", # watch / highlight → ★
"📤": "↑", # send now
"📨": "◈", # courier / queue
"💬": "◊", # chat (dup, overwrites above)
"🔔": "◐", # notify
"🔕": "◑", # mute / notifications off
"📱": "□", # QR
"🔗": "○", # link
"↻": "⟲", # re-read / refresh
"↕": "⟷", # reorder
"⇄": "⟷", # reverse (flip path)
"🏆": "◆", # trophy / record
"⌨": "↤", # command line / keyboard
"🚪": "╬", # quit / door
# Node type glyphs pass through unchanged (they're in the font)
"★": "★", # "you" marker
"●": "●", # node / client
"▲": "▲", # repeater
"■": "■", # room
"◉": "◉", # sensor
"○": "○", # unknown
}
def glyph(emoji_or_char: str) -> str:
"""Return a single-cell glyph matching the emoji or character.
On REGULAR, returns the emoji unchanged (emoji and rich rendering work). On PICOCALC,
looks up the emoji in the compact glyph table and returns a single-cell character that
fits the 512-glyph PSF font. Node-type glyphs (★●▲■◉○) always pass through unchanged.
Args:
emoji_or_char: An emoji, a node glyph, or a literal character.
Returns:
The input unchanged on REGULAR, or the mapped glyph on PICOCALC.
"""
from meshterm.platforms import get_platform
if get_platform().emoji:
return emoji_or_char
return _GLYPH_MAP.get(emoji_or_char, emoji_or_char)
def snr_style(snr: float | None) -> str:
"""Return a theme style name describing an SNR value's quality.
@@ -466,3 +656,21 @@ def snr_style(snr: float | None) -> str:
if snr >= -5:
return "snr.ok"
return "snr.bad"
# -- platform binding ------------------------------------------------------------------
_ACTIVE_THEME: Theme = MESH_THEME
_name_impl: Callable[[str, Optional[str]], str] = _name_style_by_key
_glyph_impl: Callable[[str], str] = _glyph_identity
_fold_impl: Callable[[str], str] = _no_fold
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the theme's platform-dependent choices (runs now and on every switch)."""
global _ACTIVE_THEME, _name_impl, _glyph_impl, _fold_impl
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
_name_impl = _name_style_by_key if platform.name_colour == "key" else _name_style_by_type
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
_fold_impl = _fold_to_font if platform.ascii_fold else _no_fold
+26 -5
View File
@@ -17,13 +17,30 @@ from rich.cells import cell_len
from rich.console import Console, RenderableType
from rich.text import Text
from ..theme import MESH_THEME
from ...platforms import Platform, on_platform
from ..theme import active_theme, fold_text
#: Cache one headless render console per width. Consoles are cheap but repaint happens on
#: every keystroke and on the live-monitor timer, so caching avoids needless churn. Keyed
#: by width because a console's width is fixed at construction.
#: by width because a console's width is fixed at construction; emptied whenever the
#: platform switches, since the theme and colour system are baked in at construction.
_CONSOLES: dict[int, Console] = {}
#: The colour depth the rasterizer emits at — bound per platform. ``"standard"`` on the
#: 16-slot console: the theme's ``color(N)`` styles pass through as exact slot indices,
#: and any stray truecolor (a node hue, a map feature) is downsampled by Rich to the
#: nearest conventional slot — which the palette deliberately keeps hue-aligned (see
#: ``theme._VT_SLOTS``), so even strays land in their own colour family.
_COLOR_SYSTEM = "truecolor"
@on_platform
def _bind(platform: Platform) -> None:
"""Re-bind the rasterizer's colour depth and drop stale consoles on a switch."""
global _COLOR_SYSTEM
_COLOR_SYSTEM = "truecolor" if platform.truecolor else "standard"
_CONSOLES.clear()
def _console(width: int) -> Console:
"""Return a themed, headless console that emits ANSI at the given width.
@@ -37,11 +54,11 @@ def _console(width: int) -> Console:
console = _CONSOLES.get(width)
if console is None:
console = Console(
theme=MESH_THEME,
theme=active_theme(),
width=width,
file=StringIO(),
force_terminal=True,
color_system="truecolor",
color_system=_COLOR_SYSTEM,
# This console is a rasterizer, not terminal output: its ANSI is re-parsed
# by prompt_toolkit, and the TUI's hues are semantics (node identity,
# recency heat), not decoration. So a NO_COLOR environment must not strip
@@ -74,7 +91,11 @@ def render_to_ansi(renderable: RenderableType, width: int, *, no_wrap: bool = Fa
console.print(renderable, end="", no_wrap=True, overflow="ellipsis", crop=True)
else:
console.print(renderable, end="")
return capture.get()
# THE render boundary: every visible string in the TUI leaves through here, so this
# one call is what makes the PicoCalc glyph contract hold app-wide (identity on the
# regular platform). Widths were measured on the pre-fold text; the fold preserves
# cell counts (wide emoji become glyph + pad), so the layout above survives it.
return fold_text(capture.get())
def render_lines(renderable: RenderableType, width: int, *, no_wrap: bool = False) -> list[str]:
+73 -23
View File
@@ -23,6 +23,7 @@ from rich.text import Text
from .. import __version__
from ..core.channels import is_name_derived, is_public_channel, is_public_name
from ..platforms import Platform, on_platform
from ..core.models import (
LOCAL_DEVICE_LABEL,
NODE_TYPE_CHAT,
@@ -37,11 +38,21 @@ from ..core.models import (
TxOptResult,
utcnow,
)
from .map_render import _NODE, _REPEATER, _SELF, _UNKNOWN
from .mapcanvas import RGB, parse_hex
from .pathgraph import DST_NODE, GlyphOf, LabelOf, LabelRgbOf, SRC_NODE
from .marks import (
DST_NODE,
NODE_MARK,
REPEATER_MARK,
RGB,
SELF_MARK,
SRC_NODE,
UNKNOWN_MARK,
GlyphOf,
LabelOf,
LabelRgbOf,
parse_hex,
)
from .pathline import PathLine, path_line
from .theme import name_style, node_style, snr_style
from .theme import glyph, name_style, node_style, snr_style
if TYPE_CHECKING:
from ..core.discovery import DiscoveredDevice
@@ -58,9 +69,11 @@ def channel_glyph(name: str, secret: Optional[bytes]) -> str:
"""The one-character openness marker for a channel, shared across channel-facing screens.
``#`` marks a name-derived (``#``-style) channel, ``🌐`` a fixed-key well-known public
channel (e.g. the firmware default ``Public``), and ``🔒`` a private one. Every glyph is a
single double-width cell, so callers can prefix rows with ``"{glyph} "`` without disturbing
column alignment. When the secret is unknown, the name alone is used to guess.
channel (e.g. the firmware default ``Public``), and ``🔒`` a private one — mapped
through :func:`~meshterm.ui.theme.glyph`, so PicoCalc draws the compact ``# @ ⚿``
instead. Within a platform every glyph is one width (double on regular, single on
PicoCalc), so callers can prefix rows with ``"{glyph} "`` without disturbing column
alignment. When the secret is unknown, the name alone is used to guess.
Args:
name: The channel name.
@@ -70,12 +83,12 @@ def channel_glyph(name: str, secret: Optional[bytes]) -> str:
A single-character glyph.
"""
if secret is None:
return "#" if is_public_name(name) else "🔒"
return glyph("#") if is_public_name(name) else glyph("🔒")
if is_name_derived(name, secret):
return "#"
return glyph("#")
if is_public_channel(name, secret):
return "🌐"
return "🔒"
return glyph("🌐")
return glyph("🔒")
def _identity(label: Optional[str]) -> Optional[str]:
"""Default node resolver: leave labels untouched."""
@@ -558,15 +571,15 @@ def route_graph_style(
def glyph_of(node: str) -> tuple[str, str]:
"""Us a star, a typed relay its map marker, a named node a dot, else a ring."""
if node == DST_NODE:
return _SELF
return SELF_MARK
if node == SRC_NODE:
return _SELF if src_is_self else (_NODE if source else _UNKNOWN)
return SELF_MARK if src_is_self else (NODE_MARK if source else UNKNOWN_MARK)
if type_of is not None:
node_type = type_of(node)
if node_type is not None:
return _NODE_GLYPHS.get(node_type, _DEFAULT_GLYPH)
named = resolve(node)
return _NODE if named and named != node else _UNKNOWN
return NODE_MARK if named and named != node else UNKNOWN_MARK
def label_of(node: str) -> Optional[str]:
"""Endpoints by name, relays by their first hash byte."""
@@ -584,7 +597,7 @@ def route_graph_style(
if src_is_self:
return _SELF_RGB
if not source:
return parse_hex(_UNKNOWN[1])
return parse_hex(UNKNOWN_MARK[1])
return _name_rgb(source, key_of(source) if key_of else None)
named = resolve(node)
if named and named != node:
@@ -760,12 +773,12 @@ def stats_panel(
_ROOM_COLOR = "#ffffff"
_SENSOR_COLOR = "#fb923c"
_NODE_GLYPHS: dict[int, tuple[str, str]] = {
NODE_TYPE_REPEATER: (_REPEATER[0], _REPEATER[1]),
NODE_TYPE_REPEATER: (REPEATER_MARK[0], REPEATER_MARK[1]),
NODE_TYPE_ROOM: ("■", _ROOM_COLOR),
NODE_TYPE_SENSOR: ("◉", _SENSOR_COLOR),
NODE_TYPE_CHAT: (_NODE[0], _NODE[1]),
NODE_TYPE_CHAT: (NODE_MARK[0], NODE_MARK[1]),
}
_DEFAULT_GLYPH: tuple[str, str] = (_NODE[0], _NODE[1])
_DEFAULT_GLYPH: tuple[str, str] = (NODE_MARK[0], NODE_MARK[1])
def node_marker(node_type: Optional[int]) -> tuple[str, RGB]:
@@ -783,7 +796,7 @@ def node_marker(node_type: Optional[int]) -> tuple[str, RGB]:
def self_marker() -> tuple[str, RGB]:
"""Our own node's map marker — the yellow ``★`` — as a ``(glyph, rgb)`` pair."""
return _SELF[0], parse_hex(_SELF[1])
return SELF_MARK[0], parse_hex(SELF_MARK[1])
# A heat-map gradient for a node's heard age, hottest (most recently heard) to coldest: white
# → yellow → orange → red → grey. Each stop pairs an age anchor (log10 of seconds since heard)
@@ -838,8 +851,17 @@ def format_ago(secs: Optional[float]) -> str:
def _recency_style(secs: Optional[float]) -> str:
"""The heat-map colour for a node's heard age of ``secs`` (hotter = more recent).
Interpolates the RGB channels between the two :data:`_HEAT_STOPS` bracketing ``secs`` (in
log-age space), clamping to white below the first stop and cold slate above the last.
Dispatches to the platform-bound implementation: the regular platform's continuous
gradient, or PicoCalc's quantized steps (a 16-slot palette has no room to glide).
"""
return _recency_impl(secs)
def _recency_gradient(secs: Optional[float]) -> str:
"""The regular platform's heat: continuous interpolation over :data:`_HEAT_STOPS`.
Interpolates the RGB channels between the two stops bracketing ``secs`` (in log-age
space), clamping to white below the first stop and cold slate above the last.
"""
if secs is None:
return _RECENCY_NEVER
@@ -857,6 +879,34 @@ def _recency_style(secs: Optional[float]) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def _recency_quantized(secs: Optional[float]) -> str:
"""PicoCalc's heat: the gradient collapsed onto the theme's four ``heat.*`` steps.
White within the hour, amber within six, orange within the day, muted beyond —
the gradient's own anchors, minus its red step (on a 16-slot console red is
reserved for errors). ``heat.never`` keeps never-heard a shade below ever-heard.
"""
if secs is None:
return "heat.never"
if secs <= 3600:
return "heat.hot"
if secs <= 21600:
return "heat.warm"
if secs <= 86400:
return "heat.cool"
return "heat.cold"
_recency_impl: Callable[[Optional[float]], str] = _recency_gradient
@on_platform
def _bind_heat(platform: Platform) -> None:
"""Pick the heat implementation for the platform (runs now and on every switch)."""
global _recency_impl
_recency_impl = _recency_gradient if platform.truecolor else _recency_quantized
def _key_id(value: str) -> str:
"""Normalise a key/prefix to the lowercased 12-hex id used to match heard nodes."""
return value.lower().removeprefix("0x")[:12]
@@ -977,7 +1027,7 @@ def node_type_legend(indent: str = "") -> Text:
indent: Leading spaces to sit the legend under a table or graph body.
"""
legend = Text(indent)
legend.append(_SELF[0], style=_SELF[1])
legend.append(SELF_MARK[0], style=SELF_MARK[1])
legend.append(" you", style="muted")
for node_type in (NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR):
glyph, color = _NODE_GLYPHS[node_type]
@@ -1137,7 +1187,7 @@ def contacts_table(
unknown = Text("?", style="muted")
table.add_row(
Text(_SELF[0], style=_SELF[1]),
Text(SELF_MARK[0], style=SELF_MARK[1]),
# Our own name is the app-wide pure-white "you" style, never a palette hue.
Text.assemble((self_name, "you"), (" (you)", "muted")),
Text("—", style="faint"),
+119 -8
View File
@@ -109,18 +109,61 @@ MARKS = {
0x25B8: art([ # SMALL RIGHT-POINTING TRIANGLE -- grabbed/reorder cursor
"......", "......", "......", ".#....", ".##...", ".###..",
".###..", ".##...", ".#....", "......", "......", "......"]),
# --- P3 marks: the compact icon language (see meshterm/ui/theme._GLYPH_MAP and
# --- meshterm/ui/fontset.py, which must mirror every codepoint drawn here) --------
0x2026: art([ # HORIZONTAL ELLIPSIS -- "opens further prompts", truncation
"......", "......", "......", "......", "......", "......",
"......", "......", "#.#.#.", "#.#.#.", "......", "......"]),
0x26A0: art([ # WARNING SIGN -- the warn status mark
"......", "..##..", ".#..#.", ".#..#.", "#....#", "#.##.#",
"#.##.#", "#....#", "#.##.#", "######", "......", "......"]),
0x232B: art([ # ERASE TO THE LEFT -- the backspace key in footer hints
"......", "......", "......", "..####", ".#...#", ".##.##",
"#..#.#", ".##.##", ".#...#", "..####", "......", "......"]),
0x21E7: art([ # UPWARDS WHITE ARROW -- the shift key in footer hints
"......", "......", "..##..", ".#..#.", "#....#", "##..##",
".#..#.", ".#..#.", ".#..#.", ".####.", "......", "......"]),
0x2699: art([ # GEAR -- parameter/config
"......", "......", "..##..", ".####.", "######", "##..##",
"##..##", "######", ".####.", "..##..", "......", "......"]),
0x21BB: art([ # CLOCKWISE OPEN CIRCLE ARROW -- re-read/refresh
"......", "....#.", ".#####", "#...#.", "#.....", "#.....",
"#.....", "#....#", ".####.", "......", "......", "......"]),
0x25F7: art([ # WHITE CIRCLE UPPER RIGHT QUADRANT -- the clock face (sync/time)
"......", "......", ".####.", "#..#.#", "#..#.#", "#..###",
"#....#", "#....#", ".####.", "......", "......", "......"]),
0x2316: art([ # POSITION INDICATOR -- the crosshair (trace/map position)
"......", "..##..", "..##..", "......", "......", "#.##.#",
"#.##.#", "......", "......", "..##..", "..##..", "......"]),
0x26BF: art([ # SQUARED KEY (drawn as a padlock) -- private channel
"......", "......", ".####.", ".#..#.", "######", "######",
"##..##", "##..##", "######", "######", "......", "......"]),
}
# Donor codepoints whose glyph slots we may repurpose (CP437 junk MeshTerm never uses).
DONORS = [0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x25D8, 0x25D9,
0x266A, 0x266B, 0x263C, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8]
# Donor codepoints whose glyph slots we may repurpose (glyphs MeshTerm never draws).
# Order matters: MARKS consume donors front to back, one each, so the original nine marks
# keep their original slots and the P3 nine take the next batch. Choosing a donor means
# knowing its slot's FULL codepoint list: ter-u12n aliases lookalikes onto one glyph, and
# repointing the slot erases every codepoint it carried -- donating pi (03C0) was tried
# and took Cyrillic pe (043F) with it, and the beamed notes 266B/266C share one slot (so
# 266B's donation already erases both; never list 266C as a donor of its own -- it would
# come up empty and abort the build). After the consumed batch: preferred future donors
# (the mixed double/single box set nothing draws), then the last-resort tail -- those are
# MAP TARGETS in meshterm/ui/theme (advert ☼, message ¶, data §, packet ▬, control ↨);
# consuming one breaks the compact icon language.
DONORS = [0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x25D8, 0x25D9, 0x266A,
0x266B, 0x203C, 0x2640, 0x2642, 0x2320, 0x2321, 0x00F7, 0x2552, 0x2558,
0x2559, 0x255B, 0x255E, 0x255F, 0x2561, 0x2567, 0x2568, 0x256A,
0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8]
# Rounded panel corners aliased onto the existing square corners (no new bitmap).
# Rounded panel corners aliased onto the existing square corners (no new bitmap), and the
# midline ellipsis onto the P3 baseline one (pathline's gap marker draws as the same dots).
ALIASES = {
0x256D: 0x250C, # rounded top-left -> square top-left
0x256E: 0x2510, # rounded top-right -> square top-right
0x256F: 0x2518, # rounded bottom-right -> square bottom-right
0x2570: 0x2514, # rounded bottom-left -> square bottom-left
0x22EF: 0x2026, # midline ellipsis -> ellipsis mark
}
base = gzip.open(BASE, "rb").read()
@@ -146,13 +189,19 @@ def slot_of(cp):
# Draw each mark/cursor into a donor slot: overwrite the bitmap, repoint the codepoint.
# One donor per mark, and a donor that fails to resolve aborts the build: a miss means
# the DONORS list's model of the base font is wrong, and silently consuming the *next*
# donor is how a keeper glyph gets eaten (it cost us ☼ once).
donors = list(DONORS)
for cp, bitmap in MARKS.items():
slot = None
while donors and slot is None:
slot = slot_of(donors.pop(0))
if slot is None:
if not donors:
raise SystemExit("out of donor slots for U+%04X" % cp)
donor = donors.pop(0)
slot = slot_of(donor)
if slot is None:
raise SystemExit(
"donor U+%04X (for mark U+%04X) not found in the base font -- "
"fix DONORS instead of skipping" % (donor, cp))
glyphs[slot] = bytearray(bitmap)
entries[slot] = chr(cp).encode("utf-8")
@@ -163,6 +212,18 @@ for new_cp, existing_cp in ALIASES.items():
raise SystemExit("no glyph for U+%04X to alias U+%04X onto" % (existing_cp, new_cp))
entries[slot] += chr(new_cp).encode("utf-8")
# Verify nothing the app depends on was eaten as donation collateral (a donor slot can
# carry codepoints beyond the one that earned it a place on the list). The keepers: every
# mark and alias just placed, the compact icon language's base-font targets, and Cyrillic
# pe (043F) as the canary for the pi/pe shared slot that bit once.
KEEP = list(MARKS) + list(ALIASES) + [
0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, # ☼ ¶ § ▬ ↨ -- theme map targets
0x043F, 0x03C0, # п and π -- shared-slot canaries
]
for cp in KEEP:
if slot_of(cp) is None:
raise SystemExit("build ate U+%04X -- a donor slot carried it; fix DONORS" % cp)
header = struct.pack("<IIIIIIII", PSF2_MAGIC, 0, 32, 1, 512, charsize, h, w)
out = header + b"".join(bytes(g) for g in glyphs) + b"".join(e + b"\xff" for e in entries)
gzip.open(OUT, "wb").write(out)
@@ -183,4 +244,54 @@ else
echo "FONT=$FONT_NAME" >> "$VCONSOLE"
fi
echo "persisted FONT=$FONT_NAME in $VCONSOLE (loads on every boot)"
# --- palette: program the 16 console slots to MeshTerm's colours ------------------------
# The panel's VT layer is 16 fg / 8 bg palette slots -- no per-cell RGB -- so MeshTerm's
# 16-slot theme (meshterm/ui/theme.MESH_THEME_16) is designed against the slot meanings
# below, and this section programs the slots' actual RGB values. The palette file matches
# theme.vtrgb_lines() exactly (a test keeps them in sync); the boot oneshot re-applies it
# every start (same pattern as wifi-kick). setvtrgb does the work where kbd ships it; the
# fallback printf speaks the kernel VT's own OSC palette sequences, so nothing is required.
VTRGB=/etc/vtrgb
APPLIER=/usr/local/sbin/meshterm-vtrgb
UNIT=/etc/systemd/system/meshterm-vtrgb.service
echo "writing $VTRGB ..."
cat > "$VTRGB" <<'EOF'
15,239,34,245,99,51,100,203,148,248,74,251,129,165,94,255
23,68,197,158,102,65,116,213,163,113,222,191,140,180,234,255
42,68,94,11,241,85,139,225,184,113,128,36,248,252,212,255
EOF
mkdir -p "$(dirname "$APPLIER")"
cat > "$APPLIER" <<'EOF'
#!/bin/sh
# Apply the MeshTerm console palette (slots documented in meshterm/ui/theme._VT_SLOTS).
if command -v setvtrgb >/dev/null 2>&1; then
exec setvtrgb /etc/vtrgb
fi
# No kbd setvtrgb: the kernel VT accepts its own OSC palette sequences (ESC ] P n rrggbb).
TTY=${1:-/dev/tty1}
printf '\033]P00f172a\033]P1ef4444\033]P222c55e\033]P3f59e0b\033]P46366f1\033]P5334155\033]P664748b\033]P7cbd5e1\033]P894a3b8\033]P9f87171\033]Pa4ade80\033]Pbfbbf24\033]Pc818cf8\033]Pda5b4fc\033]Pe5eead4\033]Pfffffff' > "$TTY"
EOF
chmod +x "$APPLIER"
cat > "$UNIT" <<EOF
[Unit]
Description=MeshTerm console palette (16-slot vtrgb)
After=systemd-vconsole-setup.service
[Service]
Type=oneshot
ExecStart=$APPLIER
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable meshterm-vtrgb.service >/dev/null 2>&1 || true
"$APPLIER" || echo "warning: could not apply the palette live (boot service will)"
echo "palette installed ($VTRGB + boot oneshot)"
echo "done -- launch 'meshterm' to see braille charts, node glyphs, framed panels, and the > cursor."
+25 -5
View File
@@ -46,6 +46,7 @@ from meshterm.core.models import (
from meshterm.core.watch_store import WatchStore
from meshterm.persistence.repository import DiscoveredPath
from meshterm.platforms import PICOCALC, REGULAR, Platform, set_platform
from meshterm.ui.fontset import FONT_CODEPOINTS
from meshterm.services.courier import CourierService
from meshterm.services.message_paths import Arrival
from meshterm.services.monitor_service import ACTIVITY_BUCKETS
@@ -496,9 +497,28 @@ def test_gallery_screen_fits_its_platform(
assert lines[-1].strip() == "Back", f"{entry.name}: exit row missing, got {lines[-1]!r}"
assert lines[-2].strip() == "", f"{entry.name}: no blank separator before Back"
# P3 assertions: no-truecolor check (glyph-whitelist is handled per-screen as P3 work)
# P3 assertions, on the *rendered ANSI* (the theme/fold contracts, not the config):
# picocalc output may carry no truecolor or 256-colour SGR (the console has 16 slots,
# addressed as plain 30-37/90-97/40-47 codes), and no character outside the 512-glyph
# console font. Together these are the parity gate that catches a stray emoji or hex
# colour the moment a screen grows one, instead of as tofu found on-device.
if platform.name == "picocalc":
# No truecolor SGR: platform.truecolor must be False on PICOCALC
assert not platform.truecolor, (
f"{entry.name} on picocalc requires truecolor=False, got {platform.truecolor}"
)
for where, ansi_lines in (
("render_body", screen.render_body(cols)),
("compose_base", composed.split("\n")),
):
for i, line in enumerate(ansi_lines):
assert "[38;2;" not in line and "[48;2;" not in line, (
f"{entry.name} {where} line {i} emits truecolor SGR: {line!r}"
)
assert "[38;5;" not in line and "[48;5;" not in line, (
f"{entry.name} {where} line {i} emits 256-colour SGR: {line!r}"
)
strays = {
ch for ch in line
if ord(ch) >= 0x20 and ord(ch) not in FONT_CODEPOINTS
}
assert not strays, (
f"{entry.name} {where} line {i} has characters outside the console "
f"font: {sorted(strays)!r} in {line!r}"
)
+19
View File
@@ -109,6 +109,25 @@ def test_map_default_fraction_is_the_same_constant_from_either_module() -> None:
assert from_geo == from_screen
def test_widgets_does_not_drag_the_spatial_modules() -> None:
"""``ui.widgets`` reads its mark constants from ``ui.marks``, not the heavy hosts.
The P2 sweep measured ~60 ms of Lyra import time in ``widgets`` pulling
``map_render``/``mapcanvas``/``pathgraph`` for constants alone; the constants moved
to the dependency-free ``ui.marks`` in P3. Same subprocess trick as the startup
guard: the claim is about a fresh interpreter's import graph.
"""
heavy = ("meshterm.ui.map_render", "meshterm.ui.mapcanvas", "meshterm.ui.pathgraph")
probe = (
"import sys, meshterm.ui.widgets;"
f"print(','.join(sorted(m for m in sys.modules if m in {heavy!r})))"
)
out = subprocess.run(
[sys.executable, "-c", probe], capture_output=True, text=True, check=True
).stdout.strip()
assert out == "", f"widgets should not drag these anymore: {out}"
# --- per-frame work the platform can skip -------------------------------------------
+267
View File
@@ -0,0 +1,267 @@
"""The PicoCalc visual-language contracts: palette, glyph map, fold, and bindings.
P3's deliverables are promises about *output* — only 16 palette slots, only glyphs the
console font holds, layout that survives the translation — enforced screen-by-screen by
the gallery. These tests pin the machinery itself: the two themes stay name-compatible,
the 16-slot theme obeys the VT's bold-brightness and background rules, the deploy
script's palette matches the canonical table, the font build script and the frozen
inventory move together, and the platform bindings actually rebind.
"""
from __future__ import annotations
import re
from pathlib import Path
from rich.cells import cell_len
from rich.default_styles import DEFAULT_STYLES
from meshterm.core import nodetypes
from meshterm.core.nodetypes import node_type_name, register_node_type
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui import theme
from meshterm.ui.fontset import FONT_CODEPOINTS
from meshterm.ui.theme import MESH_THEME, MESH_THEME_16, fold_text, glyph, name_style
_FONT_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "calculinux-console-font.sh"
# -- the two themes -------------------------------------------------------------------
def test_both_themes_define_exactly_the_same_style_names() -> None:
"""A style name a screen asks for must resolve on either platform."""
assert set(MESH_THEME.styles) == set(MESH_THEME_16.styles)
def _our_styles(theme_obj):
"""The style entries we declared (Rich merges its own DEFAULT_STYLES into a Theme)."""
return {
name: style
for name, style in theme_obj.styles.items()
if name not in DEFAULT_STYLES
}
def test_theme16_uses_only_the_sixteen_slots() -> None:
"""Every 16-slot style is built from ``color(0..15)`` — never hex, never the cube."""
for name, style in _our_styles(MESH_THEME_16).items():
for color in (style.color, style.bgcolor):
if color is None:
continue
assert color.number is not None and 0 <= color.number <= 15, (
f"{name!r} uses {color!r}, outside the 16 VT slots"
)
def test_theme16_bold_never_jumps_a_dim_slot_to_an_unrelated_bright_one() -> None:
"""The VT draws bold as brightness: bold on slots 0-7 lands on N+8.
Slots 5 and 6 are the repurposed dark slates whose +8 partners (lavender, teal) are
unrelated colours, so no style may combine bold with them.
"""
for name, style in _our_styles(MESH_THEME_16).items():
if style.bold and style.color is not None and style.color.number in (5, 6):
raise AssertionError(
f"{name!r} is bold on slot {style.color.number}; the VT would draw "
f"slot {style.color.number + 8} instead"
)
def test_theme16_backgrounds_stay_in_the_dim_bank() -> None:
"""The VT has no bright backgrounds — SGR 40-47 only."""
for name, style in _our_styles(MESH_THEME_16).items():
if style.bgcolor is not None:
assert style.bgcolor.number <= 7, (
f"{name!r} background on slot {style.bgcolor.number}; the VT stops at 7"
)
def test_deploy_script_carries_the_canonical_palette() -> None:
"""`/etc/vtrgb` in the deploy script matches ``theme.vtrgb_lines()`` byte for byte,
and the no-kbd OSC fallback spells the same sixteen RGB values."""
script = _FONT_SCRIPT.read_text(encoding="utf-8")
assert theme.vtrgb_lines() in script, "scripts/calculinux-console-font.sh vtrgb drifted"
for index, (_, _, hex_) in enumerate(theme._VT_SLOTS):
sequence = f"\\033]P{index:x}{hex_.lstrip('#').lower()}"
assert sequence in script, f"OSC fallback missing slot {index}: {sequence}"
# -- the compact glyph map ------------------------------------------------------------
def test_every_glyph_map_target_is_in_the_console_font() -> None:
"""The map may only hand out characters the 512-glyph font can draw."""
for emoji, compact in theme._GLYPH_MAP.items():
for ch in compact:
assert ord(ch) in FONT_CODEPOINTS, (
f"{emoji!r} maps to {compact!r}; {ch!r} is not in the console font"
)
def test_glyph_map_targets_never_widen_their_icon() -> None:
"""A compact form at most matches its emoji's measured width (the fold pads the rest)."""
for emoji, compact in theme._GLYPH_MAP.items():
assert cell_len(compact) <= cell_len(emoji), (
f"{emoji!r} ({cell_len(emoji)} cells) maps to wider {compact!r}"
)
def test_glyph_is_identity_on_regular_and_compact_on_picocalc() -> None:
set_platform(REGULAR)
assert glyph("📡") == "📡"
set_platform(PICOCALC)
assert glyph("📡") == "☼"
assert glyph("★") == "★" # node/status marks pass through — they are font-native
# -- the render-boundary fold ---------------------------------------------------------
def test_fold_is_identity_on_regular_even_after_picocalc_used_it() -> None:
"""The bound impls may not leak cached picocalc folds into a regular render."""
set_platform(PICOCALC)
assert fold_text("café") == "cafe"
set_platform(REGULAR)
assert fold_text("café") == "café"
def test_fold_strips_accents_and_preserves_cell_widths() -> None:
set_platform(PICOCALC)
for text in ("café ⚠", "Ĉu vi paroläs", "📡 Advert", "🗑 Clear", "…", "npo Waymarker 🇨🇦"):
folded = fold_text(text)
assert cell_len(folded) == cell_len(text), (text, folded)
for ch in folded:
assert ord(ch) in FONT_CODEPOINTS or ord(ch) < 0x20, (text, folded, ch)
def test_fold_replaces_the_unmappable_at_width() -> None:
"""CJK and unmapped emoji leave as ``?`` at their own width — never as tofu."""
set_platform(PICOCALC)
assert fold_text("你好") == "????"
assert cell_len(fold_text("🦕")) == cell_len("🦕")
def test_fold_keeps_ansi_sequences_intact() -> None:
set_platform(PICOCALC)
line = "\x1b[1;93mwarn é\x1b[0m\nnext"
folded = fold_text(line)
assert folded == "\x1b[1;93mwarn e\x1b[0m\nnext"
def test_fold_quantizes_embedded_truecolor_to_the_slots() -> None:
"""Canvas-emitted truecolor (the braille rasters) lands on the nearest palette slot."""
set_platform(PICOCALC)
folded = fold_text("\x1b[38;2;148;163;184m○\x1b[0m")
assert "38;2;" not in folded
assert "\x1b[90m" in folded # #94a3b8 is exactly slot 8 (muted)
background = fold_text("\x1b[48;2;94;234;212mX\x1b[0m")
assert "48;2;" not in background
assert re.search(r"\x1b\[4[0-7]m", background), background # bg clamps to the dim bank
eight_bit = fold_text("\x1b[38;5;201mX\x1b[0m")
assert "38;5;" not in eight_bit
def test_fold_drops_zero_width_machinery() -> None:
set_platform(PICOCALC)
assert fold_text("🕸️") == fold_text("🕸")
assert "‍" not in fold_text("a‍b")
# -- name colouring -------------------------------------------------------------------
def test_name_style_by_type_reads_the_registry(monkeypatch) -> None:
monkeypatch.setattr(nodetypes, "_TYPES_BY_PREFIX", {"3d": "repeater"})
set_platform(PICOCALC)
assert name_style("YUL-Cartierville", "3d63c6429436") == "type.repeater"
assert name_style("stranger", "beef") == "muted" # never registered → muted
assert name_style("nameless", None) == "muted"
set_platform(REGULAR)
assert name_style("YUL-Cartierville", "3d63c6429436").startswith("bold #")
def test_type_registry_round_trip() -> None:
register_node_type("A1B2C3", 2)
assert node_type_name("a1") == "repeater"
assert node_type_name("a1ffff") == "repeater" # any prefix of the key agrees
register_node_type("a1b2c3", None) # no type carried → no clobber
assert node_type_name("a1") == "repeater"
assert node_type_name(None) is None
assert node_type_name("f") is None # too short for a first byte
# -- the font build script stays mirrored ---------------------------------------------
def test_font_script_marks_and_fontset_move_together() -> None:
"""Every codepoint the script draws or aliases is in the frozen inventory, and every
donor it consumes is out — the two files must change in the same commit."""
script = _FONT_SCRIPT.read_text(encoding="utf-8")
marks = {int(m, 16) for m in re.findall(r"^ (0x[0-9A-Fa-f]{4}): art\(", script, re.M)}
assert marks, "could not parse MARKS out of the font script"
for cp in marks:
assert cp in FONT_CODEPOINTS, f"script draws U+{cp:04X} but fontset lacks it"
aliases = {
int(m, 16) for m in re.findall(r"^ (0x[0-9A-Fa-f]{4}): 0x[0-9A-Fa-f]{4},", script, re.M)
}
for cp in aliases:
assert cp in FONT_CODEPOINTS, f"script aliases U+{cp:04X} but fontset lacks it"
donors_match = re.search(r"DONORS = \[(.*?)\]", script, re.S)
assert donors_match is not None
# Donors are consumed front to back, one per mark; everything consumed must be out
# of the inventory. The spares at the tail legitimately remain in it.
donor_list = re.findall(r"0x[0-9A-Fa-f]{4}", donors_match.group(1))
assert len(donor_list) >= len(marks), "fewer donors than marks — the build would fail"
for cp_hex in donor_list[: len(marks)]:
cp = int(cp_hex, 16)
assert cp not in FONT_CODEPOINTS, (
f"donor U+{cp:04X} is consumed by a mark but still listed in the fontset"
)
# -- the specimen card ----------------------------------------------------------------
def test_specimen_renders_clean_on_picocalc() -> None:
"""`meshterm specimen` under picocalc obeys every contract it demonstrates:
≤53 cells per line, 16-slot SGR only, console-font characters only."""
from io import StringIO
from rich.console import Console
from meshterm.ui.specimen import specimen_lines
set_platform(PICOCALC)
console = Console(
theme=MESH_THEME_16, width=53, file=StringIO(),
force_terminal=True, color_system="standard", highlight=False,
)
with console.capture() as capture:
for line in specimen_lines():
console.print(line)
for i, line in enumerate(capture.get().splitlines()):
plain = re.sub(r"\x1b\[[0-9;]*m", "", line)
assert cell_len(plain) <= 53, f"specimen line {i} is {cell_len(plain)} cells: {plain!r}"
assert "[38;2;" not in line and "[48;2;" not in line, f"truecolor on line {i}: {line!r}"
for ch in plain:
assert ord(ch) in FONT_CODEPOINTS, f"line {i} char {ch!r} outside the font"
# -- relocated marks stay importable from their old homes -----------------------------
def test_mark_constants_reexport_from_their_old_hosts() -> None:
"""The P3 relocation to ``ui.marks`` left the old names bound in the old modules."""
from meshterm.ui.map_render import _NODE, _REPEATER, _SELF, _UNKNOWN
from meshterm.ui.mapcanvas import RGB, parse_hex
from meshterm.ui.marks import NODE_MARK, REPEATER_MARK, SELF_MARK, UNKNOWN_MARK
from meshterm.ui.pathgraph import DST_NODE, SRC_NODE, GlyphOf, LabelOf, LabelRgbOf
assert (_SELF, _REPEATER, _NODE, _UNKNOWN) == (
SELF_MARK, REPEATER_MARK, NODE_MARK, UNKNOWN_MARK,
)
assert parse_hex("#facc15") == (0xFA, 0xCC, 0x15)
assert RGB is not None and SRC_NODE and DST_NODE
assert GlyphOf is not None and LabelOf is not None and LabelRgbOf is not None