The Cardputer Zero gets a console host, and a desktop simulator built on it
The stock image gives an app no text console, so MeshTerm draws its own: the TUI runs in-process with prompt_toolkit's output feeding a small VT parser, the cells are drawn in Terminus 6x12 with MeshTerm's marks over it, and keys go in through a pipe in xterm's spelling. The front end is the only part that differs: framebuffer and evdev on the device (written ahead of the hardware), a Tk window on the desktop. Each handheld's glyph inventory is now named by Platform.font, which replaces ascii_fold; the render-boundary fold derives its accents, emoji, and fallback from that inventory and quantizes colour only where truecolor is off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
64fce69de7
commit
b3bf5a6160
21 files changed
+2199
-48
No files matched your search
@@ -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
|
||||
|
||||
+3
-1
@@ -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):
|
||||
|
||||
@@ -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`).
|
||||
"""
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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("<KeyPress>", self._on_press)
|
||||
root.bind("<KeyRelease>", 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"]
|
||||
@@ -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
|
||||
+22
-9
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+68
-1
@@ -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,
|
||||
}
|
||||
+31
-10
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
+18
-22
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user