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:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-30 21:08:30 -04:00
1 parent 64fce69de7
commit b3bf5a6160
21 files changed
+2199 -48

No files matched your search

+13
View File
@@ -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
View File
@@ -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):
+23
View File
@@ -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`).
"""
+54
View File
@@ -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:]))
+246
View File
@@ -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
+234
View File
@@ -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)
+83
View File
@@ -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
+147
View File
@@ -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)
+134
View File
@@ -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()
+226
View File
@@ -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"]
+533
View File
@@ -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
View File
@@ -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.
+25 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -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)
+1
View File
@@ -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
+74
View File
@@ -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:]))
+260
View File
@@ -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
View File
@@ -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}"
)
+3 -3
View File
@@ -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