P3 rework: the PicoCalc visual language, done properly
The first P3 (a84abf3) ran on the wrong model and its palette never reached the TUI: MESH_THEME_16 was written in 256-colour cube indices instead of the 16 VT slots, and the platform theme pick was wired into the scripted-CLI console while the real rasterizer stayed hardcoded to truecolor. Replace it end to end: - theme._VT_SLOTS: the canonical slot->RGB design (conventional hue families kept; slots 5/6 become the track/faint slates), MESH_THEME_16 rewritten against color(0..15) with the VT's bold-as-brightness and dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot install it (console-font script), OSC fallback when kbd is absent. - platforms.on_platform: set_platform now re-runs registered bindings, so theme, rasterizer and widgets swap implementations once per switch and hot paths read module globals - no per-call get_platform(). - The render-boundary fold (theme.fold_text): one str.translate pass (NFKD accent folds - the Terminus base has no accented Latin - plus symbol stand-ins and the width-padded emoji map), an embedded-SGR quantizer for canvas truecolor, and a ?-at-own-width catch-all that makes the no-wide-glyph short-circuit true by construction. Applied at the only two places every visible byte passes: render_to_ansi and MapCanvas.to_ansi_lines. - ui/fontset.py freezes the installed font's inventory (dumped live); the gallery now asserts rendered picocalc ANSI carries no truecolor/ 256-colour SGR and no character outside it - the parity gate the first attempt faked by asserting a dataclass field. - Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift, gear, refresh, clock, crosshair, padlock) drawn into donor slots, with donor misses now fatal and a keeper canary (donating pi once erased Cyrillic pe - shared slot). Table re-dumped on-device: identical to fontset.py, 519 = 519. - Type-based name colouring: registry moved to core/nodetypes (services no longer import ui), fed from monitor observations, heard_nodes and merge_contacts. Heat gradient quantizes to heat.* steps on picocalc. - ui/marks.py: the P2-deferred constant relocation - widgets no longer drags map_render/mapcanvas/pathgraph for tuples and aliases. - meshterm specimen: the visual language on one card through the real funnels - the on-device acceptance screen (JP's request). - tools/chat: delivered/no-ack state uses the standard check/query marks, not the banned emoji pair. Verified on the Lyra: font+palette installed, specimen framebuffer captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66d728a3b3
commit
84caae53f8
21 files changed
+1362
-310
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
|
||||
from ..core.nodetypes import register_node_type
|
||||
from ..core.models import (
|
||||
PATH_TRACE_TARGET,
|
||||
ChatMessage,
|
||||
@@ -1539,6 +1540,8 @@ class Repository:
|
||||
grouped.setdefault(row["node"], []).append(obs)
|
||||
|
||||
nodes = [HeardNode.from_observations(node, obs) for node, obs in grouped.items()]
|
||||
for heard in nodes: # backfill the type registry from history (PicoCalc colour)
|
||||
register_node_type(heard.public_key or heard.node, heard.node_type)
|
||||
return sorted(nodes, key=lambda n: n.last_seen, reverse=True)
|
||||
|
||||
# -- chat messages ----------------------------------------------------------
|
||||
|
||||
+32
-2
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The shared mark constants: node-type glyphs, their colours, and path sentinels.
|
||||
|
||||
The app's spatial surfaces — the map, the path graph, the braille rasters, the node
|
||||
rows — all pin nodes with the same marks in the same hues, and several of them need the
|
||||
constants *without* the machinery around them. Before this module, ``ui.widgets`` pulled
|
||||
``map_render``, ``mapcanvas`` and ``pathgraph`` onto the boot path for a handful of
|
||||
tuples and type aliases (~60 ms of import on the PicoCalc); now the constants live here,
|
||||
dependency-free, and the heavy modules import *from* the constants instead of hosting
|
||||
them. ``map_render``/``mapcanvas``/``pathgraph`` re-export their old names, so existing
|
||||
importers of those modules are untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
#: An ``(r, g, b)`` colour triple, 0–255 per channel (the raster/canvas colour type).
|
||||
RGB = tuple[int, int, int]
|
||||
|
||||
|
||||
def parse_hex(color: str) -> RGB:
|
||||
"""Convert ``"#rrggbb"`` (or ``"rrggbb"``) to an ``(r, g, b)`` tuple."""
|
||||
c = color.lstrip("#")
|
||||
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
|
||||
|
||||
|
||||
# Marker palette, shared across the whole app. The basemap is blue (water), green (parks)
|
||||
# and warm amber (roads), so markers use the hues a map never contains — pink/violet — to
|
||||
# stand out against it. Ordinary nodes are the loudest (bright pink); repeaters recede a
|
||||
# step (a calmer violet); our own node stays the fixed yellow star; a node heard of but
|
||||
# never identified is a muted grey ring. Every glyph is in the PicoCalc console font.
|
||||
SELF_MARK = ("★", "#facc15")
|
||||
REPEATER_MARK = ("▲", "#a78bfa")
|
||||
NODE_MARK = ("●", "#f472b6")
|
||||
UNKNOWN_MARK = ("○", "#94a3b8")
|
||||
|
||||
#: Sentinels naming a path's endpoints on the route graph. ``\x00`` never appears in a
|
||||
#: node hash, so they can share the node-id namespace without colliding with one.
|
||||
SRC_NODE = "\x00src"
|
||||
DST_NODE = "\x00dst"
|
||||
|
||||
#: Maps a node id to its graph marker: a ``(glyph, colour)`` pair.
|
||||
GlyphOf = Callable[[str], tuple[str, str]]
|
||||
|
||||
#: A node's graph label, or ``None``/``""`` to leave the marker bare.
|
||||
LabelOf = Callable[[str], Optional[str]]
|
||||
|
||||
#: The colour a node's label is drawn in (usually the node's name hue).
|
||||
LabelRgbOf = Callable[[str], RGB]
|
||||
+10
-17
@@ -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.
|
||||
|
||||
@@ -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("#")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The visual-language specimen: every mark, icon, colour and fold on one screen.
|
||||
|
||||
``meshterm specimen`` prints this through the platform's real machinery — the active
|
||||
theme, the ``glyph()`` icon funnel, the render-boundary fold, the heat and SNR scales —
|
||||
so what it shows *is* what the platform draws, not a mock-up of it. On the PicoCalc
|
||||
console it doubles as the font/palette acceptance card (all nine P3 marks, the node
|
||||
glyphs, braille, the 16 programmed slots); on a desktop terminal it shows the same
|
||||
content in the regular platform's emoji-and-truecolor dress. Sized to the PicoCalc
|
||||
floor: every line ≤ 53 cells, the whole card ≤ 24 rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..platforms import get_platform
|
||||
from .packet_viewer import KIND_ICONS, PAYLOAD_ICONS
|
||||
from .theme import _VT_SLOTS, fold_text, glyph, name_style, snr_style
|
||||
from .widgets import _NODE_GLYPHS, _recency_style, channel_glyph
|
||||
|
||||
#: Demo identities for the name-colour row: (name, key, node type registered for it).
|
||||
#: Keys are synthetic and only ever registered inside the one-shot specimen process.
|
||||
_DEMO_NAMES = (("Alice", "a1b2", 1), ("YUL-Poly", "3d63", 2), ("Lounge", "cc10", 3))
|
||||
|
||||
|
||||
def _palette_row() -> Text:
|
||||
"""The 16 slots as filled blocks, dim bank then bright bank.
|
||||
|
||||
On PicoCalc the blocks address the console's real slots (``color(N)``), so the row
|
||||
shows exactly what ``setvtrgb`` programmed; on the regular platform they carry the
|
||||
design's RGB values instead — a preview of the same palette in truecolor.
|
||||
"""
|
||||
row = Text()
|
||||
for slot, _, hex_ in _VT_SLOTS:
|
||||
style = f"color({slot})" if not get_platform().truecolor else hex_
|
||||
row.append("██", style=style)
|
||||
return row
|
||||
|
||||
|
||||
def _ages_row() -> Text:
|
||||
"""The heat scale over its own anchors, each age drawn in its heat colour."""
|
||||
row = Text()
|
||||
for label, secs in (("now", 0), ("5m", 300), ("3h", 10800), ("2d", 172800), ("never", None)):
|
||||
if row.plain:
|
||||
row.append(" ")
|
||||
row.append(label, style=_recency_style(secs))
|
||||
return row
|
||||
|
||||
|
||||
def specimen_lines() -> list[RenderableType]:
|
||||
"""The specimen card, one renderable per line (print through the themed console)."""
|
||||
from meshterm.core.nodetypes import register_node_type
|
||||
|
||||
platform = get_platform()
|
||||
lines: list[RenderableType] = []
|
||||
lines.append(Text.assemble(("Specimen", "accent"), (" · ", "muted"), platform.name))
|
||||
lines.append(Text())
|
||||
lines.append(_palette_row())
|
||||
legend = Text("0 bg · 5 track · 6 faint · 8 muted · 15 you", style="muted")
|
||||
lines.append(legend)
|
||||
lines.append(Text())
|
||||
|
||||
status = Text("status ")
|
||||
for mark, style in (("✓", "ok"), ("✗", "err"), ("⚠", "warn"), ("●", "err"), ("○", "muted")):
|
||||
status.append(mark + " ", style=style)
|
||||
lines.append(status)
|
||||
|
||||
nodes = Text("nodes ")
|
||||
nodes.append("★ ", style="you")
|
||||
for node_type, (mark, colour) in _NODE_GLYPHS.items():
|
||||
nodes.append(mark + " ", style=colour)
|
||||
nodes.append("○", style="muted")
|
||||
lines.append(nodes)
|
||||
|
||||
classes = Text("classes ")
|
||||
for icon in KIND_ICONS.values():
|
||||
classes.append(glyph(icon) + " ", style="brand")
|
||||
classes.append(" payloads ", style="muted")
|
||||
for typename in ("REQ", "RESPONSE", "TEXT_MSG", "GRP_TXT", "GRP_DATA", "ANON_REQ"):
|
||||
classes.append(glyph(PAYLOAD_ICONS[typename]) + " ")
|
||||
for typename in ("PATH", "TRACE", "MULTIPART", "CONTROL"):
|
||||
classes.append(glyph(PAYLOAD_ICONS[typename]) + " ")
|
||||
lines.append(classes)
|
||||
|
||||
channels = Text("channels ")
|
||||
channels.append(channel_glyph("#public", None) + " name-derived ", style="brand")
|
||||
channels.append(glyph("🌐") + " public ")
|
||||
channels.append(glyph("🔒") + " private")
|
||||
lines.append(channels)
|
||||
|
||||
concepts = Text("concepts ")
|
||||
for icon in ("📡", "🕒", "🔄", "💾", "📂", "🔑", "🗑", "✎", "⚡", "⭐", "📤", "📨"):
|
||||
concepts.append(glyph(icon) + " ")
|
||||
for icon in ("🔔", "🔕", "📱", "🔗", "🏆", "⌨"):
|
||||
concepts.append(glyph(icon) + " ")
|
||||
lines.append(concepts)
|
||||
|
||||
lines.append(Text("keys ⌫ ⇧ … ↕ ↔ ↻ ⌖ ❯ ▸"))
|
||||
lines.append(Text())
|
||||
|
||||
for name, key, node_type in _DEMO_NAMES:
|
||||
register_node_type(key, node_type)
|
||||
names = Text("names ")
|
||||
for name, key, _ in _DEMO_NAMES:
|
||||
names.append(name + " ", style=name_style(name, key))
|
||||
names.append("me", style="you")
|
||||
lines.append(names)
|
||||
|
||||
lines.append(Text("heard ").append_text(_ages_row()))
|
||||
|
||||
snr = Text("snr ")
|
||||
for reading in (8.0, -2.0, -12.0):
|
||||
snr.append(f"{reading:+.1f} ", style=snr_style(reading))
|
||||
lines.append(snr)
|
||||
|
||||
# What the render boundary makes of stored text: accents fold on PicoCalc (the raw
|
||||
# form would be tofu there — the console font has no accented Latin), Cyrillic is
|
||||
# font-native and passes through on both platforms.
|
||||
folded = fold_text("café Montréal")
|
||||
lines.append(Text.assemble("text ", (folded, "brand"), (" · ", "muted"), "привет мир"))
|
||||
lines.append(Text())
|
||||
|
||||
chart = Text("chart ")
|
||||
chart.append("⡀⡄⡆⣆⣦⣶⣷⣿", style="snr.good")
|
||||
chart.append("⣶⣤⣀", style="snr.ok")
|
||||
chart.append("⡀⠄⠂", style="snr.bad")
|
||||
chart.append(" braille", style="muted")
|
||||
lines.append(chart)
|
||||
return lines
|
||||
+433
-225
@@ -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
|
||||
@@ -17,13 +17,30 @@ from rich.cells import cell_len
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..theme import MESH_THEME
|
||||
from ...platforms import Platform, on_platform
|
||||
from ..theme import active_theme, fold_text
|
||||
|
||||
#: Cache one headless render console per width. Consoles are cheap but repaint happens on
|
||||
#: every keystroke and on the live-monitor timer, so caching avoids needless churn. Keyed
|
||||
#: by width because a console's width is fixed at construction.
|
||||
#: by width because a console's width is fixed at construction; emptied whenever the
|
||||
#: platform switches, since the theme and colour system are baked in at construction.
|
||||
_CONSOLES: dict[int, Console] = {}
|
||||
|
||||
#: The colour depth the rasterizer emits at — bound per platform. ``"standard"`` on the
|
||||
#: 16-slot console: the theme's ``color(N)`` styles pass through as exact slot indices,
|
||||
#: and any stray truecolor (a node hue, a map feature) is downsampled by Rich to the
|
||||
#: nearest conventional slot — which the palette deliberately keeps hue-aligned (see
|
||||
#: ``theme._VT_SLOTS``), so even strays land in their own colour family.
|
||||
_COLOR_SYSTEM = "truecolor"
|
||||
|
||||
|
||||
@on_platform
|
||||
def _bind(platform: Platform) -> None:
|
||||
"""Re-bind the rasterizer's colour depth and drop stale consoles on a switch."""
|
||||
global _COLOR_SYSTEM
|
||||
_COLOR_SYSTEM = "truecolor" if platform.truecolor else "standard"
|
||||
_CONSOLES.clear()
|
||||
|
||||
|
||||
def _console(width: int) -> Console:
|
||||
"""Return a themed, headless console that emits ANSI at the given width.
|
||||
@@ -37,11 +54,11 @@ def _console(width: int) -> Console:
|
||||
console = _CONSOLES.get(width)
|
||||
if console is None:
|
||||
console = Console(
|
||||
theme=MESH_THEME,
|
||||
theme=active_theme(),
|
||||
width=width,
|
||||
file=StringIO(),
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
color_system=_COLOR_SYSTEM,
|
||||
# This console is a rasterizer, not terminal output: its ANSI is re-parsed
|
||||
# by prompt_toolkit, and the TUI's hues are semantics (node identity,
|
||||
# recency heat), not decoration. So a NO_COLOR environment must not strip
|
||||
@@ -74,7 +91,11 @@ def render_to_ansi(renderable: RenderableType, width: int, *, no_wrap: bool = Fa
|
||||
console.print(renderable, end="", no_wrap=True, overflow="ellipsis", crop=True)
|
||||
else:
|
||||
console.print(renderable, end="")
|
||||
return capture.get()
|
||||
# THE render boundary: every visible string in the TUI leaves through here, so this
|
||||
# one call is what makes the PicoCalc glyph contract hold app-wide (identity on the
|
||||
# regular platform). Widths were measured on the pre-fold text; the fold preserves
|
||||
# cell counts (wide emoji become glyph + pad), so the layout above survives it.
|
||||
return fold_text(capture.get())
|
||||
|
||||
|
||||
def render_lines(renderable: RenderableType, width: int, *, no_wrap: bool = False) -> list[str]:
|
||||
|
||||
+73
-23
@@ -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"),
|
||||
|
||||
@@ -109,18 +109,61 @@ MARKS = {
|
||||
0x25B8: art([ # SMALL RIGHT-POINTING TRIANGLE -- grabbed/reorder cursor
|
||||
"......", "......", "......", ".#....", ".##...", ".###..",
|
||||
".###..", ".##...", ".#....", "......", "......", "......"]),
|
||||
# --- P3 marks: the compact icon language (see meshterm/ui/theme._GLYPH_MAP and
|
||||
# --- meshterm/ui/fontset.py, which must mirror every codepoint drawn here) --------
|
||||
0x2026: art([ # HORIZONTAL ELLIPSIS -- "opens further prompts", truncation
|
||||
"......", "......", "......", "......", "......", "......",
|
||||
"......", "......", "#.#.#.", "#.#.#.", "......", "......"]),
|
||||
0x26A0: art([ # WARNING SIGN -- the warn status mark
|
||||
"......", "..##..", ".#..#.", ".#..#.", "#....#", "#.##.#",
|
||||
"#.##.#", "#....#", "#.##.#", "######", "......", "......"]),
|
||||
0x232B: art([ # ERASE TO THE LEFT -- the backspace key in footer hints
|
||||
"......", "......", "......", "..####", ".#...#", ".##.##",
|
||||
"#..#.#", ".##.##", ".#...#", "..####", "......", "......"]),
|
||||
0x21E7: art([ # UPWARDS WHITE ARROW -- the shift key in footer hints
|
||||
"......", "......", "..##..", ".#..#.", "#....#", "##..##",
|
||||
".#..#.", ".#..#.", ".#..#.", ".####.", "......", "......"]),
|
||||
0x2699: art([ # GEAR -- parameter/config
|
||||
"......", "......", "..##..", ".####.", "######", "##..##",
|
||||
"##..##", "######", ".####.", "..##..", "......", "......"]),
|
||||
0x21BB: art([ # CLOCKWISE OPEN CIRCLE ARROW -- re-read/refresh
|
||||
"......", "....#.", ".#####", "#...#.", "#.....", "#.....",
|
||||
"#.....", "#....#", ".####.", "......", "......", "......"]),
|
||||
0x25F7: art([ # WHITE CIRCLE UPPER RIGHT QUADRANT -- the clock face (sync/time)
|
||||
"......", "......", ".####.", "#..#.#", "#..#.#", "#..###",
|
||||
"#....#", "#....#", ".####.", "......", "......", "......"]),
|
||||
0x2316: art([ # POSITION INDICATOR -- the crosshair (trace/map position)
|
||||
"......", "..##..", "..##..", "......", "......", "#.##.#",
|
||||
"#.##.#", "......", "......", "..##..", "..##..", "......"]),
|
||||
0x26BF: art([ # SQUARED KEY (drawn as a padlock) -- private channel
|
||||
"......", "......", ".####.", ".#..#.", "######", "######",
|
||||
"##..##", "##..##", "######", "######", "......", "......"]),
|
||||
}
|
||||
|
||||
# Donor codepoints whose glyph slots we may repurpose (CP437 junk MeshTerm never uses).
|
||||
DONORS = [0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x25D8, 0x25D9,
|
||||
0x266A, 0x266B, 0x263C, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8]
|
||||
# Donor codepoints whose glyph slots we may repurpose (glyphs MeshTerm never draws).
|
||||
# Order matters: MARKS consume donors front to back, one each, so the original nine marks
|
||||
# keep their original slots and the P3 nine take the next batch. Choosing a donor means
|
||||
# knowing its slot's FULL codepoint list: ter-u12n aliases lookalikes onto one glyph, and
|
||||
# repointing the slot erases every codepoint it carried -- donating pi (03C0) was tried
|
||||
# and took Cyrillic pe (043F) with it, and the beamed notes 266B/266C share one slot (so
|
||||
# 266B's donation already erases both; never list 266C as a donor of its own -- it would
|
||||
# come up empty and abort the build). After the consumed batch: preferred future donors
|
||||
# (the mixed double/single box set nothing draws), then the last-resort tail -- those are
|
||||
# MAP TARGETS in meshterm/ui/theme (advert ☼, message ¶, data §, packet ▬, control ↨);
|
||||
# consuming one breaks the compact icon language.
|
||||
DONORS = [0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x25D8, 0x25D9, 0x266A,
|
||||
0x266B, 0x203C, 0x2640, 0x2642, 0x2320, 0x2321, 0x00F7, 0x2552, 0x2558,
|
||||
0x2559, 0x255B, 0x255E, 0x255F, 0x2561, 0x2567, 0x2568, 0x256A,
|
||||
0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8]
|
||||
|
||||
# Rounded panel corners aliased onto the existing square corners (no new bitmap).
|
||||
# Rounded panel corners aliased onto the existing square corners (no new bitmap), and the
|
||||
# midline ellipsis onto the P3 baseline one (pathline's gap marker draws as the same dots).
|
||||
ALIASES = {
|
||||
0x256D: 0x250C, # rounded top-left -> square top-left
|
||||
0x256E: 0x2510, # rounded top-right -> square top-right
|
||||
0x256F: 0x2518, # rounded bottom-right -> square bottom-right
|
||||
0x2570: 0x2514, # rounded bottom-left -> square bottom-left
|
||||
0x22EF: 0x2026, # midline ellipsis -> ellipsis mark
|
||||
}
|
||||
|
||||
base = gzip.open(BASE, "rb").read()
|
||||
@@ -146,13 +189,19 @@ def slot_of(cp):
|
||||
|
||||
|
||||
# Draw each mark/cursor into a donor slot: overwrite the bitmap, repoint the codepoint.
|
||||
# One donor per mark, and a donor that fails to resolve aborts the build: a miss means
|
||||
# the DONORS list's model of the base font is wrong, and silently consuming the *next*
|
||||
# donor is how a keeper glyph gets eaten (it cost us ☼ once).
|
||||
donors = list(DONORS)
|
||||
for cp, bitmap in MARKS.items():
|
||||
slot = None
|
||||
while donors and slot is None:
|
||||
slot = slot_of(donors.pop(0))
|
||||
if slot is None:
|
||||
if not donors:
|
||||
raise SystemExit("out of donor slots for U+%04X" % cp)
|
||||
donor = donors.pop(0)
|
||||
slot = slot_of(donor)
|
||||
if slot is None:
|
||||
raise SystemExit(
|
||||
"donor U+%04X (for mark U+%04X) not found in the base font -- "
|
||||
"fix DONORS instead of skipping" % (donor, cp))
|
||||
glyphs[slot] = bytearray(bitmap)
|
||||
entries[slot] = chr(cp).encode("utf-8")
|
||||
|
||||
@@ -163,6 +212,18 @@ for new_cp, existing_cp in ALIASES.items():
|
||||
raise SystemExit("no glyph for U+%04X to alias U+%04X onto" % (existing_cp, new_cp))
|
||||
entries[slot] += chr(new_cp).encode("utf-8")
|
||||
|
||||
# Verify nothing the app depends on was eaten as donation collateral (a donor slot can
|
||||
# carry codepoints beyond the one that earned it a place on the list). The keepers: every
|
||||
# mark and alias just placed, the compact icon language's base-font targets, and Cyrillic
|
||||
# pe (043F) as the canary for the pi/pe shared slot that bit once.
|
||||
KEEP = list(MARKS) + list(ALIASES) + [
|
||||
0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, # ☼ ¶ § ▬ ↨ -- theme map targets
|
||||
0x043F, 0x03C0, # п and π -- shared-slot canaries
|
||||
]
|
||||
for cp in KEEP:
|
||||
if slot_of(cp) is None:
|
||||
raise SystemExit("build ate U+%04X -- a donor slot carried it; fix DONORS" % cp)
|
||||
|
||||
header = struct.pack("<IIIIIIII", PSF2_MAGIC, 0, 32, 1, 512, charsize, h, w)
|
||||
out = header + b"".join(bytes(g) for g in glyphs) + b"".join(e + b"\xff" for e in entries)
|
||||
gzip.open(OUT, "wb").write(out)
|
||||
@@ -183,4 +244,54 @@ else
|
||||
echo "FONT=$FONT_NAME" >> "$VCONSOLE"
|
||||
fi
|
||||
echo "persisted FONT=$FONT_NAME in $VCONSOLE (loads on every boot)"
|
||||
|
||||
# --- palette: program the 16 console slots to MeshTerm's colours ------------------------
|
||||
# The panel's VT layer is 16 fg / 8 bg palette slots -- no per-cell RGB -- so MeshTerm's
|
||||
# 16-slot theme (meshterm/ui/theme.MESH_THEME_16) is designed against the slot meanings
|
||||
# below, and this section programs the slots' actual RGB values. The palette file matches
|
||||
# theme.vtrgb_lines() exactly (a test keeps them in sync); the boot oneshot re-applies it
|
||||
# every start (same pattern as wifi-kick). setvtrgb does the work where kbd ships it; the
|
||||
# fallback printf speaks the kernel VT's own OSC palette sequences, so nothing is required.
|
||||
VTRGB=/etc/vtrgb
|
||||
APPLIER=/usr/local/sbin/meshterm-vtrgb
|
||||
UNIT=/etc/systemd/system/meshterm-vtrgb.service
|
||||
|
||||
echo "writing $VTRGB ..."
|
||||
cat > "$VTRGB" <<'EOF'
|
||||
15,239,34,245,99,51,100,203,148,248,74,251,129,165,94,255
|
||||
23,68,197,158,102,65,116,213,163,113,222,191,140,180,234,255
|
||||
42,68,94,11,241,85,139,225,184,113,128,36,248,252,212,255
|
||||
EOF
|
||||
|
||||
mkdir -p "$(dirname "$APPLIER")"
|
||||
cat > "$APPLIER" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Apply the MeshTerm console palette (slots documented in meshterm/ui/theme._VT_SLOTS).
|
||||
if command -v setvtrgb >/dev/null 2>&1; then
|
||||
exec setvtrgb /etc/vtrgb
|
||||
fi
|
||||
# No kbd setvtrgb: the kernel VT accepts its own OSC palette sequences (ESC ] P n rrggbb).
|
||||
TTY=${1:-/dev/tty1}
|
||||
printf '\033]P00f172a\033]P1ef4444\033]P222c55e\033]P3f59e0b\033]P46366f1\033]P5334155\033]P664748b\033]P7cbd5e1\033]P894a3b8\033]P9f87171\033]Pa4ade80\033]Pbfbbf24\033]Pc818cf8\033]Pda5b4fc\033]Pe5eead4\033]Pfffffff' > "$TTY"
|
||||
EOF
|
||||
chmod +x "$APPLIER"
|
||||
|
||||
cat > "$UNIT" <<EOF
|
||||
[Unit]
|
||||
Description=MeshTerm console palette (16-slot vtrgb)
|
||||
After=systemd-vconsole-setup.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$APPLIER
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable meshterm-vtrgb.service >/dev/null 2>&1 || true
|
||||
"$APPLIER" || echo "warning: could not apply the palette live (boot service will)"
|
||||
echo "palette installed ($VTRGB + boot oneshot)"
|
||||
|
||||
echo "done -- launch 'meshterm' to see braille charts, node glyphs, framed panels, and the > cursor."
|
||||
+25
-5
@@ -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}"
|
||||
)
|
||||
@@ -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 -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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("ab")
|
||||
|
||||
|
||||
# -- 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
|
||||
|
||||
Reference in new issue
Block a user