From 84caae53f87c07dbba2f11c9de8f2a999ab81978 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 1 Aug 2026 12:48:07 -0400 Subject: [PATCH] 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 --- meshterm/cli.py | 16 + meshterm/core/contact_store.py | 6 +- meshterm/core/nodetypes.py | 70 +++ meshterm/persistence/repository.py | 3 + meshterm/platforms.py | 34 +- meshterm/services/monitor_service.py | 3 +- meshterm/tools/chat.py | 2 +- meshterm/ui/fontset.py | 59 +++ meshterm/ui/map_render.py | 18 +- meshterm/ui/mapcanvas.py | 23 +- meshterm/ui/marks.py | 49 ++ meshterm/ui/pathgraph.py | 27 +- meshterm/ui/pathline.py | 4 +- meshterm/ui/specimen.py | 130 ++++++ meshterm/ui/theme.py | 658 ++++++++++++++++++--------- meshterm/ui/tui/render.py | 31 +- meshterm/ui/widgets.py | 96 +++- scripts/calculinux-console-font.sh | 127 +++++- tests/test_gallery.py | 30 +- tests/test_performance.py | 19 + tests/test_theme16.py | 267 +++++++++++ 21 files changed, 1362 insertions(+), 310 deletions(-) create mode 100644 meshterm/core/nodetypes.py create mode 100644 meshterm/ui/fontset.py create mode 100644 meshterm/ui/marks.py create mode 100644 meshterm/ui/specimen.py create mode 100644 tests/test_theme16.py diff --git a/meshterm/cli.py b/meshterm/cli.py index 3f4b3af..d55cf38 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -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. diff --git a/meshterm/core/contact_store.py b/meshterm/core/contact_store.py index cd50ec4..affadcb 100644 --- a/meshterm/core/contact_store.py +++ b/meshterm/core/contact_store.py @@ -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 diff --git a/meshterm/core/nodetypes.py b/meshterm/core/nodetypes.py new file mode 100644 index 0000000..e526840 --- /dev/null +++ b/meshterm/core/nodetypes.py @@ -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 diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index 78881bb..33cecd0 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -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 ---------------------------------------------------------- diff --git a/meshterm/platforms.py b/meshterm/platforms.py index 7bbe3f6..9f005d2 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -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", ] diff --git a/meshterm/services/monitor_service.py b/meshterm/services/monitor_service.py index 6223b02..3949dec 100644 --- a/meshterm/services/monitor_service.py +++ b/meshterm/services/monitor_service.py @@ -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) diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 1b24ff8..1d0d998 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -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)}) diff --git a/meshterm/ui/fontset.py b/meshterm/ui/fontset.py new file mode 100644 index 0000000..5dc6073 --- /dev/null +++ b/meshterm/ui/fontset.py @@ -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 diff --git a/meshterm/ui/map_render.py b/meshterm/ui/map_render.py index b3560c4..fac9ae0 100644 --- a/meshterm/ui/map_render.py +++ b/meshterm/ui/map_render.py @@ -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 ---------------------------------------------------------- diff --git a/meshterm/ui/mapcanvas.py b/meshterm/ui/mapcanvas.py index 7bdf172..866aaf8 100644 --- a/meshterm/ui/mapcanvas.py +++ b/meshterm/ui/mapcanvas.py @@ -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 diff --git a/meshterm/ui/marks.py b/meshterm/ui/marks.py new file mode 100644 index 0000000..a61106c --- /dev/null +++ b/meshterm/ui/marks.py @@ -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] diff --git a/meshterm/ui/pathgraph.py b/meshterm/ui/pathgraph.py index 775fc2f..95fbcec 100644 --- a/meshterm/ui/pathgraph.py +++ b/meshterm/ui/pathgraph.py @@ -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. diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 4e8a4dc..87190f2 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -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("#") diff --git a/meshterm/ui/specimen.py b/meshterm/ui/specimen.py new file mode 100644 index 0000000..4228dd7 --- /dev/null +++ b/meshterm/ui/specimen.py @@ -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 diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index 1d0e3f8..d1c9e12 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -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 diff --git a/meshterm/ui/tui/render.py b/meshterm/ui/tui/render.py index ea3b5b3..d5b1a20 100644 --- a/meshterm/ui/tui/render.py +++ b/meshterm/ui/tui/render.py @@ -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]: diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index bccdcee..f96dcba 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -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"), diff --git a/scripts/calculinux-console-font.sh b/scripts/calculinux-console-font.sh index 62a6b90..c6affda 100644 --- a/scripts/calculinux-console-font.sh +++ b/scripts/calculinux-console-font.sh @@ -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("> "$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" </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." diff --git a/tests/test_gallery.py b/tests/test_gallery.py index b68747c..a6ecf85 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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}" + ) diff --git a/tests/test_performance.py b/tests/test_performance.py index 1a920dc..c9e759a 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -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 ------------------------------------------- diff --git a/tests/test_theme16.py b/tests/test_theme16.py new file mode 100644 index 0000000..3fc4941 --- /dev/null +++ b/tests/test_theme16.py @@ -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 +