Files
MeshTerm/meshterm/platforms.py
T
Jean-Pierre MartineauandClaude Opus 5.5 6840138eb4 On the PicoCalc, every QR code is braille, one module a dot
Braille was a fallback for codes too big for half blocks; on the
PicoCalc it is now the grid every code is drawn on, eight modules to a
cell, share screens and page fences alike. A channel share is 23 cells
by 12 rows there, a contact card 29 by 15, each with its link under it.
The regular platform keeps half blocks, its fonts' braille being dotted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:55:01 -04:00

387 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The platform seam: a frozen spec every consumer binds to once, at boot.
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
PicoCalc's 53-column framebuffer console — with no forked screens and no runtime layer.
A :class:`Platform` is resolved once, at process start, and held in a module-level
singleton; everything downstream (the theme, the frame compositor, the header, the
session's width handling) reads it at *its own* construction/call time and binds its
behaviour accordingly, so the render loop itself never branches on platform.
Read the active platform through :func:`get_platform` (or the module-qualified
``platforms.PLATFORM``) — never ``from meshterm.platforms import PLATFORM`` at a
module's top level. That statement runs once, at import time, and copies whatever
:data:`PLATFORM` pointed to *then*; :func:`set_platform` rebinding the module global
afterwards would never reach that stale copy. A function call always re-reads the
current value, so it has no such trap.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass, replace
from pathlib import Path
@dataclass(frozen=True, slots=True)
class Platform:
"""One flavour's complete rendering/behaviour spec.
Every field is data a consumer binds to at its own construction/call time (see the
module docstring) — there is no code path that branches on ``platform.name`` itself
outside this module and the small set of binding points documented at each field.
Attributes:
name: Stable identifier — ``"regular"`` or ``"picocalc"`` — used for
``--platform``/``MESHTERM_PLATFORM`` matching and diagnostics.
readable_cols: The readability standard (CLAUDE.md's "screens stay readable at
N columns") and the dual-platform gallery's test width.
readable_rows: The row *floor* a screen designs to. PicoCalc's live console is
53×26 (custom 6×12 font); a 6×8 font rebuild (P5) gives 53×40. Screens design
to this floor and exploit extra rows fluidly; the gallery renders picocalc at
both 26 and 40 rows.
frame_border: Whether the base screen draws inside a bordered ``Panel``. ``False``
swaps it for a title-bar row instead (+4 cols, +1 row reclaimed) — a PicoCalc
chrome saving, wired in P4.
menu_icons: Whether a *command row* leads with a decorative icon — the main menu's
per-tool mark, an action list's ``🗑``/``🕒``, a body action's ``✎``. The label
already names the action, so the icon is a family cue, not information; where
the console can only spell it as a one-glyph stand-in the cue is worth less
than the cells it costs and reads as noise besides (``@`` for both Map and
Mesh walk, ``…`` for both Live feed and Time machine — JP, on-device,
2026-08-11). ``False`` drops the whole lane, mark and emoji alike, and the
cells go to the label and its description. This is about *decoration* only:
a glyph that carries data — a channel's openness, a packet's class, a node's
type, a status mark on an outcome — is not a menu icon and is never dropped
(see :func:`~meshterm.ui.menus.command_label`).
dialog_margin: Total columns a floating dialog leaves to the backdrop it sits over
— half of it on each side (see
:func:`~meshterm.ui.tui.frame._dialog_layout`). The gutter is what makes a
dialog read as floating rather than as a new screen, and three columns a side
do that comfortably at 72. On the 53-column console the *same* gutter is a
twelfth of the whole display, and it costs content: a packet card's reception
row folds ``rssi`` onto a line of its own for want of two cells (JP, on-device,
2026-08-10). Two columns a side still reads as floating and buys them back.
header_atoms: Which segments compose the persistent header, in the vocabulary
``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired
into :func:`~meshterm.ui.menu._header` in P4; unconsumed until then.
footer_fkeys: Whether the footer is the fixed F1–F5 hint lane (with F6–F10 as
their paired opposites) instead of each screen's own ``footer_hint`` string.
Wired in P4.
width_reclaim: Whether the session may report one extra terminal column (see
:class:`~meshterm.ui.tui.session._WidthExtendedOutput`). Correctness-critical
on PicoCalc's exact-width console: a phantom extra column tears the frame, so
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
on top of this default.
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
through a compact single-BMP-glyph table instead (P3), and neither half of the
emoji alignment runs — the reserve-two measurement
(:func:`~meshterm.ui.tui.emoji_width.install`) or the column pinning
(:mod:`~meshterm.ui.tui.colsnap`). Once no emoji are ever drawn, every glyph on
screen is one the console font has verified, and the stock widths are exact.
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
the render boundary before display. Storage is never touched. Wired in P3.
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
a 16-slot palette theme instead (the console's real ceiling — no per-cell RGB),
and quantizes the two scales that would otherwise spend a gradient — the
per-node key hue (:func:`~meshterm.ui.theme.node_style`) and the heard-age heat
(:func:`~meshterm.ui.widgets._recency_style`). Wired in P3.
solid_braille: Whether the console font draws a braille cell's eight dots as solid
tiles with no gap between them or between neighbouring cells — the PicoCalc's
built fonts (``scripts/picocalc``) do — so braille is a true 2×4 pixel grid,
fine enough for what must stay contiguous. It is what lets every QR code be
drawn in braille, one module a dot (:mod:`~meshterm.ui.qr`). A desktop font's
braille is dotted, and a code drawn in it scans as nothing.
effects: Whether animated/decorative rendering runs at all — the braille spinner,
which drops to a ``LINE`` fallback. Cheaper on a console where a repaint is
dear. The header battery gauge is deliberately *not* behind it: its sweep is
the charging state itself and its blink is the last warning before the pack
dies, and both ride the idle repaint that happens anyway. Wired in P2/P3.
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the
*idle* repaint cadence only, since every screen pushes its own repaint through
``TuiSession.invalidate`` when its data actually moves. Composing one frame was
measured at 74 ms on the PicoCalc at 53×26 (110 ms at 53×40), so a 1 Hz idle
tick alone costs 7–11% of a core doing nothing; it ticks at half that rate
there. What drifts is cosmetic — the header's per-minute pulse and its packet
counter — and none of it is legible at one-second resolution anyway.
spinner_tick_s: Seconds between frames of a "working" spinner. The single source for
every animated wait in the app. The desktop's 0.12 s is not a rate this hardware
can hold: a PicoCalc frame would claim 62% of a core at 53×26, and at 53×40 its
110 ms of compose would leave under 10 ms of each interval for the work actually
being waited on — and before this phase's savings it did not fit at all, at
152 ms against a 120 ms budget. It slows to a cadence the hardware can hold
comfortably while still reading as alive.
battery: Which source feeds the header's battery gauge — ``"companion"`` (read
from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs
``power_supply`` driver, confirmed present in P0). Wired in P5.
modifier_watch: Whether the optional keyboard-lib/evdev Shift-state watcher may
engage, flipping the displayed F-key lane live while Shift is held. An
experiment gated to PicoCalc only — desktop terminals have no such lane to
flip, and the watcher needs ``/dev/input`` access this platform is known to
have (the deploy user in the ``input`` group). Always optional and lazy; wired in P4.
"""
name: str
readable_cols: int
readable_rows: int
frame_border: bool
menu_icons: bool
dialog_margin: int
header_atoms: tuple[str, ...]
footer_fkeys: bool
width_reclaim: bool
emoji: bool
ascii_fold: bool
truecolor: bool
solid_braille: bool
effects: bool
tick_s: float
spinner_tick_s: float
battery: str
modifier_watch: bool
#: Exactly today's behaviour — the desktop/ssh terminal, unchanged by this seam's arrival.
REGULAR = Platform(
name="regular",
readable_cols=72,
readable_rows=24,
frame_border=True,
menu_icons=True,
dialog_margin=6,
header_atoms=("version", "device", "badges", "pulse", "battery"),
footer_fkeys=False,
width_reclaim=True,
emoji=True,
ascii_fold=False,
truecolor=True,
solid_braille=False,
effects=True,
tick_s=1.0,
spinner_tick_s=0.12,
battery="companion",
modifier_watch=False,
)
#: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a
#: binding point (header_atoms, ascii_fold, truecolor, effects beyond the
#: two P1 bindings, tick_s, battery, modifier_watch) are P2–P5's targets, recorded here
#: now so the seam exists before the flavour work lands.
PICOCALC = Platform(
name="picocalc",
readable_cols=53,
readable_rows=26,
frame_border=False,
menu_icons=False,
dialog_margin=4,
# JP's call (2026-08-01, post-P7 review): the handheld's header brands the app —
# "MeshTerm vX" — rather than naming the device/port (on a soldered radio the port
# never changes), and the activity pulse takes whatever room remains.
header_atoms=("version", "badges", "pulse", "battery"),
footer_fkeys=True,
width_reclaim=False,
emoji=False,
ascii_fold=True,
truecolor=False,
solid_braille=True,
effects=False,
tick_s=2.0,
spinner_tick_s=0.5,
battery="host",
modifier_watch=True,
)
_BY_NAME = {p.name: p for p in (REGULAR, PICOCALC)}
#: The active platform. Do not import this name directly (see the module docstring) —
#: read it through :func:`get_platform`, and change it only through :func:`set_platform`.
PLATFORM = REGULAR
def get_platform() -> Platform:
"""Return the active :class:`Platform`.
Safe to call from anywhere, at any time — always reflects the most recent
:func:`set_platform` call, however that module was imported.
"""
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 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.
Idempotent (setting the same platform twice is a no-op in effect) and reversible, so
tests can swap in a platform for one test and restore the previous one afterwards
(see the ``_reset_platform`` autouse fixture in ``tests/conftest.py``).
Args:
platform: The platform to make active.
"""
global PLATFORM
PLATFORM = platform
for binding in _BINDINGS:
binding(platform)
@dataclass(frozen=True, slots=True)
class Resolution:
"""What :func:`resolve` looked at and what it landed on.
Exists so the ``meshterm platform`` diagnostic can report every input the resolution
considered, not just the final answer.
Attributes:
flag: The ``--platform`` value passed on the command line, if any.
env: The ``MESHTERM_PLATFORM`` environment variable's value, if set.
detected_model: The ``/proc/device-tree/model`` contents read during resolution
(trailing NUL/space stripped), or ``None`` when the file doesn't exist (every
non-Lyra machine, including every desktop and CI runner).
source: Which input decided the outcome — ``"--platform flag"``,
``"MESHTERM_PLATFORM env var"``, ``"auto-detect"``, or ``"default"``.
platform: The resolved :class:`Platform`.
"""
flag: str | None
env: str | None
detected_model: str | None
source: str
platform: Platform
#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a trailing
#: space before the NUL; re-verified live on-device during P1 (2026-08-01) as exactly
#: ``b"Luckfox Lyra\x00"`` — no trailing space. :func:`_read_device_tree_model` strips
#: whatever surrounds it either way, so this is a documentation correction, not a behaviour
#: change — flagged here in case a different unit/firmware revision does carry one.
_LYRA_MODEL = "Luckfox Lyra"
_DEVICE_TREE_MODEL_PATH = Path("/proc/device-tree/model")
def _read_device_tree_model() -> str | None:
"""Read and normalize ``/proc/device-tree/model``, or ``None`` when it doesn't exist.
Absent on every machine that isn't running a device-tree-based Linux kernel — every
desktop, every CI runner, and (today) every ``ssh`` session onto one of those. This is
deliberately the *only* signal auto-detection consults (no terminal-size guessing), so
a small window on the desktop is never mistaken for the device.
"""
try:
raw = _DEVICE_TREE_MODEL_PATH.read_bytes()
except OSError:
return None
return raw.decode("utf-8", "replace").rstrip("\x00").strip()
def _lookup(name: str, *, source: str) -> Platform:
"""Resolve a platform name to its :class:`Platform`, or raise a clear ``ValueError``."""
try:
return _BY_NAME[name.strip().lower()]
except KeyError:
choices = ", ".join(sorted(_BY_NAME))
# Plain ASCII: this can surface through Typer/Click's own error path (raised
# before make_console() has reconfigured stdout/stderr to UTF-8), which mangles
# non-ASCII on a legacy Windows console the way make_console's docstring warns of.
raise ValueError(
f"unknown platform {name!r} (from {source}) - choose from: {choices}"
) from None
def resolve(flag: str | None = None) -> Resolution:
"""Decide which platform is active, and record what the decision looked at.
Resolution order: the explicit ``--platform`` flag, then the ``MESHTERM_PLATFORM``
environment variable, then device-tree auto-detection, then :data:`REGULAR`. Only the
input that actually decides the outcome is name-validated — an env var typo is
reported when the flag isn't set, but never blocks a session that passed an explicit
(correct) flag.
Args:
flag: The ``--platform`` command-line value, if given.
Returns:
The full :class:`Resolution`, for both immediate use (``.platform``) and the
``meshterm platform`` diagnostic's report.
Raises:
ValueError: The deciding input (flag or env var) named an unknown platform.
"""
env = os.environ.get("MESHTERM_PLATFORM") or None
model = _read_device_tree_model()
if flag:
return Resolution(flag, env, model, "--platform flag", _lookup(flag, source="--platform"))
if env:
return Resolution(
flag,
env,
model,
"MESHTERM_PLATFORM env var",
_lookup(env, source="MESHTERM_PLATFORM"),
)
if model == _LYRA_MODEL:
return Resolution(flag, env, model, "auto-detect", PICOCALC)
return Resolution(flag, env, model, "default", REGULAR)
def without_emoji(platform: Platform) -> Platform:
"""The same platform with its icons routed through the compact glyph table.
A terminal that cannot draw emoji is not a different *flavour* — the classic Windows
console has the desktop's width, colour depth and keyboard, and wants every other
thing :data:`REGULAR` says. Only the one flag moves, which is what the seam's flags
being independent is for: ``ascii_fold`` stays off, so accents and the rest of the
BMP still come through.
Args:
platform: The resolved platform.
Returns:
``platform`` itself when its icons are already compact, else a copy with
``emoji`` cleared.
"""
return platform if not platform.emoji else replace(platform, emoji=False)
__all__ = [
"Platform",
"REGULAR",
"PICOCALC",
"without_emoji",
"PLATFORM",
"get_platform",
"set_platform",
"on_platform",
"Resolution",
"resolve",
]