diff --git a/CLAUDE.md b/CLAUDE.md index a9e5074..58f14b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -659,6 +659,19 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT which the console host copies). Each chip is centred over its key, eight cells of label, white on the fn key's orange-red (a step darker than the print, same hue, so the white holds 4:1), and white on dark blue while Shift is held. +- **The Cardputer draws its own pixels** (`meshterm/host/`): the stock image leaves an app + no text console, only the framebuffer and the keyboard's event device. The console host + runs the ordinary TUI in-process — prompt_toolkit's app session pointed at a fixed 53×14 + output whose bytes feed the host's own VT parser (`host/vt.py`), and a pipe the host + types into (`host/keys.py`, xterm's spelling, so Shift+F4–F8 parse as F16–F20) — then + draws the cells in Terminus 6×12 with MeshTerm's marks over it (`host/font.py`). The + front end is the only part that differs: the framebuffer and evdev on the device + (`host/device.py`, written ahead of the hardware), a Tk window on the desktop + (`host/sim.py`): `python -m meshterm.host --mock`, after + `python scripts/cardputer/fetch-terminus.py` once. Its glyph contract is + `fontset.CARDPUTER_CODEPOINTS`, named by `Platform.font` as the PicoCalc's is; the fold + derives everything from the platform's font (accents, emoji, the `?` net) and quantizes + colour only where `truecolor` is off. - `meshterm specimen` prints the whole visual language through the real funnels — the acceptance card on-device, a preview under `--platform picocalc` on the desktop. - Dev loop: `meshterm --mock --platform picocalc` in a 53×40 window, or diff --git a/meshterm/cli.py b/meshterm/cli.py index 5b4096c..f99c4cf 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -36,7 +36,7 @@ from .core.preferences import ( from .core.selection import DeviceSelectionError from .persistence.logging import configure_logging, get_logger, level_from_name, log_path from .persistence.repository import Repository -from .platforms import Resolution, resolve, set_platform, without_emoji +from .platforms import Resolution, get_platform, resolve, set_platform, without_emoji from .tools import all_tools from .tools.base import Tool, ToolResult from .ui import renderers, script @@ -408,6 +408,8 @@ def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences """ from .ui.termfont import classic_console + if get_platform().console_host: + return False if not classic_console() or prefs.get("console_setup") == "off": return False if _move_to_windows_terminal(console): diff --git a/meshterm/host/__init__.py b/meshterm/host/__init__.py new file mode 100644 index 0000000..c4ef48d --- /dev/null +++ b/meshterm/host/__init__.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The console host: MeshTerm drawing its own pixels, for a panel no terminal runs on. + +The Cardputer Zero has no text console an app may use. Its launcher owns the screen, the +kernel's framebuffer console is switched off, and a store app may not run as root to +switch it back. What an app *does* get is the framebuffer itself and the keyboard's event +device. So MeshTerm brings its own terminal: this package runs the ordinary TUI in-process, +with prompt_toolkit writing to a fixed 53×14 output whose bytes go to a small VT parser +(:mod:`.vt`) rather than to any terminal, and reading from a pipe the host writes keys into +(:mod:`.keys`). The parsed cell grid is drawn into pixels with a 6×12 bitmap font +(:mod:`.font`, :mod:`.raster`), and the pixels go wherever the host is showing them: + +* on the device, the framebuffer the launcher hands over, with keys from the keyboard's + event device (:mod:`.device`); +* on a desktop, a window drawing the same 320×170 panel scaled up, with keys from the + desktop keyboard (:mod:`.sim`) — the simulator, which is the device's own code path + everywhere except the two ends. + +Nothing in the TUI knows it is hosted. The session already takes its input and output +from prompt_toolkit's app session wherever its own terminal wrappers are off, which they +are on this platform, so the host only has to set that session up and call the CLI the +way a shell would (:mod:`.run`). +""" diff --git a/meshterm/host/__main__.py b/meshterm/host/__main__.py new file mode 100644 index 0000000..d72ebd4 --- /dev/null +++ b/meshterm/host/__main__.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``python -m meshterm.host`` — run MeshTerm in the Cardputer Zero's console host. + + python -m meshterm.host [--sim [--scale N] | --device] [MESHTERM ARGS...] + +``--sim`` (the default off the device) opens the desktop simulator; ``--device`` draws to +the framebuffer and reads the keyboard, and is the default wherever the launcher has named +a framebuffer (``APPLAUNCH_LINUX_FBDEV_DEVICE``). Everything else is passed to MeshTerm as +it would be on a command line — ``--mock`` for the simulated mesh, ``--ble``/``--port``/ +``--tcp`` for a companion — with ``--platform cardputer`` supplied by the host. +""" + +from __future__ import annotations + +import os +import sys + + +def main(argv: list[str]) -> int: + """Parse the host's own flags, pass the rest to MeshTerm, and run it.""" + from .device import FB_ENV + from .run import run + + device = bool(os.environ.get(FB_ENV)) + scale = 3 + rest: list[str] = [] + args = iter(argv) + for arg in args: + if arg == "--sim": + device = False + elif arg == "--device": + device = True + elif arg == "--scale": + scale = max(1, int(next(args, "3"))) + else: + rest.append(arg) + if device: + from .device import front_end + else: + from .sim import front_end as sim_front_end + + def front_end(): + return sim_front_end(scale) + + try: + build = front_end() + except FileNotFoundError as missing: + print(f"meshterm host: {missing}", file=sys.stderr) + return 1 + return run(rest, build) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/meshterm/host/device.py b/meshterm/host/device.py new file mode 100644 index 0000000..3b5d202 --- /dev/null +++ b/meshterm/host/device.py @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: Apache-2.0 +"""On the Cardputer Zero: pixels to the framebuffer, keys from the keyboard's event device. + +The launcher starts an app with the screen to itself and tells it where things are: +``APPLAUNCH_LINUX_FBDEV_DEVICE`` names the panel's framebuffer and +``APPLAUNCH_LINUX_KEYBOARD_DEVICE`` its keyboard, which it shares rather than grabs — so the +app reads key events itself, while the launcher watches the same stream for a held Esc +(3 s, then SIGTERM to the app's process group). This front end follows that contract. + +**Written ahead of the hardware** (2026-09-30), from M5's published sources: the +framebuffer and keyboard paths and the Esc policy from the launcher, the key codes from the +keyboard driver's keymaps, and the Sym layer's characters from M5's console keymap +(``tca8418_keypad_m5stack_keymap.map``). The mapping is unit-tested; the I/O waits for a +device to run on. + +The keyboard driver does the Fn layer in the kernel, so Fn+4 arrives as a plain ``KEY_F4`` +and the arrows as real arrow keys; Shift is a real ``KEY_LEFTSHIFT`` held down for as long +as the sticky Shift is armed. Sym is the exception: its layer sends *placeholder* key codes +(``KEY_LEFTBRACE`` for ``!``, …) that M5's own keymap gives their symbols, so this module +gives them the same ones, and is the only place that knows. +""" + +from __future__ import annotations + +import os +import signal +import struct +import threading +from collections.abc import Callable +from pathlib import Path + +from ..services import modifier_watch +from .font import Font +from .keys import Key, encode +from .raster import Raster, rgb565 +from .run import DEFAULT_BG, DEFAULT_FG +from .vt import Terminal + +FB_ENV = "APPLAUNCH_LINUX_FBDEV_DEVICE" +KEYBOARD_ENV = "APPLAUNCH_LINUX_KEYBOARD_DEVICE" +#: Where the launcher's own default puts the keyboard, when no variable says. +DEFAULT_KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event" + +#: ``struct input_event`` on a 64-bit kernel: timeval, then type, code, value. +_EVENT = struct.Struct("llHHi") +_EV_KEY = 0x01 + +# Modifier key codes (linux/input-event-codes.h). +_SHIFT = {42, 54} +_CTRL = {29, 97} +_ALT = {56, 100} + +#: Key codes that are not text, by the name :mod:`.keys` spells them. +_NAMED: dict[int, str] = { + 1: "escape", + 14: "backspace", + 15: "tab", + 28: "enter", + 96: "enter", + 102: "home", + 103: "up", + 104: "pageup", + 105: "left", + 106: "right", + 107: "end", + 108: "down", + 109: "pagedown", + 110: "insert", + 111: "delete", + **{59 + n: f"f{n + 1}" for n in range(10)}, # KEY_F1..KEY_F10 + 87: "f11", + 88: "f12", +} + +#: The base layer's characters. +_TEXT: dict[int, str] = { + **{2 + n: "1234567890"[n] for n in range(10)}, + **{16 + n: "qwertyuiop"[n] for n in range(10)}, + **{30 + n: "asdfghjkl"[n] for n in range(9)}, + **{44 + n: "zxcvbnm"[n] for n in range(7)}, + 57: " ", +} + +#: The Sym layer's placeholder codes and the characters M5's console keymap gives them. +_SYM: dict[int, str] = { + 26: "!", 27: "@", 39: "#", 40: "$", 41: "%", 43: "^", 51: "&", 52: "*", + 53: "(", 94: ")", 55: "~", 69: "`", 70: "_", 71: "-", 72: "+", 73: "=", + 74: "[", 75: "]", 76: "{", 77: "}", 79: ";", 80: ":", 81: "'", 82: '"', + 83: "<", 85: ">", 86: "\\", 89: "|", 90: ",", 91: ".", 92: "/", 93: "?", +} # fmt: skip + + +class KeyState: + """Turns the keyboard's events into :class:`~.keys.Key` presses, tracking modifiers.""" + + def __init__(self) -> None: + """Start with no modifier held.""" + self._held: set[int] = set() + + @property + def shift(self) -> bool: + """Whether a Shift key is held — or armed, which the driver reports the same way.""" + return bool(self._held & _SHIFT) + + def event(self, code: int, value: int) -> Key | None: + """One ``EV_KEY`` event (``value`` 1 down, 2 repeat, 0 up): the press it makes.""" + if code in _SHIFT | _CTRL | _ALT: + if value: + self._held.add(code) + else: + self._held.discard(code) + return None + if value == 0: + return None + shift = self.shift + ctrl = bool(self._held & _CTRL) + alt = bool(self._held & _ALT) + name = _NAMED.get(code) + if name is not None: + return Key(name=name, shift=shift, ctrl=ctrl, alt=alt) + text = _SYM.get(code) or _TEXT.get(code) + if text is None: + return None # Fn and Sym themselves, media keys, brightness: nothing to type + if shift and text.isalpha(): + text = text.upper() + return Key(text=text, ctrl=ctrl, alt=alt) + + +class Framebuffer: + """The panel's framebuffer, written a band of pixel rows at a time.""" + + def __init__(self, path: str, *, width: int, height: int) -> None: + """Open the framebuffer at ``path`` for a ``width`` × ``height`` panel.""" + self.path = path + self._fd = os.open(path, os.O_RDWR) + stride = self._sysfs("stride") + self.stride = int(stride) if stride else width * 2 + self.width = width + self.height = height + + def _sysfs(self, name: str) -> str | None: + node = Path("/sys/class/graphics") / Path(self.path).name / name + try: + return node.read_text().strip() + except OSError: + return None + + def write(self, pixels: bytes | bytearray, row_bytes: int, first: int, last: int) -> None: + """Write pixel rows ``first`` to ``last`` (exclusive) of a ``row_bytes``-wide image.""" + view = memoryview(pixels) + for y in range(first, min(last, self.height)): + os.pwrite(self._fd, view[y * row_bytes : (y + 1) * row_bytes], y * self.stride) + + def close(self) -> None: + """Close the framebuffer.""" + os.close(self._fd) + + +class Device: + """The front end on the device: a framebuffer writer and a keyboard reader.""" + + def __init__( + self, + terminal: Terminal, + lock: threading.Lock, + type_text: Callable[[str], None], + *, + font: Font, + framebuffer: str, + keyboard: str, + ) -> None: + """Open the framebuffer, and start the drawing and key-reading threads.""" + self._lock = lock + self._type = type_text + self._raster = Raster( + terminal, font, pack=rgb565, default_fg=DEFAULT_FG, default_bg=DEFAULT_BG + ) + self._fb = Framebuffer(framebuffer, width=self._raster.width, height=self._raster.height) + self._keyboard = keyboard + self._ready = threading.Event() + self._closing = False + threading.Thread(target=self._draw, name="cardputer-fb", daemon=True).start() + threading.Thread(target=self._read_keys, name="cardputer-keys", daemon=True).start() + + def frame_ready(self) -> None: + """Wake the drawing thread: a frame is complete.""" + self._ready.set() + + def close(self) -> None: + """Stop both threads.""" + self._closing = True + self._ready.set() + + def _draw(self) -> None: + while not self._closing: + self._ready.wait() + self._ready.clear() + if self._closing: + break + with self._lock: + bands = self._raster.update() + pixels = bytes(self._raster.pixels) + for first, last in bands: + self._fb.write(pixels, self._raster.stride, first, last) + self._fb.close() + + def _read_keys(self) -> None: + state = KeyState() + with open(self._keyboard, "rb", buffering=0) as stream: + while not self._closing: + data = stream.read(_EVENT.size) + if len(data) < _EVENT.size: + break + _, _, kind, code, value = _EVENT.unpack(data) + if kind != _EV_KEY: + continue + was_shifted = state.shift + key = state.event(code, value) + if state.shift != was_shifted: + modifier_watch.report_shift(state.shift) + if key is not None: + sequence = encode(key) + if sequence: + self._type(sequence) + + +def _leave_on_sigterm() -> None: + """Treat the launcher's SIGTERM (a held Esc) as an interrupt, so MeshTerm cleans up.""" + signal.signal(signal.SIGTERM, signal.default_int_handler) + + +def front_end(): + """The device as a :data:`~.run.FrontEndFactory`, from the launcher's environment.""" + from .font import find_font + + font = find_font() + framebuffer = os.environ.get(FB_ENV) or "/dev/fb0" + keyboard = os.environ.get(KEYBOARD_ENV) or DEFAULT_KEYBOARD + _leave_on_sigterm() + + def build(terminal: Terminal, lock: threading.Lock, type_text: Callable[[str], None]): + return Device( + terminal, lock, type_text, font=font, framebuffer=framebuffer, keyboard=keyboard + ) + + return build diff --git a/meshterm/host/font.py b/meshterm/host/font.py new file mode 100644 index 0000000..9e0f50a --- /dev/null +++ b/meshterm/host/font.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The 6×12 bitmap font the console host draws cells in. + +Terminus at 12 pixels is the base: it is the PicoCalc's base too, so the two handhelds +read alike, and at 1,356 glyphs it covers everything MeshTerm draws on this platform but a +handful of its own marks. The host is not a kernel console, so nothing caps the glyph count +at 512 — no donor slots, no folding braille away. MeshTerm's marks are drawn over the base +from the same pixel art the PicoCalc's font build uses (:data:`MARKS`), so ``★``, ``❯``, +``⚿`` and the rest look the same on both devices. + +Terminus is licensed under the SIL Open Font License, so it is never shipped inside +MeshTerm: the host loads it from a BDF file on the machine (:func:`find_font`). On the +desktop, ``scripts/cardputer/fetch-terminus.py`` puts the two 12-pixel BDFs, with their +licence, where :func:`find_font` looks. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from ..core.config import default_config_dir + +#: The cell, in pixels. +CELL_W = 6 +CELL_H = 12 + +#: The file names :func:`find_font` looks for: regular, then bold. +REGULAR_BDF = "ter-u12n.bdf" +BOLD_BDF = "ter-u12b.bdf" + +#: Where a font lives when ``MESHTERM_HOST_FONTS`` names no other directory. +FONT_ENV = "MESHTERM_HOST_FONTS" + + +def _art(rows: list[str]) -> bytes: + """Pixel art (``#`` lit) as one byte per row, the leftmost pixel the high bit.""" + return bytes(sum(0x80 >> x for x, ch in enumerate(row) if ch == "#") for row in rows) + + +#: MeshTerm's own marks, drawn over the base font. The same pixel art as the PicoCalc's +#: font build (``scripts/picocalc/calculinux-console-font-6x12.sh``, ``MARKS``), so a mark +#: means one picture on every handheld. +# fmt: off +MARKS: dict[int, bytes] = { + 0x25CF: _art([ # BLACK CIRCLE -- node / unread + "......", "......", "..##..", ".####.", "######", "######", + "######", "######", ".####.", "..##..", "......", "......"]), + 0x25C9: _art([ # FISHEYE -- sensor + "......", "......", "..##..", ".####.", "##..##", "#.##.#", + "#.##.#", "##..##", ".####.", "..##..", "......", "......"]), + 0x2605: _art([ # BLACK STAR -- you / best + "......", "..##..", "..##..", "######", ".####.", "..##..", + ".####.", "##..##", "#....#", "......", "......", "......"]), + 0x2014: _art([ # EM DASH -- title separator, full width + "......", "......", "......", "......", "......", "######", + "######", "......", "......", "......", "......", "......"]), + 0x2713: _art([ # CHECK MARK -- ok + "......", "......", "......", ".....#", "....#.", "...#..", + "#.#...", ".#....", "......", "......", "......", "......"]), + 0x2717: _art([ # BALLOT X -- error + "......", "......", "......", "#....#", ".#..#.", "..##..", + "..##..", ".#..#.", "#....#", "......", "......", "......"]), + 0x25B6: _art([ # RIGHT-POINTING TRIANGLE -- run / play + "......", "#.....", "##....", "###...", "####..", "#####.", + "#####.", "####..", "###...", "##....", "#.....", "......"]), + 0x276F: _art([ # HEAVY RIGHT ANGLE QUOTE -- the list selection cursor + "......", "##....", ".##...", "..##..", "...##.", "....##", + "....##", "...##.", "..##..", ".##...", "##....", "......"]), + 0x25B8: _art([ # SMALL RIGHT-POINTING TRIANGLE -- grabbed/reorder cursor + "......", "......", "......", ".#....", ".##...", ".###..", + ".###..", ".##...", ".#....", "......", "......", "......"]), + 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 + "......", "......", ".####.", ".#..#.", "######", "######", + "##..##", "##..##", "######", "######", "......", "......"]), +} +# fmt: on + +#: Codepoints drawn as another's glyph, as the PicoCalc's font aliases them. +ALIASES: dict[int, int] = { + 0x22EF: 0x2026, # midline ellipsis -> the ellipsis mark +} + +#: What a character the font has no glyph for is drawn as: a hollow box, so a gap in the +#: font is visible rather than silently blank. +MISSING = _art( + ["......", "#####.", "#...#.", "#...#.", "#...#.", "#...#."] + + ["#...#.", "#...#.", "#...#.", "#####.", "......", "......"] +) + + +def load_bdf(path: Path) -> dict[int, bytes]: + """The glyphs of a BDF font sized to the 6×12 cell, by codepoint. + + Each glyph is one byte per pixel row, the leftmost pixel the high bit, placed in the + cell by its bounding box against the font's ascent — so a glyph narrower or shorter + than the cell sits where the font's designer put it. + + Raises: + OSError: The file can't be read. + ValueError: It isn't a BDF font. + """ + glyphs: dict[int, bytes] = {} + ascent = CELL_H - 2 + encoding = -1 + box = (CELL_W, CELL_H, 0, -2) + bitmap: list[int] | None = None + with path.open(encoding="latin-1") as handle: + first = handle.readline() + if not first.startswith("STARTFONT"): + raise ValueError(f"{path} is not a BDF font") + for raw in handle: + words = raw.split() + if not words: + continue + key = words[0] + if bitmap is not None: + if key == "ENDCHAR": + glyphs[encoding] = _place(bitmap, box, ascent) + bitmap = None + else: + bitmap.append(int(key, 16)) + elif key == "FONT_ASCENT": + ascent = int(words[1]) + elif key == "ENCODING": + encoding = int(words[1]) + elif key == "BBX": + box = (int(words[1]), int(words[2]), int(words[3]), int(words[4])) + elif key == "BITMAP": + bitmap = [] + glyphs.pop(-1, None) + return glyphs + + +def _place(bitmap: list[int], box: tuple[int, int, int, int], ascent: int) -> bytes: + """One glyph's BDF rows, set into the 6×12 cell.""" + width, height, x_off, y_off = box + row_bits = max(8, (width + 7) // 8 * 8) + top = ascent - (y_off + height) + rows = [0] * CELL_H + for index, bits in enumerate(bitmap[:height]): + y = top + index + if 0 <= y < CELL_H: + # Left-align the row in its byte, then shift right by the glyph's x offset. + value = bits >> (row_bits - 8) if row_bits > 8 else bits + rows[y] = (value >> max(0, x_off)) & 0xFF + return bytes(rows) + + +@dataclass(frozen=True, slots=True) +class Font: + """A regular and a bold face, each a codepoint → 12-byte glyph table.""" + + regular: dict[int, bytes] + bold: dict[int, bytes] + + def glyph(self, char: str, *, bold: bool = False) -> bytes: + """The glyph for ``char`` (its first codepoint), or :data:`MISSING`.""" + if not char: + return bytes(CELL_H) + code = ord(char[0]) + code = ALIASES.get(code, code) + table = self.bold if bold else self.regular + found = table.get(code) + if found is None and bold: + found = self.regular.get(code) + return MISSING if found is None else found + + def has(self, code: int) -> bool: + """Whether the font draws codepoint ``code`` with a glyph of its own.""" + return ALIASES.get(code, code) in self.regular + + +def build_font(regular: dict[int, bytes], bold: dict[int, bytes] | None = None) -> Font: + """A :class:`Font` from base glyph tables, with MeshTerm's marks drawn over both. + + A missing bold face reuses the regular one, emboldened by a one-pixel smear. + """ + regular = {**regular, **MARKS} + if bold is None: + bold = {code: bytes(row | row >> 1 for row in rows) for code, rows in regular.items()} + else: + bold = {**bold, **MARKS} + return Font(regular=regular, bold=bold) + + +def font_dir() -> Path: + """Where :func:`find_font` looks: ``$MESHTERM_HOST_FONTS``, else ``fonts`` in the config dir.""" + override = os.environ.get(FONT_ENV, "").strip() + return Path(override) if override else default_config_dir() / "fonts" + + +def find_font() -> Font: + """Load the host font from :func:`font_dir`. + + Raises: + FileNotFoundError: There is no regular face there, with how to fetch one. + """ + directory = font_dir() + regular_path = directory / REGULAR_BDF + if not regular_path.is_file(): + raise FileNotFoundError( + f"no {REGULAR_BDF} in {directory} -- fetch Terminus with " + "`python scripts/cardputer/fetch-terminus.py` (or set " + f"{FONT_ENV} to a directory holding it)" + ) + bold_path = directory / BOLD_BDF + bold = load_bdf(bold_path) if bold_path.is_file() else None + return build_font(load_bdf(regular_path), bold) diff --git a/meshterm/host/keys.py b/meshterm/host/keys.py new file mode 100644 index 0000000..452df13 --- /dev/null +++ b/meshterm/host/keys.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Keys to the bytes a terminal sends for them — what the console host types into MeshTerm. + +Both ends of the host meet here: the device reads key codes off the keyboard's event +device, the simulator reads them off a desktop window, and each turns its own events into +a :class:`Key` for :func:`encode` to spell the way xterm does. xterm's spelling is the +contract because it is what prompt_toolkit parses, and what a desktop terminal running +``--platform cardputer`` sends too, so the TUI cannot tell the three apart: + +* Shift with F4–F8 is xterm's ``CSI 1;2 S`` / ``CSI 15;2 ~`` …, which prompt_toolkit + reads as F16–F20 — the Cardputer deck's Shift bank + (:data:`~meshterm.ui.tui.fkeys.CARDPUTER_DECK`). +* A modified arrow, Home, End, Page key or F-key carries xterm's modifier parameter + (``1 + Shift + 2·Alt + 4·Ctrl``), so Ctrl+PgUp is ``CSI 5;5 ~``, the section jump. +* Ctrl with a letter is its C0 control; Alt with anything is ESC then the key. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +#: The keys that are not text, by name. Arrows, Home and End take xterm's ``CSI 1;m X`` +#: modified form; the rest take ``CSI n;m ~``. +_CURSOR_FINAL = {"up": "A", "down": "B", "right": "C", "left": "D", "home": "H", "end": "F"} +_TILDE = { + "insert": 2, + "delete": 3, + "pageup": 5, + "pagedown": 6, + "f5": 15, + "f6": 17, + "f7": 18, + "f8": 19, + "f9": 20, + "f10": 21, + "f11": 23, + "f12": 24, +} +_SS3 = {"f1": "P", "f2": "Q", "f3": "R", "f4": "S"} +_PLAIN = {"escape": "\x1b", "enter": "\r", "tab": "\t", "backspace": "\x7f"} + + +@dataclass(frozen=True, slots=True) +class Key: + """One key press: a named key (``"up"``, ``"f4"``, ``"enter"``) or the text it types.""" + + name: str = "" + text: str = "" + shift: bool = False + ctrl: bool = False + alt: bool = False + + +def encode(key: Key) -> str: + """The bytes a terminal sends for ``key``, or ``""`` for one it has no spelling for.""" + modifier = 1 + key.shift + 2 * key.alt + 4 * key.ctrl + name = key.name + if name in _CURSOR_FINAL: + final = _CURSOR_FINAL[name] + return f"\x1b[{final}" if modifier == 1 else f"\x1b[1;{modifier}{final}" + if name in _SS3: + final = _SS3[name] + return f"\x1bO{final}" if modifier == 1 else f"\x1b[1;{modifier}{final}" + if name in _TILDE: + number = _TILDE[name] + return f"\x1b[{number}~" if modifier == 1 else f"\x1b[{number};{modifier}~" + if name == "tab" and key.shift: + return "\x1b[Z" + if name in _PLAIN: + sequence = _PLAIN[name] + return "\x1b" + sequence if key.alt and name != "escape" else sequence + text = key.text + if not text: + return "" + if key.ctrl and len(text) == 1: + lower = text.lower() + if "a" <= lower <= "z": + text = chr(ord(lower) - 96) + elif text in "@[\\]^_": + text = chr(ord(text) & 0x1F) + elif text == " ": + text = "\x00" + return "\x1b" + text if key.alt else text diff --git a/meshterm/host/raster.py b/meshterm/host/raster.py new file mode 100644 index 0000000..10fef67 --- /dev/null +++ b/meshterm/host/raster.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Cells to pixels: the panel the console host shows, drawn from a :class:`~.vt.Terminal`. + +The panel is 320×170; 53 columns of 6 pixels and 14 rows of 12 leave a two-pixel margin +right and bottom, which this splits evenly so the grid sits centred. Pixels are packed +straight into the byte layout the destination wants — 16-bit RGB565 for the Cardputer's +framebuffer, 24-bit RGB for a desktop window — so nothing converts a whole frame on its +way out. + +Only rows the terminal reports dirty are redrawn, and a glyph row is drawn by lookup, not +per pixel: every (6-bit row pattern, foreground, background) triple is packed once and +reused, which is what keeps a full repaint affordable on a 1 GHz core. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from .font import CELL_H, CELL_W, Font +from .vt import BOLD, DIM, HIDDEN, REVERSE, RGB, STRIKE, UNDERLINE, Terminal + +#: The Cardputer Zero's panel, in pixels. +PANEL_W = 320 +PANEL_H = 170 + +#: A packer: one colour to the destination's bytes for one pixel. +Packer = Callable[[RGB], bytes] + + +def rgb565(colour: RGB) -> bytes: + """``colour`` as one little-endian RGB565 pixel (the Cardputer framebuffer's format).""" + r, g, b = colour + value = (r >> 3) << 11 | (g >> 2) << 5 | b >> 3 + return value.to_bytes(2, "little") + + +def rgb888(colour: RGB) -> bytes: + """``colour`` as one 24-bit RGB pixel (a desktop window's format).""" + return bytes(colour) + + +def _blend(a: RGB, b: RGB) -> RGB: + """Halfway from ``a`` to ``b`` — how a dim cell's ink is drawn.""" + return ((a[0] + b[0]) // 2, (a[1] + b[1]) // 2, (a[2] + b[2]) // 2) + + +class Raster: + """The panel's pixels, kept in step with a terminal. + + Args: + terminal: The grid to draw. + font: The cell font. + pack: How one pixel is laid out in :attr:`pixels`. + default_fg: The ink of a cell whose style names none. + default_bg: The paper of a cell whose style names none. + width: Panel width in pixels. + height: Panel height in pixels. + """ + + def __init__( + self, + terminal: Terminal, + font: Font, + *, + pack: Packer = rgb888, + default_fg: RGB = (170, 170, 170), + default_bg: RGB = (0, 0, 0), + width: int = PANEL_W, + height: int = PANEL_H, + ) -> None: + """Lay the grid out on the panel and paint it blank.""" + self.terminal = terminal + self.font = font + self.pack = pack + self.default_fg = default_fg + self.default_bg = default_bg + self.width = width + self.height = height + self.bpp = len(pack((0, 0, 0))) + self.stride = width * self.bpp + self.left = (width - terminal.cols * CELL_W) // 2 + self.top = (height - terminal.rows * CELL_H) // 2 + self.pixels = bytearray(pack(default_bg) * (width * height)) + self._spans: dict[tuple[int, RGB, RGB], bytes] = {} + + def _span(self, bits: int, fg: RGB, bg: RGB) -> bytes: + """One glyph row's six pixels, packed (cached by pattern and colours).""" + key = (bits, fg, bg) + span = self._spans.get(key) + if span is None: + ink, paper = self.pack(fg), self.pack(bg) + span = b"".join(ink if bits & (0x80 >> x) else paper for x in range(CELL_W)) + if len(self._spans) > 65536: + self._spans.clear() + self._spans[key] = span + return span + + def update(self) -> list[tuple[int, int]]: + """Redraw the rows the terminal says changed. + + Returns: + The pixel row bands ``(first_y, last_y_exclusive)`` that changed, in order, + for a destination that writes only what moved. + """ + rows = sorted(self.terminal.take_dirty()) + for row in rows: + self._draw_row(row) + bands: list[tuple[int, int]] = [] + for row in rows: + y0 = self.top + row * CELL_H + if bands and bands[-1][1] == y0: + bands[-1] = (bands[-1][0], y0 + CELL_H) + else: + bands.append((y0, y0 + CELL_H)) + return bands + + def redraw(self) -> None: + """Mark every row dirty and redraw the whole panel.""" + self.terminal.take_dirty() + for row in range(self.terminal.rows): + self._draw_row(row) + + def _draw_row(self, row: int) -> None: + cells = self.terminal.screen[row] + y0 = self.top + row * CELL_H + stride, bpp = self.stride, self.bpp + pixels = self.pixels + for col, (char, style) in enumerate(cells): + fg = style.fg or self.default_fg + bg = style.bg or self.default_bg + flags = style.flags + if flags & REVERSE: + fg, bg = bg, fg + if flags & DIM: + fg = _blend(fg, bg) + if flags & HIDDEN or char == "": + glyph = bytes(CELL_H) + else: + glyph = self.font.glyph(char, bold=bool(flags & BOLD)) + if flags & UNDERLINE: + glyph = glyph[: CELL_H - 2] + b"\xfc" + glyph[CELL_H - 1 :] + if flags & STRIKE: + glyph = glyph[:6] + b"\xfc" + glyph[7:] + x0 = (self.left + col * CELL_W) * bpp + for line in range(CELL_H): + at = (y0 + line) * stride + x0 + pixels[at : at + CELL_W * bpp] = self._span(glyph[line], fg, bg) diff --git a/meshterm/host/run.py b/meshterm/host/run.py new file mode 100644 index 0000000..211a3e3 --- /dev/null +++ b/meshterm/host/run.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Run MeshTerm inside the console host: the TUI unchanged, its terminal ours. + +:func:`run` builds the three pieces every host shares — the :class:`~.vt.Terminal` the +TUI's bytes land in, a :class:`HostOutput` prompt_toolkit writes to, and a pipe input the +host's keys go into — sets them as prompt_toolkit's app session, and calls the ordinary +CLI with ``--platform cardputer`` exactly as a shell would. A *front end* is the part that +differs: where the pixels go and where the keys come from. It is handed the terminal, the +lock that guards it and a way to type, starts whatever threads it needs, and is told each +time a frame is complete. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Sequence +from typing import Protocol + +from prompt_toolkit.application.current import create_app_session +from prompt_toolkit.data_structures import Size +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output.color_depth import ColorDepth +from prompt_toolkit.output.vt100 import Vt100_Output + +from ..platforms import CARDPUTER +from .vt import RGB, Terminal + + +def _hex(colour: str) -> RGB: + raw = colour.removeprefix("#") + return (int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)) + + +def _palette() -> tuple[RGB, ...]: + """The 16 named colours: the same RGBs the PicoCalc's console is programmed with.""" + from ..ui.theme import _VT_SLOTS + + return tuple(_hex(rgb) for _, _, rgb in _VT_SLOTS) + + +#: The ink of a cell whose style names none — a desktop terminal's default foreground, +#: which is what the truecolor theme this platform runs was designed against. +DEFAULT_FG: RGB = (204, 204, 204) +#: The paper of a cell whose style names none. +DEFAULT_BG: RGB = (0, 0, 0) + + +class _Sink: + """The text stream a :class:`Vt100_Output` writes to: bytes into the terminal.""" + + encoding = "utf-8" + + def __init__(self, terminal: Terminal, lock: threading.Lock, on_frame: Callable[[], None]): + self._terminal = terminal + self._lock = lock + self._on_frame = on_frame + self._pending: list[str] = [] + + def write(self, data: str) -> int: + self._pending.append(data) + return len(data) + + def flush(self) -> None: + if not self._pending: + return + data = "".join(self._pending) + self._pending.clear() + with self._lock: + self._terminal.feed(data) + self._on_frame() + + def isatty(self) -> bool: + return True + + def fileno(self) -> int: + raise OSError("the console host's output has no file descriptor") + + +def host_output( + terminal: Terminal, lock: threading.Lock, on_frame: Callable[[], None] +) -> Vt100_Output: + """A prompt_toolkit output drawing into ``terminal``: fixed size, 24-bit colour.""" + size = Size(rows=terminal.rows, columns=terminal.cols) + return Vt100_Output( + _Sink(terminal, lock, on_frame), # type: ignore[arg-type] + lambda: size, + term="xterm-256color", + default_color_depth=ColorDepth.TRUE_COLOR, + enable_cpr=False, + ) + + +class FrontEnd(Protocol): + """Where a host's pixels go and its keys come from.""" + + def frame_ready(self) -> None: + """A complete frame has landed in the terminal; draw it when convenient.""" + + def close(self) -> None: + """The TUI has ended; stop.""" + + +#: Builds a front end from the terminal, its lock, and a function that types into the TUI. +FrontEndFactory = Callable[[Terminal, threading.Lock, Callable[[str], None]], FrontEnd] + + +def run(argv: Sequence[str], front_end: FrontEndFactory) -> int: + """Run the MeshTerm CLI with ``argv`` inside the host, drawn by ``front_end``. + + Returns: + The CLI's exit status. + """ + from .. import cli + + lock = threading.Lock() + with create_pipe_input() as pipe: + terminal = Terminal( + CARDPUTER.readable_cols, + CARDPUTER.readable_rows, + palette=_palette(), + reply=pipe.send_text, + ) + end = front_end(terminal, lock, pipe.send_text) + output = host_output(terminal, lock, end.frame_ready) + try: + with create_app_session(input=pipe, output=output): + # Standalone, as a shell would run it: the CLI reports its own errors and + # ends by raising SystemExit with the status. + cli.app(args=["--platform", "cardputer", *argv], windows_expand_args=False) + return 0 + except SystemExit as done: + return done.code if isinstance(done.code, int) else (0 if done.code is None else 1) + finally: + end.close() diff --git a/meshterm/host/sim.py b/meshterm/host/sim.py new file mode 100644 index 0000000..820e26d --- /dev/null +++ b/meshterm/host/sim.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The desktop simulator: the Cardputer Zero's panel in a window, keys from the desktop. + +Everything between the two ends is the device's own code — the terminal, the font, the +rasterizer, the key encoding — so what the window shows is the panel's pixels, scaled up +without smoothing, and what the desktop keyboard types is what the device's keyboard will. +Under the panel it draws the five keys the F-key lane sits over, 4 to 8, at the positions +M5's drawing gives them, so a chip that drifts off its key is visible. + +The desktop's own F4–F8 stand in for Fn+4…8, and Shift with them is the second bank; the +window also reports Shift to the lane as it goes down and up, so the bank flips on screen +the way it will on the device. Closing the window leaves MeshTerm at once, as ^Q twice +would. Ctrl+Shift+S saves the panel at its true 320×170 as a PNG in the working directory. + +Tk runs on a thread of its own, the TUI on the main thread; the two meet only through the +terminal's lock, a flag saying a frame is ready, and the function that types. +""" + +from __future__ import annotations + +import sys +import threading +import time +from collections.abc import Callable +from pathlib import Path + +from ..services import modifier_watch +from .font import Font +from .keys import Key, encode +from .raster import PANEL_H, PANEL_W, Raster +from .run import DEFAULT_BG, DEFAULT_FG +from .vt import Terminal + +#: The lane keys' centres on the panel, in panel pixels (M5's screen-key drawing). +_LANE_KEYS = ((4, 48), (5, 104), (6, 160), (7, 216), (8, 272)) + +_NAMED = { + "Up": "up", + "Down": "down", + "Left": "left", + "Right": "right", + "Home": "home", + "End": "end", + "Prior": "pageup", + "Next": "pagedown", + "Insert": "insert", + "Delete": "delete", + "Escape": "escape", + "Return": "enter", + "KP_Enter": "enter", + "Tab": "tab", + "ISO_Left_Tab": "tab", + "BackSpace": "backspace", + **{f"F{n}": f"f{n}" for n in range(1, 13)}, +} + +_SHIFTS = ("Shift_L", "Shift_R") + +#: Tk's modifier bits: Shift and Control everywhere, Alt where the platform puts it. +_SHIFT_BIT = 0x0001 +_CTRL_BIT = 0x0004 +_ALT_BIT = 0x20000 if sys.platform == "win32" else 0x0008 + + +def key_from_tk(keysym: str, char: str, state: int) -> Key | None: + """A Tk key event as a :class:`Key`, or ``None`` for one that types nothing.""" + shift = bool(state & _SHIFT_BIT) or keysym == "ISO_Left_Tab" + ctrl = bool(state & _CTRL_BIT) + alt = bool(state & _ALT_BIT) + name = _NAMED.get(keysym) + if name is not None: + return Key(name=name, shift=shift, ctrl=ctrl, alt=alt) + if ctrl and len(keysym) == 1: + return Key(text=keysym, ctrl=True, alt=alt) + if keysym == "space" and ctrl: + return Key(text=" ", ctrl=True, alt=alt) + if char and char.isprintable(): + return Key(text=char, alt=alt) + return None + + +class Simulator: + """A window showing the panel, typing the desktop's keys into the TUI.""" + + def __init__( + self, + terminal: Terminal, + lock: threading.Lock, + type_text: Callable[[str], None], + *, + font: Font, + scale: int = 3, + ) -> None: + """Open the window on a thread of its own, and wait until it is up.""" + self._terminal = terminal + self._lock = lock + self._type = type_text + self._scale = scale + self._raster = Raster(terminal, font, default_fg=DEFAULT_FG, default_bg=DEFAULT_BG) + self._ready = threading.Event() + self._ready.set() + self._closing = False + self._started = threading.Event() + threading.Thread(target=self._main, name="cardputer-sim", daemon=True).start() + self._started.wait(5) + + # --- the TUI's side --------------------------------------------------------------- + + def frame_ready(self) -> None: + """Note that a frame is complete; the window draws it on its next tick.""" + self._ready.set() + + def close(self) -> None: + """Close the window on its next tick.""" + self._closing = True + + # --- the window's side ------------------------------------------------------------ + + def _main(self) -> None: + import tkinter as tk + + scale = self._scale + margin = 6 * scale + keys_h = 26 * scale + root = tk.Tk() + root.title("MeshTerm — Cardputer Zero simulator") + root.configure(background="#2b2f36") + root.resizable(False, False) + width = PANEL_W * scale + 2 * margin + height = PANEL_H * scale + 2 * margin + keys_h + canvas = tk.Canvas( + root, width=width, height=height, background="#2b2f36", highlightthickness=0 + ) + canvas.pack() + base = tk.PhotoImage(width=PANEL_W, height=PANEL_H) + shown = base.zoom(scale, scale) + canvas.create_image(margin, margin, image=shown, anchor="nw", tags="panel") + self._draw_keys(canvas, margin, scale) + self._tk = (root, canvas, base, shown) + root.bind("", self._on_press) + root.bind("", self._on_release) + root.protocol("WM_DELETE_WINDOW", self._on_close) + root.focus_force() + self._started.set() + root.after(16, self._tick) + root.mainloop() + + def _draw_keys(self, canvas, margin: int, scale: int) -> None: + top = margin + PANEL_H * scale + 4 * scale + size = 18 * scale + for number, centre in _LANE_KEYS: + x = margin + centre * scale + canvas.create_line(x, top - 3 * scale, x, top, fill="#e34b0f", width=scale) + canvas.create_rectangle( + x - size // 2, top, x + size // 2, top + size, + fill="#1d2025", outline="#e34b0f", width=scale, + ) # fmt: skip + canvas.create_text( + x, top + 5 * scale, text=f"F{number}", fill="#ff6633", + font=("Consolas", 4 * scale, "bold"), + ) # fmt: skip + canvas.create_text( + x, top + 12 * scale, text=str(number), fill="#eef1f4", + font=("Consolas", 6 * scale, "bold"), + ) # fmt: skip + + def _tick(self) -> None: + root, canvas, base, _ = self._tk + if self._closing: + root.destroy() + return + if self._ready.is_set(): + self._ready.clear() + with self._lock: + bands = self._raster.update() + pixels = bytes(self._raster.pixels) + if bands: + header = f"P6 {PANEL_W} {PANEL_H} 255\n".encode() + base.configure(data=header + pixels, format="PPM") + shown = base.zoom(self._scale, self._scale) + canvas.itemconfigure("panel", image=shown) + self._tk = (root, canvas, base, shown) + root.after(16, self._tick) + + def _on_press(self, event) -> None: + if event.keysym in _SHIFTS: + modifier_watch.report_shift(True) + return + if event.keysym in ("S", "s") and event.state & _CTRL_BIT and event.state & _SHIFT_BIT: + self._save() + return + key = key_from_tk(event.keysym, event.char, event.state) + if key is not None: + data = encode(key) + if data: + self._type(data) + + def _on_release(self, event) -> None: + if event.keysym in _SHIFTS: + modifier_watch.report_shift(False) + + def _on_close(self) -> None: + # ^Q asks; a second ^Q while it asks leaves at once. + self._type("\x11") + time.sleep(0.2) + self._type("\x11") + + def _save(self) -> None: + _, _, base, _ = self._tk + path = Path.cwd() / f"cardputer-{time.strftime('%Y%m%d-%H%M%S')}.png" + base.write(str(path), format="png") + + +def front_end(scale: int = 3): + """The simulator as a :data:`~.run.FrontEndFactory`, loading the host font first.""" + from .font import find_font + + font = find_font() + + def build(terminal: Terminal, lock: threading.Lock, type_text: Callable[[str], None]): + return Simulator(terminal, lock, type_text, font=font, scale=scale) + + return build + + +__all__ = ["Simulator", "front_end", "key_from_tk"] diff --git a/meshterm/host/vt.py b/meshterm/host/vt.py new file mode 100644 index 0000000..d8f21c7 --- /dev/null +++ b/meshterm/host/vt.py @@ -0,0 +1,533 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The terminal the console host draws: a VT byte stream parsed into a grid of cells. + +Small on purpose. Its one producer is MeshTerm itself — prompt_toolkit's ``Vt100_Output`` +and the session's own fast row writer — so it understands what those emit and the handful +of neighbours a stray library might, and quietly ignores the rest of the VT220/xterm +universe: cursor movement and addressing, erase and insert/delete, scroll regions, the +alternate screen, autowrap, and SGR in all three colour depths (16, 256, and truecolor, +with ``;`` or ``:`` separators). What it does not model it skips by the grammar — a +whole escape sequence, never part of one — so an unknown sequence can never leak its +parameters onto the screen as text. + +The grid is the source of truth for the pixels: :class:`Terminal` records which rows +changed since the host last drew (:meth:`Terminal.take_dirty`), so a keystroke that moves +the highlight one row repaints two rows of the panel, not fourteen. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +from rich.cells import cell_len + +#: A colour, as 8-bit channels. +RGB = tuple[int, int, int] + +#: Style flags (:attr:`Style.flags` is a bitmask of these). +BOLD = 1 +DIM = 2 +ITALIC = 4 +UNDERLINE = 8 +REVERSE = 16 +HIDDEN = 32 +STRIKE = 64 + + +@dataclass(frozen=True, slots=True) +class Style: + """How a cell is drawn: its colours (``None`` = the terminal's default) and flags.""" + + fg: RGB | None = None + bg: RGB | None = None + flags: int = 0 + + +#: The style a fresh or erased cell has. +PLAIN = Style() + +#: A cell: the character drawn there (``""`` for the right half of a wide character) +#: and its style. +Cell = tuple[str, Style] + +_BLANK: Cell = (" ", PLAIN) + +#: A conventional 16-colour palette, for a host that passes none of its own. +_DEFAULT_PALETTE: tuple[RGB, ...] = ( + (0, 0, 0), (205, 0, 0), (0, 205, 0), (205, 205, 0), + (0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229), + (127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (92, 92, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), +) # fmt: skip + +_CUBE_LEVELS = (0, 95, 135, 175, 215, 255) + +# Parser states. +_GROUND, _ESC, _CSI, _OSC, _CHARSET, _STRING = range(6) + + +def _xterm_256(index: int, palette: Sequence[RGB]) -> RGB: + """The colour xterm's 256-colour index ``index`` names.""" + if index < 16: + return palette[index] + if index < 232: + index -= 16 + return ( + _CUBE_LEVELS[index // 36], + _CUBE_LEVELS[index // 6 % 6], + _CUBE_LEVELS[index % 6], + ) + level = 8 + 10 * (index - 232) + return (level, level, level) + + +class Terminal: + """A ``cols`` × ``rows`` character grid driven by a VT byte stream. + + Args: + cols: Width in cells. + rows: Height in cells. + palette: The 16 colours SGR 30–37/90–97 (and 256-colour indices 0–15) name. + reply: Called with the bytes a real terminal would answer a query with (a + cursor-position report, a device-attributes reply); ``None`` drops them. + """ + + def __init__( + self, + cols: int, + rows: int, + *, + palette: Sequence[RGB] = _DEFAULT_PALETTE, + reply: Callable[[str], None] | None = None, + ) -> None: + """Start blank, the cursor home, in the main screen.""" + self.cols = cols + self.rows = rows + self.palette = tuple(palette) + self._reply = reply + self._main = self._fresh_screen() + self._alt = self._fresh_screen() + self.screen = self._main + self.x = 0 + self.y = 0 + self.style = PLAIN + self.cursor_visible = True + self.autowrap = True + self.application_cursor = False + self._wrap_pending = False + self._top = 0 + self._bottom = rows - 1 + self._saved: tuple[int, int, Style] = (0, 0, PLAIN) + self._state = _GROUND + self._params = "" + self._dirty: set[int] = set(range(rows)) + + # --- the grid, as the host reads it ------------------------------------------------ + + def _fresh_screen(self) -> list[list[Cell]]: + return [[_BLANK] * self.cols for _ in range(self.rows)] + + def take_dirty(self) -> set[int]: + """The rows changed since the last call, which this call forgets.""" + dirty, self._dirty = self._dirty, set() + return dirty + + def line(self, row: int) -> str: + """Row ``row`` as plain text, wide characters once each.""" + return "".join(char for char, _ in self.screen[row]) + + def text(self) -> str: + """The whole screen as plain text, one line per row.""" + return "\n".join(self.line(row) for row in range(self.rows)) + + # --- input -------------------------------------------------------------------------- + + def feed(self, data: str) -> None: + """Parse ``data``, updating the grid.""" + for char in data: + state = self._state + if state == _GROUND: + if char >= " " and char != "\x7f": + self._print(char) + elif char == "\x1b": + self._state = _ESC + else: + self._control(char) + elif state == _CSI: + if "\x40" <= char <= "\x7e": + self._state = _GROUND + self._csi(self._params, char) + elif char == "\x1b": + self._state = _ESC + elif char in "\x18\x1a": + self._state = _GROUND # CAN/SUB abort the sequence + elif char >= " ": + self._params += char + else: + self._control(char) # C0 controls act even mid-sequence + elif state == _ESC: + self._escape(char) + elif state == _OSC: + if char == "\x07": + self._state = _GROUND + elif char == "\x1b": + self._state = _STRING # ST is ESC \, which _STRING finishes + elif state == _STRING: + if char == "\x1b": + self._state = _ESC + elif char == "\x07": + self._state = _GROUND + else: # _CHARSET: the designator's one final character + self._state = _GROUND + + def _escape(self, char: str) -> None: + self._state = _GROUND + if char == "[": + self._state, self._params = _CSI, "" + elif char == "]": + self._state = _OSC + elif char in "()*+-./": + self._state = _CHARSET + elif char in "P^_X": + self._state = _STRING + elif char == "7": + self._saved = (self.x, self.y, self.style) + elif char == "8": + self._restore() + elif char == "D": + self._linefeed() + elif char == "E": + self.x = 0 + self._linefeed() + elif char == "M": + self._reverse_index() + elif char == "c": + self.__init__(self.cols, self.rows, palette=self.palette, reply=self._reply) + # "=", ">", "\\" and anything else: nothing to draw. + + def _control(self, char: str) -> None: + if char == "\r": + self.x = 0 + self._wrap_pending = False + elif char in "\n\x0b\x0c": + self._linefeed() + elif char == "\b": + self.x = max(0, self.x - 1) + self._wrap_pending = False + elif char == "\t": + self.x = min(self.cols - 1, (self.x // 8 + 1) * 8) + self._wrap_pending = False + # BEL, SO/SI and the rest draw nothing. + + # --- drawing -------------------------------------------------------------------------- + + def _print(self, char: str) -> None: + width = cell_len(char) + row = self.screen[self.y] + if width == 0: + # A combining mark rides the cell before it. + if self.x > 0 or self._wrap_pending: + col = self.x if self._wrap_pending else self.x - 1 + base, style = row[col] + row[col] = (base + char, style) + self._dirty.add(self.y) + return + if self._wrap_pending or (width == 2 and self.x == self.cols - 1): + if self.autowrap: + self.x = 0 + self._linefeed() + row = self.screen[self.y] + self._wrap_pending = False + x = self.x + self._unsplit(row, x) + row[x] = (char, self.style) + if width == 2 and x + 1 < self.cols: + self._unsplit(row, x + 1) + row[x + 1] = ("", self.style) + self._dirty.add(self.y) + x += width + if x >= self.cols: + self.x = self.cols - 1 + self._wrap_pending = self.autowrap + else: + self.x = x + + def _unsplit(self, row: list[Cell], x: int) -> None: + """Before ``x`` is overwritten, blank whatever half of a wide character it was.""" + char, style = row[x] + if char == "" and x > 0: + row[x - 1] = (" ", row[x - 1][1]) + elif x + 1 < self.cols and row[x + 1][0] == "" and cell_len(char) == 2: + row[x + 1] = (" ", style) + + def _erase_style(self) -> Style: + """What an erased cell carries: the current background, nothing else.""" + return PLAIN if self.style.bg is None else Style(bg=self.style.bg) + + def _erase(self, row: int, first: int, last: int) -> None: + cell = (" ", self._erase_style()) + line = self.screen[row] + for x in range(max(0, first), min(self.cols, last + 1)): + line[x] = cell + self._dirty.add(row) + + def _linefeed(self) -> None: + self._wrap_pending = False + if self.y == self._bottom: + self._scroll_up(1) + elif self.y < self.rows - 1: + self.y += 1 + + def _reverse_index(self) -> None: + self._wrap_pending = False + if self.y == self._top: + self._scroll_down(1) + elif self.y > 0: + self.y -= 1 + + def _scroll_up(self, count: int, top: int | None = None) -> None: + top = self._top if top is None else top + bottom = self._bottom + count = min(count, bottom - top + 1) + region = self.screen[top : bottom + 1] + blank = [(" ", self._erase_style())] * self.cols + region = region[count:] + [list(blank) for _ in range(count)] + self.screen[top : bottom + 1] = region + self._dirty.update(range(top, bottom + 1)) + + def _scroll_down(self, count: int, top: int | None = None) -> None: + top = self._top if top is None else top + bottom = self._bottom + count = min(count, bottom - top + 1) + region = self.screen[top : bottom + 1] + blank = [(" ", self._erase_style())] * self.cols + region = [list(blank) for _ in range(count)] + region[: len(region) - count] + self.screen[top : bottom + 1] = region + self._dirty.update(range(top, bottom + 1)) + + def _restore(self) -> None: + self.x, self.y, self.style = self._saved + self.x = min(self.x, self.cols - 1) + self.y = min(self.y, self.rows - 1) + self._wrap_pending = False + + def _goto(self, x: int, y: int) -> None: + self.x = min(max(0, x), self.cols - 1) + self.y = min(max(0, y), self.rows - 1) + self._wrap_pending = False + + # --- CSI ------------------------------------------------------------------------------ + + def _csi(self, raw: str, final: str) -> None: + private = raw[:1] if raw[:1] in "?<=>" else "" + body = raw[len(private) :].rstrip(" !\"#$%&'()*+,-./") # intermediates + params = [part for part in body.split(";")] if body else [] + + def arg(index: int, default: int) -> int: + if index >= len(params): + return default + head = params[index].split(":")[0] + return int(head) if head.isdigit() and int(head) else default + + if private == "?": + if final in "hl": + self._private_mode(params, final == "h") + return + if private: + return # secondary DA and kin: nothing to draw + if final == "m": + self._sgr(params) + elif final in "Hf": + self._goto(arg(1, 1) - 1, arg(0, 1) - 1) + elif final == "A": + self._goto(self.x, max(self._top if self.y >= self._top else 0, self.y - arg(0, 1))) + elif final == "B": + bottom = self._bottom if self.y <= self._bottom else self.rows - 1 + self._goto(self.x, min(bottom, self.y + arg(0, 1))) + elif final == "C": + self._goto(self.x + arg(0, 1), self.y) + elif final == "D": + self._goto(self.x - arg(0, 1), self.y) + elif final == "E": + self._goto(0, self.y + arg(0, 1)) + elif final == "F": + self._goto(0, self.y - arg(0, 1)) + elif final == "G": + self._goto(arg(0, 1) - 1, self.y) + elif final == "d": + self._goto(self.x, arg(0, 1) - 1) + elif final == "J": + self._erase_display(arg(0, 0) if params and params[0] else 0) + elif final == "K": + mode = arg(0, 0) if params and params[0] else 0 + if mode == 0: + self._erase(self.y, self.x, self.cols - 1) + elif mode == 1: + self._erase(self.y, 0, self.x) + else: + self._erase(self.y, 0, self.cols - 1) + elif final == "X": + self._erase(self.y, self.x, self.x + arg(0, 1) - 1) + elif final == "@": + self._insert_chars(arg(0, 1)) + elif final == "P": + self._delete_chars(arg(0, 1)) + elif final == "L": + if self._top <= self.y <= self._bottom: + self._scroll_down(arg(0, 1), top=self.y) + self.x = 0 + elif final == "M": + if self._top <= self.y <= self._bottom: + self._scroll_up(arg(0, 1), top=self.y) + self.x = 0 + elif final == "S": + self._scroll_up(arg(0, 1)) + elif final == "T": + self._scroll_down(arg(0, 1)) + elif final == "r": + top, bottom = arg(0, 1) - 1, arg(1, self.rows) - 1 + if 0 <= top < bottom < self.rows: + self._top, self._bottom = top, bottom + self._goto(0, 0) + elif final == "s": + self._saved = (self.x, self.y, self.style) + elif final == "u": + self._restore() + elif final == "n" and self._reply is not None: + if arg(0, 0) == 6: + self._reply(f"\x1b[{self.y + 1};{self.x + 1}R") + elif arg(0, 0) == 5: + self._reply("\x1b[0n") + elif final == "c" and self._reply is not None: + self._reply("\x1b[?1;2c") + # Anything else (window ops, cursor shape, ...) changes nothing here. + + def _erase_display(self, mode: int) -> None: + if mode == 0: + self._erase(self.y, self.x, self.cols - 1) + for row in range(self.y + 1, self.rows): + self._erase(row, 0, self.cols - 1) + elif mode == 1: + for row in range(0, self.y): + self._erase(row, 0, self.cols - 1) + self._erase(self.y, 0, self.x) + else: + for row in range(self.rows): + self._erase(row, 0, self.cols - 1) + + def _insert_chars(self, count: int) -> None: + line = self.screen[self.y] + count = min(count, self.cols - self.x) + cell = (" ", self._erase_style()) + line[self.x :] = ([cell] * count + line[self.x :])[: self.cols - self.x] + self._dirty.add(self.y) + + def _delete_chars(self, count: int) -> None: + line = self.screen[self.y] + count = min(count, self.cols - self.x) + cell = (" ", self._erase_style()) + line[self.x :] = line[self.x + count :] + [cell] * count + self._dirty.add(self.y) + + def _private_mode(self, params: list[str], on: bool) -> None: + for raw in params: + mode = int(raw) if raw.isdigit() else -1 + if mode == 25: + self.cursor_visible = on + elif mode == 7: + self.autowrap = on + elif mode == 1: + self.application_cursor = on + elif mode in (47, 1047, 1049): + if mode == 1049 and on: + self._saved = (self.x, self.y, self.style) + target = self._alt if on else self._main + if target is not self.screen: + if on: + self._alt = self._fresh_screen() + target = self._alt + self.screen = target + self._dirty.update(range(self.rows)) + if mode == 1049 and not on: + self._restore() + + # --- SGR ------------------------------------------------------------------------------ + + def _sgr(self, params: list[str]) -> None: + if not params: + params = ["0"] + # Flatten "38:2::r:g:b" (colon sub-parameters) into the ";" form's sequence. + codes: list[int] = [] + for part in params: + pieces = part.split(":") + if len(pieces) > 1 and pieces[0] in ("38", "48", "58"): + head, rest = pieces[0], pieces[1:] + if rest[:1] == ["2"] and len(rest) == 5: + rest = ["2", *rest[2:]] # drop the colour-space id + codes.extend(int(p) if p.isdigit() else 0 for p in (head, *rest)) + else: + codes.append(int(pieces[0]) if pieces[0].isdigit() else 0) + fg, bg, flags = self.style.fg, self.style.bg, self.style.flags + i = 0 + while i < len(codes): + code = codes[i] + if code == 0: + fg, bg, flags = None, None, 0 + elif code == 1: + flags |= BOLD + elif code == 2: + flags |= DIM + elif code == 3: + flags |= ITALIC + elif code in (4, 21): + flags |= UNDERLINE + elif code == 7: + flags |= REVERSE + elif code == 8: + flags |= HIDDEN + elif code == 9: + flags |= STRIKE + elif code == 22: + flags &= ~(BOLD | DIM) + elif code == 23: + flags &= ~ITALIC + elif code == 24: + flags &= ~UNDERLINE + elif code == 27: + flags &= ~REVERSE + elif code == 28: + flags &= ~HIDDEN + elif code == 29: + flags &= ~STRIKE + elif 30 <= code <= 37: + fg = self.palette[code - 30] + elif 40 <= code <= 47: + bg = self.palette[code - 40] + elif 90 <= code <= 97: + fg = self.palette[code - 90 + 8] + elif 100 <= code <= 107: + bg = self.palette[code - 100 + 8] + elif code == 39: + fg = None + elif code == 49: + bg = None + elif code in (38, 48, 58): + colour, used = self._extended(codes, i + 1) + if code == 38: + fg = colour + elif code == 48: + bg = colour + i += used + i += 1 + self.style = Style(fg, bg, flags) + + def _extended(self, codes: list[int], start: int) -> tuple[RGB | None, int]: + """Read an extended colour at ``codes[start:]``: ``(colour, codes consumed)``.""" + kind = codes[start] if start < len(codes) else None + if kind == 5 and start + 1 < len(codes): + return _xterm_256(min(255, codes[start + 1]), self.palette), 2 + if kind == 2 and start + 3 < len(codes): + r, g, b = (min(255, c) for c in codes[start + 1 : start + 4]) + return (r, g, b), 4 + return None, len(codes) - start diff --git a/meshterm/platforms.py b/meshterm/platforms.py index fa8fb69..c829957 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -86,8 +86,13 @@ class Platform: (: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. + font: The glyph inventory the screen is drawn in, by name (see + :data:`~meshterm.ui.fontset.FONTS`), or ``""`` where a terminal draws whatever + it is sent. A handheld's every frame is folded down to its font at the render + boundary (:func:`~meshterm.ui.theme.fold_text`) — an accent the font lacks + stripped, an emoji turned to its compact glyph, anything else still missing a + narrow ``?`` — so nothing the panel can't draw is ever sent to it. Storage is + never touched. 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 @@ -115,6 +120,10 @@ class Platform: 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. + console_host: Whether the screen is drawn by MeshTerm's own console host + (:mod:`meshterm.host`) rather than a terminal — where the app is its own + terminal, nothing about the one it was started from (a classic Windows console + to move out of, a font to offer) is its business. modifier_watch: The keyboard whose Shift state the optional evdev watcher follows, flipping the displayed F-key lane live while Shift is held — a case-insensitive substring of the input device's name, or ``""`` to leave the watcher off. @@ -133,12 +142,13 @@ class Platform: lane_deck: str width_reclaim: bool emoji: bool - ascii_fold: bool + font: str truecolor: bool effects: bool tick_s: float spinner_tick_s: float battery: str + console_host: bool modifier_watch: str @property @@ -159,17 +169,18 @@ REGULAR = Platform( lane_deck="", width_reclaim=True, emoji=True, - ascii_fold=False, + font="", truecolor=True, effects=True, tick_s=1.0, spinner_tick_s=0.12, battery="companion", + console_host=False, modifier_watch="", ) #: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a -#: binding point (header_atoms, ascii_fold, truecolor, effects beyond the +#: binding point (header_atoms, font, 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( @@ -186,12 +197,13 @@ PICOCALC = Platform( lane_deck="picocalc", width_reclaim=False, emoji=False, - ascii_fold=True, + font="picocalc", truecolor=False, effects=False, tick_s=2.0, spinner_tick_s=0.5, battery="host", + console_host=False, modifier_watch="picocalc", ) @@ -219,12 +231,13 @@ CARDPUTER = Platform( lane_deck="cardputer", width_reclaim=False, emoji=False, - ascii_fold=False, + font="cardputer", truecolor=True, effects=False, tick_s=2.0, spinner_tick_s=0.5, battery="companion", + console_host=True, modifier_watch="tca8418c", ) @@ -395,8 +408,8 @@ def without_emoji(platform: Platform) -> Platform: 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. + being independent is for: ``font`` stays unset, so accents and the rest of the BMP + still come through. Args: platform: The resolved platform. diff --git a/meshterm/services/modifier_watch.py b/meshterm/services/modifier_watch.py index ef1452a..ef0c05e 100644 --- a/meshterm/services/modifier_watch.py +++ b/meshterm/services/modifier_watch.py @@ -49,6 +49,10 @@ _SYS_INPUT = Path("/sys/class/input") _shift_down = False _thread: threading.Thread | None = None +#: The session's repaint request, kept from :func:`start` even where no input device was +#: found, so a Shift report from the console host (:func:`report_shift`) still repaints. +_on_change: Callable[[], None] | None = None + #: How long a resolved F6–F10 keycode keeps :func:`shift_down` latched ``True`` after the #: raw signal drops — long enough to bridge the MCU's release/re-assert flicker around a #: chord, short enough that letting Shift go for real still reads as released well within @@ -73,6 +77,25 @@ def shift_down() -> bool: ) +def report_shift(down: bool) -> None: + """Set the Shift state from a source that knows it first-hand, and repaint on a change. + + The console host reads the keyboard itself — the simulator's window, the device's event + stream — so it knows when Shift goes down before any F-key arrives, and tells the lane + here rather than leaving a second reader to find the same keyboard. + """ + global _shift_down + if down == _shift_down: + return + _shift_down = down + callback = _on_change + if callback is not None: + try: + callback() + except Exception: # noqa: BLE001 - a repaint hiccup must not break the host's input + pass + + def note_shift_bank_key() -> None: """Record that an F6–F10 keycode just resolved — proof Shift was physically down. @@ -107,7 +130,8 @@ def start(on_change: Callable[[], None], keyboard: str) -> bool: Cardputer Zero, whose driver holds Shift down in the event stream for as long as its sticky Shift is armed, so a tapped Shift flips the lane as a held one does. """ - global _thread + global _thread, _on_change + _on_change = on_change if _thread is not None: return True device = _find_keyboard(keyboard) diff --git a/meshterm/ui/fontset.py b/meshterm/ui/fontset.py index aef4deb..ef23e5e 100644 --- a/meshterm/ui/fontset.py +++ b/meshterm/ui/fontset.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -"""The PicoCalc console font's codepoint inventory — the glyph contract. +"""The handhelds' glyph inventories — the glyph contract. The PicoCalc panel draws through the kernel console with one 512-glyph PSF font (``meshterm.psf.gz``, built by ``scripts/picocalc/calculinux-console-font-6x12.sh``): the stock @@ -16,6 +16,9 @@ inventory was dumped live from the PSF's Unicode table (2026-08-01), minus the d 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. + +The Cardputer Zero has a contract of its own, :data:`CARDPUTER_CODEPOINTS`: what its console +host draws, which no kernel caps. :data:`FONTS` names both, for ``Platform.font``. """ from __future__ import annotations @@ -60,3 +63,67 @@ FONT_CODEPOINTS: frozenset[int] = frozenset( def in_font(char: str) -> bool: """Whether the console font has a glyph for ``char``.""" return ord(char) in FONT_CODEPOINTS + + +#: The Cardputer Zero's inventory: what its console host draws (:mod:`meshterm.host.font`) +#: — Terminus 4.49's 12-pixel face, every one of its 1,356 glyphs, with MeshTerm's own +#: marks drawn over it and ``⋯`` aliased onto the ellipsis, as on the PicoCalc. No 512-glyph +#: cap here, so braille, the powerline chevrons and accented Latin are all simply present. +#: Generated from ``ter-u12n.bdf`` plus the host's ``MARKS`` and ``ALIASES``; if either +#: changes, this table moves in the same commit (``tests/test_console_host.py`` checks the +#: marks, and the font itself wherever it is installed). +# fmt: off +CARDPUTER_RANGES: tuple[tuple[int, int], ...] = ( + (0x0020, 0x007E), (0x00A0, 0x017F), (0x0186, 0x0186), (0x018E, 0x0190), + (0x0192, 0x0192), (0x019D, 0x019E), (0x01B5, 0x01B7), (0x01CD, 0x01D4), + (0x01E2, 0x01F0), (0x01F4, 0x01F5), (0x01FC, 0x01FF), (0x0218, 0x021B), + (0x0232, 0x0233), (0x0237, 0x0237), (0x0254, 0x0254), (0x0258, 0x0259), + (0x025B, 0x025B), (0x0272, 0x0272), (0x0292, 0x0292), (0x02BB, 0x02BD), + (0x02C6, 0x02C7), (0x02D8, 0x02D9), (0x02DB, 0x02DD), (0x0300, 0x0308), + (0x030A, 0x030C), (0x0329, 0x0329), (0x0384, 0x038A), (0x038C, 0x038C), + (0x038E, 0x03A1), (0x03A3, 0x03CE), (0x03D1, 0x03D1), (0x03D5, 0x03D5), + (0x03F0, 0x03F6), (0x0400, 0x045F), (0x0462, 0x0463), (0x046A, 0x046B), + (0x0490, 0x049D), (0x04A0, 0x04A5), (0x04AA, 0x04AB), (0x04AE, 0x04B3), + (0x04B6, 0x04BB), (0x04C0, 0x04C2), (0x04CF, 0x04DF), (0x04E2, 0x04F5), + (0x04F8, 0x04F9), (0x05D0, 0x05EA), (0x1E0C, 0x1E0D), (0x1E34, 0x1E37), + (0x1E40, 0x1E47), (0x1E6C, 0x1E6D), (0x1EB8, 0x1EB9), (0x1EBC, 0x1EBD), + (0x1ECA, 0x1ECD), (0x1EE4, 0x1EE5), (0x1EF8, 0x1EF9), (0x2000, 0x2022), + (0x2026, 0x2026), (0x2030, 0x2030), (0x2032, 0x2033), (0x2039, 0x203A), + (0x203C, 0x203C), (0x203E, 0x203E), (0x2070, 0x2071), (0x2074, 0x208E), + (0x2090, 0x2098), (0x209A, 0x209A), (0x20A7, 0x20A7), (0x20AA, 0x20AA), + (0x20AC, 0x20AC), (0x20AE, 0x20AE), (0x2102, 0x2102), (0x210E, 0x210F), + (0x2115, 0x2116), (0x211A, 0x211A), (0x211D, 0x211D), (0x2122, 0x2122), + (0x2124, 0x2124), (0x2126, 0x2126), (0x2135, 0x2135), (0x2190, 0x2195), + (0x21A4, 0x21A4), (0x21A6, 0x21A6), (0x21A8, 0x21A8), (0x21B2, 0x21B3), + (0x21B5, 0x21B5), (0x21BB, 0x21BB), (0x21CB, 0x21CC), (0x21D0, 0x21D5), + (0x21E7, 0x21E7), (0x2200, 0x2200), (0x2203, 0x220D), (0x2212, 0x2216), + (0x2219, 0x221A), (0x221E, 0x221F), (0x2225, 0x2225), (0x2227, 0x222A), + (0x2248, 0x2248), (0x2260, 0x2261), (0x2264, 0x2265), (0x226A, 0x226B), + (0x2282, 0x2283), (0x2286, 0x2287), (0x22A5, 0x22A5), (0x22C2, 0x22C3), + (0x22EF, 0x22EF), (0x2300, 0x2300), (0x2302, 0x2302), (0x2308, 0x230B), + (0x2310, 0x2310), (0x2316, 0x2316), (0x2319, 0x2319), (0x2320, 0x2321), + (0x232B, 0x232B), (0x239B, 0x23A9), (0x23AB, 0x23AF), (0x23BA, 0x23BD), + (0x23D0, 0x23D0), (0x2409, 0x240D), (0x2424, 0x2424), (0x2500, 0x2503), + (0x2508, 0x254B), (0x2550, 0x2593), (0x2596, 0x25A0), (0x25AC, 0x25AC), + (0x25AE, 0x25AE), (0x25B2, 0x25B2), (0x25B6, 0x25B6), (0x25B8, 0x25B8), + (0x25BA, 0x25BA), (0x25BC, 0x25BC), (0x25C0, 0x25C0), (0x25C4, 0x25C4), + (0x25C6, 0x25C6), (0x25C9, 0x25CB), (0x25CF, 0x25CF), (0x25D8, 0x25D9), + (0x25F7, 0x25F7), (0x2605, 0x2605), (0x263A, 0x263C), (0x2640, 0x2640), + (0x2642, 0x2642), (0x2660, 0x2660), (0x2663, 0x2663), (0x2665, 0x2666), + (0x266A, 0x266B), (0x2699, 0x2699), (0x26A0, 0x26A0), (0x26BF, 0x26BF), + (0x2713, 0x2714), (0x2717, 0x2718), (0x276F, 0x276F), (0x27E8, 0x27EB), + (0x2800, 0x28FF), (0x2E2C, 0x2E2C), (0xE0A0, 0xE0A2), (0xE0B0, 0xE0B3), + (0xF6BE, 0xF6BE), (0xFFFD, 0xFFFD), +) +# fmt: on + +#: The Cardputer's inventory as one frozen set. +CARDPUTER_CODEPOINTS: frozenset[int] = frozenset( + cp for first, last in CARDPUTER_RANGES for cp in range(first, last + 1) +) + +#: Every handheld's glyph inventory, by the name ``Platform.font`` gives it. +FONTS: dict[str, frozenset[int]] = { + "picocalc": FONT_CODEPOINTS, + "cardputer": CARDPUTER_CODEPOINTS, +} diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index cb0c092..7f5a24d 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -22,7 +22,7 @@ from rich.console import Console from rich.theme import Theme from ..platforms import Platform, on_platform -from .fontset import FONT_CODEPOINTS +from .fontset import FONT_CODEPOINTS, FONTS MESH_THEME = Theme( { @@ -759,11 +759,19 @@ def _glyph_compact(icon: str) -> str: return _GLYPH_MAP.get(icon, icon) -# -- the render-boundary fold (PicoCalc) ---------------------------------------------- +# -- the render-boundary fold (the handhelds) ------------------------------------------ + +#: The active platform's glyph inventory (``Platform.font``), bound in :func:`_bind`: the +#: PicoCalc's 512-glyph console font, or what the Cardputer's console host draws. +_FOLD_FONT: frozenset[int] = FONT_CODEPOINTS #: 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)) +_FOLD_ALLOWED: frozenset[int] = _FOLD_FONT.union(range(0x00, 0x20)) + +#: Whether the fold also quantizes embedded truecolor/256-colour SGR to the 16 slots — a +#: console that has no more (the PicoCalc's), never a panel the host paints in 24 bits. +_FOLD_QUANTIZE = True #: 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 — @@ -904,7 +912,12 @@ def _quantize_params(match: _re.Match[str]) -> str: def _build_fold_table() -> dict[int, str]: - """Compose the full translation table (see :data:`_FOLD_TABLE`).""" + """Compose the full translation table (see :data:`_FOLD_TABLE`). + + Only for what the active font lacks: a character it draws passes as itself, so the + Cardputer keeps the accents, chevrons and the rest Terminus has that the PicoCalc's + 512 glyphs don't. + """ import unicodedata table: dict[int, str] = {} @@ -913,17 +926,19 @@ def _build_fold_table() -> dict[int, str]: # 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: + if cp in _FOLD_FONT: 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 + if ord(char) not in _FOLD_FONT: + 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 + if ord(emoji) not in _FOLD_FONT: + pad = max(0, cell_len(emoji) - cell_len(compact)) + table[ord(emoji)] = compact + " " * pad return table @@ -943,7 +958,7 @@ def _fold_to_font(text: str) -> str: global _FOLD_TABLE if _FOLD_TABLE is None: _FOLD_TABLE = _build_fold_table() - folded = _quantize_sgr(text).translate(_FOLD_TABLE) + folded = (_quantize_sgr(text) if _FOLD_QUANTIZE else text).translate(_FOLD_TABLE) if folded.isascii(): return folded if all(ord(ch) in _FOLD_ALLOWED for ch in folded): @@ -1009,10 +1024,16 @@ _STYLE_RGB: dict[str, tuple[int, int, int]] = {} def _bind(platform: Platform) -> None: """Bind the theme's platform-dependent choices (runs now and on every switch).""" global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE + global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE _ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16 _node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized _glyph_impl = _glyph_identity if platform.emoji else _glyph_compact - _fold_impl = _fold_to_font if platform.ascii_fold else _no_fold + font = FONTS.get(platform.font) + if font is not None: + _FOLD_FONT = font + _FOLD_ALLOWED = font.union(range(0x00, 0x20)) + _FOLD_QUANTIZE = not platform.truecolor + _fold_impl = _fold_to_font if font is not None else _no_fold _STYLE_RGB.clear() # The fold table's emoji pads are computed with cell_len at build time. Cell widths # can be re-measured/patched (the emoji-width calibration on the regular platform), diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 61e59c5..29a303b 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -366,7 +366,7 @@ def _has_wide_glyph(text: str) -> bool: like anywhere else. Reading ``emoji`` here instead would have skipped the scan on a frame that genuinely needed it, and smeared the row. """ - if get_platform().ascii_fold: + if get_platform().font: return False return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text) diff --git a/scripts/README.md b/scripts/README.md index b97bf8c..94f652c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,6 +7,7 @@ Repo-side tools. None of these ship in the wheel. | `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth | | `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi; `xiao-radio/` is the XIAO radio kit (firmware build, flashing, UART setup) | | `uconsole/` | The uConsole's always-on SPI bridge service (MeshTerm can also drive that radio directly, with no script at all — see `docs/uconsole.md`) | +| `cardputer/` | The Cardputer Zero's console host: `fetch-terminus.py` installs the 12-pixel Terminus fonts it draws in (OFL, so never shipped with MeshTerm) | ## basemap-doctor.py diff --git a/scripts/cardputer/fetch-terminus.py b/scripts/cardputer/fetch-terminus.py new file mode 100644 index 0000000..fe5bd96 --- /dev/null +++ b/scripts/cardputer/fetch-terminus.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Fetch the Terminus 12-pixel BDF fonts the Cardputer console host draws in. + +Terminus is licensed under the SIL Open Font License, so MeshTerm never ships it: this +downloads the upstream release, checks it is the archive it should be, and extracts +``ter-u12n.bdf``, ``ter-u12b.bdf`` and the licence beside them into the directory the host +reads (``$MESHTERM_HOST_FONTS``, else ``fonts`` under the MeshTerm config directory). + +Run it once on a machine that will run the host or its desktop simulator: + + python scripts/cardputer/fetch-terminus.py + +Where Python can't fetch it (a certificate store that rejects the mirror), download the +archive any other way and hand it over; it is checked against the same digest: + + python scripts/cardputer/fetch-terminus.py --archive terminus-font-4.49.1.tar.gz +""" + +from __future__ import annotations + +import hashlib +import io +import sys +import tarfile +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from meshterm.host.font import BOLD_BDF, REGULAR_BDF, font_dir # noqa: E402 + +URL = ( + "https://sourceforge.net/projects/terminus-font/files/terminus-font-4.49/" + "terminus-font-4.49.1.tar.gz/download" +) +#: The release archive's SHA-256, so a mirror can't hand back something else. +SHA256 = "d961c1b781627bf417f9b340693d64fc219e0113ad3a3af1a3424c7aa373ef79" +ROOT = "terminus-font-4.49.1/" +WANTED = (REGULAR_BDF, BOLD_BDF, "OFL.TXT") + + +def main(argv: list[str]) -> int: + """Fetch (or take) the archive, verify it, and extract the fonts and their licence.""" + target = font_dir() + if argv[:1] == ["--archive"] and len(argv) == 2: + print(f"installing Terminus 4.49.1 from {argv[1]} into {target}") + data = Path(argv[1]).read_bytes() + elif not argv: + print(f"fetching Terminus 4.49.1 into {target}") + with urllib.request.urlopen(URL, timeout=60) as response: + data = response.read() + else: + print(__doc__) + return 2 + digest = hashlib.sha256(data).hexdigest() + if digest != SHA256: + print(f"refusing the download: sha256 {digest} is not the release's {SHA256}") + return 1 + target.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + for name in WANTED: + member = archive.extractfile(ROOT + name) + if member is None: + print(f"the archive has no {name}") + return 1 + (target / name).write_bytes(member.read()) + print(f" {name}") + print("done") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_console_host.py b/tests/test_console_host.py new file mode 100644 index 0000000..be1a7ae --- /dev/null +++ b/tests/test_console_host.py @@ -0,0 +1,260 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The Cardputer's console host: its terminal, keys, font, pixels, and a whole run.""" + +from __future__ import annotations + +import threading +import time + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from meshterm.host.device import KeyState +from meshterm.host.font import CELL_H, MARKS, MISSING, build_font, load_bdf +from meshterm.host.keys import Key, encode +from meshterm.host.raster import PANEL_H, PANEL_W, Raster, rgb565 +from meshterm.host.run import host_output, run +from meshterm.host.sim import key_from_tk +from meshterm.host.vt import BOLD, REVERSE, UNDERLINE, Terminal +from meshterm.ui.tui.fkeys import CARDPUTER_DECK + +_BDF = """STARTFONT 2.1 +FONT test +SIZE 12 72 72 +FONTBOUNDINGBOX 6 12 0 -2 +FONT_ASCENT 10 +FONT_DESCENT 2 +CHARS 1 +STARTCHAR A +ENCODING 65 +BBX 6 12 0 -2 +BITMAP +00 +00 +70 +88 +88 +88 +F8 +88 +88 +88 +00 +00 +ENDCHAR +ENDFONT +""" + + +def _parsed(data: str) -> list[Keys | str]: + keys: list[Keys | str] = [] + parser = Vt100Parser(lambda press: keys.append(press.key)) + parser.feed(data) + parser.flush() + return keys + + +# --- the terminal --------------------------------------------------------------------- + + +def test_text_lands_where_the_cursor_is_and_wraps_at_the_edge() -> None: + """Printing starts at the cursor and wraps onto the next row at the right edge.""" + term = Terminal(6, 3) + term.feed("\x1b[2;3Habcdef") + assert term.line(1) == " abcd" + assert term.line(2) == "ef " + + +def test_erase_and_cursor_moves() -> None: + """Erase-to-end and erase-to-start clear what they name and nothing else.""" + term = Terminal(8, 2) + term.feed("abcdefgh\r\nijklmnop\x1b[1;4H\x1b[K\x1b[2;2H\x1b[1K") + assert term.text() == "abc \n klmnop" + + +def test_sgr_in_every_colour_depth() -> None: + """16-colour, 256-colour and truecolor SGR, with either separator, and the flags.""" + term = Terminal(8, 1, palette=[(i, i, i) for i in range(16)]) + term.feed("\x1b[31ma\x1b[38;5;196mb\x1b[38;2;1;2;3mc\x1b[38:2::4:5:6md\x1b[1;4;7me\x1b[0mf") + styles = [style for _, style in term.screen[0][:6]] + assert styles[0].fg == (1, 1, 1) # palette slot 1 + assert styles[1].fg == (255, 0, 0) # the 256-colour cube's pure red + assert styles[2].fg == (1, 2, 3) and styles[3].fg == (4, 5, 6) + assert styles[4].flags == BOLD | UNDERLINE | REVERSE + assert styles[5].fg is None and styles[5].flags == 0 + + +def test_unknown_sequences_are_skipped_whole() -> None: + """A sequence the parser doesn't model leaves nothing of itself on screen.""" + term = Terminal(10, 1) + term.feed("\x1b]0;a title\x07\x1b[?2004h\x1b[>4;2m\x1bPq#0;\x1b\\x\x1b(By") + assert term.line(0) == "xy " + + +def test_the_alternate_screen_keeps_the_main_one() -> None: + """Leaving the alternate screen brings the main one back as it was.""" + term = Terminal(4, 1) + term.feed("main\x1b[?1049h\x1b[2J\x1b[Halt") + assert term.line(0) == "alt " + term.feed("\x1b[?1049l") + assert term.line(0) == "main" + + +def test_only_changed_rows_are_dirty() -> None: + """A one-cell change marks one row for redrawing.""" + term = Terminal(4, 3) + term.take_dirty() + term.feed("\x1b[3;1Hx") + assert term.take_dirty() == {2} + + +def test_prompt_toolkit_draws_into_it() -> None: + """The output the host hands prompt_toolkit really does land in the grid.""" + term = Terminal(20, 2) + frames: list[int] = [] + output = host_output(term, threading.Lock(), lambda: frames.append(1)) + output.cursor_goto(2, 4) # 1-based, as a terminal counts + output.write("hello") + output.flush() + assert term.line(1).startswith(" hello") and frames + assert output.get_size().columns == 20 and output.get_size().rows == 2 + + +# --- keys ----------------------------------------------------------------------------- + + +def test_the_lane_keys_reach_prompt_toolkit_as_the_deck_expects() -> None: + """Fn+4..8 parse as F4-F8 and Shift with them as F16-F20, the Cardputer deck's banks.""" + plain = [_parsed(encode(Key(name=f"f{n}")))[0] for n in range(4, 9)] + shifted = [_parsed(encode(Key(name=f"f{n}", shift=True)))[0] for n in range(4, 9)] + assert [k.value for k in plain] == [f"f{n}" for n in CARDPUTER_DECK.keys] + assert [k.value for k in shifted] == [f"f{n}" for n in CARDPUTER_DECK.shift_keys] + + +def test_modified_navigation_and_control_letters() -> None: + """Ctrl+PgUp (the section jump), arrows, Shift+Tab, and the C0 control letters.""" + assert _parsed(encode(Key(name="pageup", ctrl=True))) == [Keys.ControlPageUp] + assert _parsed(encode(Key(name="up"))) == [Keys.Up] + assert _parsed(encode(Key(name="tab", shift=True))) == [Keys.BackTab] + assert encode(Key(text="q", ctrl=True)) == "\x11" + assert encode(Key(text="w", ctrl=True)) == "\x17" + assert encode(Key(name="escape")) == "\x1b" + assert encode(Key(name="backspace")) == "\x7f" + + +def test_tk_events_become_keys() -> None: + """The simulator reads Shift, Ctrl and text off Tk's events.""" + assert key_from_tk("F4", "", 0x0001) == Key(name="f4", shift=True) + assert key_from_tk("q", "\x11", 0x0004) == Key(text="q", ctrl=True) + assert key_from_tk("a", "a", 0) == Key(text="a") + assert key_from_tk("Shift_L", "", 0) is None + + +def test_the_device_keyboard_types_what_its_keycaps_say() -> None: + """Letters and digits as printed, Sym's placeholder codes as M5's keymap names them.""" + keys = KeyState() + assert keys.event(30, 1) == Key(text="a") # KEY_A + keys.event(42, 1) # Shift down — the driver holds it for a sticky Shift too + assert keys.event(30, 1) == Key(text="A") + assert keys.event(62, 1) == Key(name="f4", shift=True) # Shift+Fn+4 + keys.event(42, 0) + assert keys.event(26, 1) == Key(text="!") # Sym+1 arrives as KEY_LEFTBRACE + assert keys.event(93, 1) == Key(text="?") # Sym+M + assert keys.event(389, 1) is None # the Fn key itself (KEY_DVD) types nothing + assert keys.event(30, 0) is None # a release types nothing + + +# --- the font and the pixels ------------------------------------------------------------ + + +def test_a_bdf_glyph_lands_in_the_cell(tmp_path) -> None: + """A BDF glyph is set into the 6x12 cell where its bounding box says.""" + path = tmp_path / "t.bdf" + path.write_text(_BDF) + glyph = load_bdf(path)[65] + assert len(glyph) == CELL_H + assert glyph[2] == 0x70 and glyph[6] == 0xF8 + + +def test_meshterm_marks_draw_over_the_base_and_gaps_show() -> None: + """MeshTerm's marks win over the base font, and a missing glyph draws as a box.""" + font = build_font({0x2605: bytes(12)}) + assert font.glyph("★") == MARKS[0x2605] # the mark wins over the base's glyph + assert font.glyph("⋯") == MARKS[0x2026] # aliased, as on the PicoCalc + assert font.glyph("一") == MISSING + + +def test_the_raster_draws_a_cell_in_its_colours() -> None: + """A cell's ink and paper land in the right pixels, and only dirty rows redraw.""" + term = Terminal(53, 14) + font = build_font({0x41: bytes([0, 0, 0x70, 0x88, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88, 0, 0])}) + raster = Raster(term, font, pack=rgb565, default_bg=(0, 0, 0)) + term.feed("\x1b[38;2;255;255;255m\x1b[48;2;0;0;255mA") + bands = raster.update() + assert bands == [(raster.top, raster.top + 12 * 14)] # the first paint is every row + assert len(raster.pixels) == PANEL_W * PANEL_H * 2 + + def pixel(x: int, y: int) -> bytes: + at = (raster.top + y) * raster.stride + (raster.left + x) * 2 + return bytes(raster.pixels[at : at + 2]) + + assert pixel(1, 2) == rgb565((255, 255, 255)) # ink: the A's top bar + assert pixel(0, 2) == rgb565((0, 0, 255)) # paper beside it + term.feed("\x1b[14;1Hz") + assert raster.update() == [(raster.top + 13 * 12, raster.top + 14 * 12)] + + +# --- a whole run ---------------------------------------------------------------------- + + +def test_meshterm_runs_inside_the_host(monkeypatch) -> None: + """The ordinary CLI, hosted: the menu draws at 53x14 with the Cardputer's lane, ^Q^Q leaves.""" + from meshterm.ui import menu + + # Quitting arms a watchdog that hard-exits the process if teardown wedges — right for + # the host, which ends with the TUI, but fatal to the test run it is hosted in here. + monkeypatch.setattr(menu, "_arm_exit_watchdog", lambda *args, **kwargs: None) + seen: dict[str, str] = {} + + class Headless: + def __init__(self, terminal, lock, type_text): + self._terminal, self._lock = terminal, lock + + def drive(): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + with self._lock: + text = terminal.text() + if "What would you like to do?" in text: + seen["menu"] = text + break + time.sleep(0.1) + type_text("\x11") + time.sleep(0.3) + type_text("\x11") + + threading.Thread(target=drive, daemon=True).start() + + def frame_ready(self): + pass + + def close(self): + pass + + assert run(["--mock"], Headless) == 0 + menu = seen["menu"].splitlines() + assert len(menu) == 14 and all(len(line) == 53 for line in menu) + assert "Quit?" in menu[-1] # the main menu's lane, drawn by the Cardputer deck + + +def test_the_cardputer_inventory_is_what_the_host_draws() -> None: + """``fontset.CARDPUTER_CODEPOINTS`` holds every mark, and the installed font exactly.""" + from meshterm.host.font import ALIASES, font_dir, load_bdf + from meshterm.ui.fontset import CARDPUTER_CODEPOINTS + + assert set(MARKS) | set(ALIASES) <= CARDPUTER_CODEPOINTS + installed = font_dir() / "ter-u12n.bdf" + if not installed.is_file(): + return # no Terminus on this machine (CI): the marks are the part that is ours + drawn = {cp for cp in load_bdf(installed) if cp >= 0x20} | set(MARKS) | set(ALIASES) + assert drawn == CARDPUTER_CODEPOINTS diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 236842c..64d8102 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -79,7 +79,7 @@ from meshterm.ui.contacts_screen import ContactsScreen from meshterm.ui.courier_screen import CourierOutboxScreen from meshterm.ui.dashboard_screen import DashboardScreen from meshterm.ui.device_info_screen import DeviceInfoScreen -from meshterm.ui.fontset import FONT_CODEPOINTS +from meshterm.ui.fontset import FONTS from meshterm.ui.livefeed_screen import LiveFeedScreen from meshterm.ui.map_render import MapMarker from meshterm.ui.map_screen import MapScreen @@ -1567,33 +1567,29 @@ def test_gallery_screen_fits_its_platform( # 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. - # A platform without emoji (both handhelds) may emit none: every icon funnel routes - # through the compact glyph map there, so a pictograph in the output is one that - # bypassed it. The PicoCalc's font gate below is stricter still; the Cardputer's - # glyph contract waits on the console host's font. - if not platform.emoji: - for i, line in enumerate(_plain(composed).splitlines()): - pictographs = {ch for ch in line if ord(ch) >= 0x1F000 or ch == "️"} - assert not pictographs, ( - f"{entry.name} line {i} draws emoji on {platform.name}: {sorted(pictographs)!r}" - ) - - if platform.name == "picocalc": + # A handheld may emit nothing outside its own font (Platform.font): the PicoCalc's + # 512-glyph console font, or what the Cardputer's console host draws. And a console + # with 16 slots (the PicoCalc's) may carry no truecolor or 256-colour SGR either. + # 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.font: + font = FONTS[platform.font] 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} + if not platform.truecolor: + 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} assert not strays, ( - f"{entry.name} {where} line {i} has characters outside the console " - f"font: {sorted(strays)!r} in {line!r}" + f"{entry.name} {where} line {i} has characters outside the " + f"{platform.name} font: {sorted(strays)!r} in {line!r}" ) diff --git a/tests/test_platforms.py b/tests/test_platforms.py index c810cb5..f1ef186 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -161,12 +161,12 @@ def test_without_emoji_moves_one_flag_and_nothing_else() -> None: The classic Windows console has the desktop's width, colour depth, borders and keyboard — only the icons have to change. Every other field must survive, and - ``ascii_fold`` above all: folding there would strip accents from names the console - renders perfectly well. + ``font`` above all: folding to a handheld's font there would strip accents from names + the console renders perfectly well. """ plain = without_emoji(REGULAR) assert plain.emoji is False - assert plain.ascii_fold is False + assert plain.font == "" assert plain.truecolor is True assert plain.readable_cols == REGULAR.readable_cols assert plain.footer_fkeys is REGULAR.footer_fkeys