Docstrings and comments of meshterm/emulator are in ASD-STE100

The same rewrite as for the core package. The physical screen of a
handheld is now the display, and the computer is the handheld. The
comment on FONT_ENV now says that it is the environment variable.

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-10-06 03:51:55 -04:00
1 parent 6b90e0505b
commit 3e251a9707
11 files changed
+433 -371

No files matched your search

+31 -25
View File
@@ -1,31 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
"""The emulator: MeshTerm drawing a handheld's display itself, on the device or in a window.
"""The emulator: MeshTerm draws a handheld's display itself, on the handheld or in a window.
**Not a strict emulator.** Nothing here runs the handheld's processor or its operating
system: MeshTerm runs as itself, on whatever machine runs this. What is reproduced is the
device's *display and keyboard* — its panel's pixel size, the character grid it divides
into, the font every glyph is drawn in, the colours it can show and how bold changes them,
and the keys that drive the F-key lane — so the screen in the window obeys the same
constraints the device's would. It mimics the display; it doesn't emulate the machine.
**Not a strict emulator.** Nothing here runs the processor or the operating system of the
handheld. MeshTerm runs as itself, on the machine that runs this package. The emulator
copies the display and the keyboard of the handheld: the size of the display in pixels,
the character grid on the display, the font that draws each glyph, the colours that the
display can show and how bold changes them, and the keyboard keys that drive the F-key
lane. Thus the screen in the window obeys the same constraints as the screen of the
handheld. The emulator copies the display. It does not emulate the machine.
It began as the Cardputer Zero's *console host* — the program a text app runs inside — and
on that device it is still exactly that.
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-size output whose bytes go to a small VT parser
(:mod:`.vt`) rather than to any terminal, and reading from a pipe this package types 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 to one of two front ends:
The emulator started as the *console host* of the Cardputer Zero: the program that a text
app runs in. On that handheld, it is still exactly that. The Cardputer Zero has no text
console that an app can use. Its launcher owns the screen, the framebuffer console of the
kernel is off, and a store app cannot run as root to turn it on again. An app gets only
the framebuffer itself and the event device file of the keyboard. Thus MeshTerm brings its
own terminal. This package runs the ordinary TUI in the same process:
* on the Cardputer Zero, the framebuffer the launcher hands over, with keys from the
keyboard's event device (:mod:`.framebuffer`);
* on a desktop, a window drawing the device's panel scaled up, with keys from the desktop
keyboard (:mod:`.window`) — the device's own code path everywhere except the two ends.
* prompt_toolkit writes to an output of a fixed size. The bytes of that output go to a
small VT parser (:mod:`.vt`) instead of to a terminal.
* prompt_toolkit reads from a pipe, and this package types key presses into that pipe
(:mod:`.keys`).
Nothing in the TUI knows it is emulated. 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 these platforms, so this package only has to set that session up and call the CLI
the way a shell would (:mod:`.run`).
A 6×12 bitmap font draws the parsed cell grid into pixels (:mod:`.font`, :mod:`.raster`).
The pixels go to one of two front ends:
* On the Cardputer Zero: the framebuffer that the launcher gives, with key presses from
the event device file of the keyboard (:mod:`.framebuffer`).
* On a desktop: a window that draws the display of the handheld at a larger scale, with
key presses from the desktop keyboard (:mod:`.window`). The code path is the same as on
the handheld, except at these two ends.
Nothing in the TUI knows that it is emulated. When the terminal wrappers of the session
are off, the session takes its input and output from the app session of prompt_toolkit.
The wrappers are off on these platforms. Thus this package must only set up that session
and call the CLI as a shell does (:mod:`.run`).
"""
+20 -19
View File
@@ -1,16 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
"""``python -m meshterm.emulator`` — run MeshTerm as it runs on a handheld.
"""``python -m meshterm.emulator``: run MeshTerm as it runs on a handheld.
python -m meshterm.emulator DEVICE [--scale N] [MESHTERM ARGS...]
python -m meshterm.emulator --framebuffer [MESHTERM ARGS...]
``DEVICE`` (``cardputer-zero``, ``picocalc-lyra``) opens a window showing that device's
display — the same as ``meshterm emulate DEVICE``, which is how a person runs it. On the
Cardputer Zero itself, where the launcher names a framebuffer
(``APPLAUNCH_LINUX_FBDEV_DEVICE``), it draws to that framebuffer and reads the keyboard
instead; ``--framebuffer`` asks for that anywhere. 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`` supplied by the emulator.
``DEVICE`` (``cardputer-zero``, ``picocalc-lyra``) opens a window that shows the display of
that handheld. This is the same as ``meshterm emulate DEVICE``, which is the command that
the user runs. On the Cardputer Zero itself, the launcher names a framebuffer
(``APPLAUNCH_LINUX_FBDEV_DEVICE``). There, the emulator draws to that framebuffer and reads
the keyboard instead. ``--framebuffer`` asks for that behaviour on any machine. All the
other arguments go to MeshTerm as on a command line: ``--mock`` for the simulated mesh, and
``--ble``/``--port``/``--tcp`` for a companion. The emulator supplies ``--platform``.
"""
from __future__ import annotations
@@ -28,22 +28,23 @@ def start(
framebuffer: bool | None = None,
complain: Callable[[str], None] | None = None,
) -> int:
"""Run MeshTerm in the emulator: ``name``'s display in a window, or the real panel.
"""Run MeshTerm in the emulator: the display of ``name`` in a window, or the real display.
Args:
name: The device to emulate in a window, by id; ignored on the framebuffer, which
is the Cardputer Zero's own.
argv: MeshTerm's own arguments, passed through as a shell would pass them.
scale: The window's zoom, in whole pixels.
framebuffer: Draw to the device's framebuffer (``True``) or a window (``False``);
``None`` decides by whether the launcher named a framebuffer.
complain: Where a sentence saying why it can't start goes (stderr by default).
name: The id of the handheld to emulate in a window. It is ignored on the
framebuffer, because that framebuffer is the Cardputer Zero's own.
argv: The arguments of MeshTerm itself, passed on as a shell passes them.
scale: The zoom of the window, in whole pixels.
framebuffer: Draw to the framebuffer of the handheld (``True``) or to a window
(``False``). ``None`` decides from whether the launcher named a framebuffer.
complain: Where the sentence goes that says why the emulator cannot start (stderr
by default).
Returns:
MeshTerm's exit status, or ``1`` when the emulator couldn't start.
The exit status of MeshTerm, or ``1`` when the emulator could not start.
Raises:
ValueError: ``name`` is missing or names no device the emulator offers.
ValueError: ``name`` is missing, or it names no handheld that the emulator offers.
"""
from .devices import CARDPUTER_ZERO_DEVICE, device
from .framebuffer import FB_ENV
@@ -62,7 +63,7 @@ def start(
emulated = device(name)
from .window import front_end as window_front_end
def factory(): # noqa: ANN202 - the window's factory, deferred to report its errors
def factory(): # noqa: ANN202 - the window factory. It is deferred to report its errors.
return window_front_end(emulated, scale)
try:
+37 -31
View File
@@ -1,11 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
"""The handhelds the emulator can stand in for, and what about each it reproduces.
"""The handhelds that the emulator can act as, and what the emulator copies of each.
A device here is its *display and keyboard* as MeshTerm meets them — the platform MeshTerm
runs as on it, the panel's size in pixels, how a console draws on that panel, and which
desktop keys stand in for its lane keys — never its processor or its operating system
(see :mod:`meshterm.emulator`). Each is named by its platform's id, so ``meshterm emulate
picocalc-lyra`` and ``--platform picocalc-lyra`` say the same device.
A handheld here is its display and its keyboard, as MeshTerm meets them. That is the
platform that MeshTerm runs as on it, the size of the display in pixels, how a console
draws on that display, and which desktop keyboard keys act as its lane keys. It is never
its processor or its operating system (refer to :mod:`meshterm.emulator`). The id of each
handheld is the id of its platform. Thus ``meshterm emulate picocalc-lyra`` and
``--platform picocalc-lyra`` name the same handheld.
"""
from __future__ import annotations
@@ -24,23 +25,27 @@ if TYPE_CHECKING:
@dataclass(frozen=True)
class EmulatedDevice:
"""One handheld's display and keyboard, as the emulator reproduces them.
"""The display and the keyboard of one handheld, as the emulator copies them.
Attributes:
platform: The platform MeshTerm runs as on the device; its grid is the panel's.
name: The device as people call it, for the window's title.
panel: The panel's size in pixels, ``(width, height)``.
console: Whether a Linux console draws the device's screen — the grid from the
panel's top-left, bold as bright, the palette's slot 7 on slot 0 as the default
colours, and sixteen colours out — rather than MeshTerm in truecolour.
lane_keys: The keys under the panel that drive the F-key lane, as ``(label,
centre)`` pairs in panel pixels, one per slot of the :attr:`deck`, left to
right — drawn under the window's panel so a chip that drifts off its key
shows, and pressed there with the mouse. Empty for a device whose lane keys
sit elsewhere.
shifted: The desktop's Shift+F-key as the key the device's keyboard sends instead.
The PicoCalc's keyboard sends F6–F10 for Shift+F1–F5; the Cardputer Zero's sends
a shifted F4–F8, which needs no translating.
platform: The platform that MeshTerm runs as on the handheld. Its grid is the grid
of the display.
name: The usual name of the handheld, for the title of the window.
panel: The size of the display in pixels, ``(width, height)``.
console: Whether a Linux console draws the screen of the handheld, instead of
MeshTerm in truecolour. A console puts the grid at the top-left of the
display, shows bold as bright, uses palette slot 7 on slot 0 as the default
colours, and sends sixteen colours.
lane_keys: The keyboard keys under the display that drive the F-key lane, as
``(label, centre)`` pairs in display pixels: one for each slot of the
:attr:`deck`, from left to right. The window draws them under its display, so
that a chip that moves away from its keyboard key is visible. The user can
press them there with the mouse. Empty for a handheld whose lane keys are at
a different place.
shifted: The Shift+F-key of the desktop, mapped to the keyboard key that the
keyboard of the handheld sends instead. The PicoCalc's keyboard sends F6–F10
for Shift+F1–F5. The Cardputer Zero's keyboard sends a shifted F4–F8, for
which no translation is necessary.
"""
platform: Platform
@@ -52,30 +57,31 @@ class EmulatedDevice:
@property
def id(self) -> str:
"""The device's id: its platform's name (``picocalc-lyra``)."""
"""The id of the handheld: the name of its platform (``picocalc-lyra``)."""
return self.platform.name
@property
def color_depth(self) -> ColorDepth:
"""What prompt_toolkit may send: sixteen colours to a console, truecolour otherwise."""
"""What prompt_toolkit can send: sixteen colours to a console, else truecolour."""
return ColorDepth.DEPTH_4_BIT if self.console else ColorDepth.TRUE_COLOR
@property
def deck(self) -> LaneDeck:
"""The F-key lane the device's platform deals (:mod:`~meshterm.ui.tui.fkeys`)."""
"""The F-key lane deck of the platform of the handheld (:mod:`~meshterm.ui.tui.fkeys`)."""
from ..ui.tui.fkeys import DECKS
return DECKS[self.platform.lane_deck]
def translate(self, key: Key) -> Key:
"""The key as the device's keyboard would send it (see :attr:`shifted`)."""
"""The keyboard key as the keyboard of the handheld sends it (refer to :attr:`shifted`)."""
if key.shift and key.name in self.shifted:
return Key(name=self.shifted[key.name], ctrl=key.ctrl, alt=key.alt)
return key
#: M5Stack's Cardputer Zero: a 320×170 panel MeshTerm draws itself, its lane on Fn+4…8 —
#: the five keys right under the panel, at the positions M5's drawing gives them.
#: M5Stack's Cardputer Zero: a 320×170 display that MeshTerm draws itself. Its lane is on
#: Fn+4…8, the five keyboard keys directly under the display, at the positions that the
#: drawing from M5 gives them.
CARDPUTER_ZERO_DEVICE = EmulatedDevice(
platform=CARDPUTER_ZERO,
name="Cardputer Zero",
@@ -83,8 +89,8 @@ CARDPUTER_ZERO_DEVICE = EmulatedDevice(
lane_keys=(("4", 48), ("5", 104), ("6", 160), ("7", 216), ("8", 272)),
)
#: ClockworkPi's PicoCalc with a Luckfox Lyra core: a 320×320 panel the Linux console draws
#: in 6×12 cells, its lane on F1–F5 with Shift sending F6–F10.
#: ClockworkPi's PicoCalc with a Luckfox Lyra core: a 320×320 display that the Linux
#: console draws in 6×12 cells. Its lane is on F1–F5, and Shift sends F6–F10.
PICOCALC_LYRA_DEVICE = EmulatedDevice(
platform=PICOCALC_LYRA,
name="PicoCalc (Luckfox Lyra)",
@@ -93,14 +99,14 @@ PICOCALC_LYRA_DEVICE = EmulatedDevice(
shifted={f"f{n}": f"f{n + 5}" for n in range(1, 6)},
)
#: Every device the emulator offers, by id.
#: All the handhelds that the emulator offers, by id.
DEVICES: dict[str, EmulatedDevice] = {
device.id: device for device in (CARDPUTER_ZERO_DEVICE, PICOCALC_LYRA_DEVICE)
}
def device(name: str) -> EmulatedDevice:
"""The device called ``name``, or a ``ValueError`` naming the ones there are."""
"""The handheld called ``name``, or a ``ValueError`` that names the available ones."""
try:
return DEVICES[name.strip().lower()]
except KeyError:
+16 -14
View File
@@ -1,14 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
"""Fetch the Terminus 12-pixel fonts the emulator draws in — once, and only when asked.
"""Download the Terminus 12-pixel fonts that the emulator draws in: one time, only on request.
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
emulator reads (:func:`~.font.font_dir`). ``meshterm emulate --fetch-fonts`` runs it.
Terminus has the SIL Open Font License. Thus MeshTerm never ships it. This module
downloads the upstream release and checks that it is the correct archive. Then it
extracts ``ter-u12n.bdf``, ``ter-u12b.bdf``, and the licence next to them into the
directory that the emulator reads (:func:`~.font.font_dir`). ``meshterm emulate
--fetch-fonts`` runs it.
Where Python can't fetch it — a certificate store that rejects the mirror — the archive can
be downloaded any other way and handed over with ``--archive``; it is checked against the
same digest either way.
Python can fail to download the archive, for example when a certificate store rejects the
mirror. Then you can download the archive in a different way and give it with
``--archive``. In both cases, the module checks the archive against the same digest.
"""
from __future__ import annotations
@@ -26,24 +27,25 @@ 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.
#: The SHA-256 of the release archive, so that a mirror cannot return a different file.
SHA256 = "d961c1b781627bf417f9b340693d64fc219e0113ad3a3af1a3424c7aa373ef79"
_ROOT = "terminus-font-4.49.1/"
_WANTED = (REGULAR_BDF, BOLD_BDF, "OFL.TXT")
def install_terminus(archive: Path | None = None, say: Callable[[str], None] = print) -> Path:
"""Download (or take) Terminus 4.49.1, verify it, and install the fonts and licence.
"""Download (or take) Terminus 4.49.1, check it, and install the fonts and the licence.
Args:
archive: The release archive, already downloaded; ``None`` downloads it.
say: Where progress lines go.
archive: The release archive, already downloaded. ``None`` downloads it.
say: Where the progress lines go.
Returns:
The directory the fonts were installed into.
The directory that the fonts were installed into.
Raises:
ValueError: The archive is not the release (its digest differs) or lacks a file.
ValueError: The archive is not the release (its digest is different), or a file
is missing from it.
OSError: The download or a write failed.
"""
target = font_dir()
+75 -62
View File
@@ -1,17 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
"""The 6×12 bitmap font the emulator draws cells in.
"""The 6×12 bitmap font that the emulator 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 emulator 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; and so is its braille (:data:`BRAILLE`),
solid tiles where Terminus draws the dots apart.
The base is Terminus at 12 pixels. It is also the base of the PicoCalc's font, so the two
handhelds look the same. It has 1,356 glyphs. These cover all that MeshTerm draws on this
platform, except a small number of its own marks. The emulator is not a kernel console, so
no limit of 512 glyphs applies: no donor slots, and no braille folded away.
Terminus is licensed under the SIL Open Font License, so it is never shipped inside
MeshTerm: the emulator loads it from a BDF file on the machine (:func:`find_font`). On the
desktop, ``scripts/cardputer-zero/fetch-terminus.py`` puts the two 12-pixel BDFs, with their
MeshTerm's marks are drawn over the base from the same pixel art that the PicoCalc's font
build uses (:data:`MARKS`). Thus ``★``, ``❯``, ``⚿``, and the other marks look the same on
both handhelds. The braille is also the same (:data:`BRAILLE`): solid tiles, where
Terminus draws the dots apart.
Terminus has the SIL Open Font License. Thus it is never shipped in MeshTerm: the
emulator reads it from a BDF file on the machine (:func:`find_font`). On the desktop,
``scripts/cardputer-zero/fetch-terminus.py`` puts the two 12-pixel BDF files, with their
licence, where :func:`find_font` looks.
"""
@@ -27,92 +29,97 @@ from ..core.config import default_config_dir
CELL_W = 6
CELL_H = 12
#: The file names :func:`find_font` looks for: regular, then bold.
#: The file names that :func:`find_font` looks for: regular, then bold.
REGULAR_BDF = "ter-u12n.bdf"
BOLD_BDF = "ter-u12b.bdf"
#: Where a font lives when ``MESHTERM_EMULATOR_FONTS`` names no other directory.
#: The environment variable that names another directory for the fonts (refer to
#: :func:`font_dir`).
FONT_ENV = "MESHTERM_EMULATOR_FONTS"
def _art(rows: list[str]) -> bytes:
"""Pixel art (``#`` lit) as one byte per row, the leftmost pixel the high bit."""
"""Pixel art as one byte for each row.
A ``#`` is a pixel that is on. The leftmost pixel is 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-lyra/calculinux-console-font-6x12.sh``, ``MARKS``), so a mark
#: means one picture on every handheld.
#: MeshTerm's own marks, drawn over the base font. The pixel art is the same as in the
#: PicoCalc's font build (``scripts/picocalc-lyra/calculinux-console-font-6x12.sh``,
#: ``MARKS``). Thus a mark has the same picture on each handheld.
# fmt: off
MARKS: dict[int, bytes] = {
0x25CF: _art([ # BLACK CIRCLE -- node / unread
0x25CF: _art([ # BLACK CIRCLE: node / unread
"......", "......", "..##..", ".####.", "######", "######",
"######", "######", ".####.", "..##..", "......", "......"]),
0x25C9: _art([ # FISHEYE -- sensor
0x25C9: _art([ # FISHEYE: sensor
"......", "......", "..##..", ".####.", "##..##", "#.##.#",
"#.##.#", "##..##", ".####.", "..##..", "......", "......"]),
0x2605: _art([ # BLACK STAR -- you / best
0x2605: _art([ # BLACK STAR: you / best
"......", "..##..", "..##..", "######", ".####.", "..##..",
".####.", "##..##", "#....#", "......", "......", "......"]),
0x2014: _art([ # EM DASH -- title separator, full width
0x2014: _art([ # EM DASH: title separator, full width
"......", "......", "......", "......", "......", "######",
"######", "......", "......", "......", "......", "......"]),
0x2713: _art([ # CHECK MARK -- ok
0x2713: _art([ # CHECK MARK: ok
"......", "......", "......", ".....#", "....#.", "...#..",
"#.#...", ".#....", "......", "......", "......", "......"]),
0x2717: _art([ # BALLOT X -- error
0x2717: _art([ # BALLOT X: error
"......", "......", "......", "#....#", ".#..#.", "..##..",
"..##..", ".#..#.", "#....#", "......", "......", "......"]),
0x25B6: _art([ # RIGHT-POINTING TRIANGLE -- run / play
0x25B6: _art([ # RIGHT-POINTING TRIANGLE: run / play
"......", "#.....", "##....", "###...", "####..", "#####.",
"#####.", "####..", "###...", "##....", "#.....", "......"]),
0x276F: _art([ # HEAVY RIGHT ANGLE QUOTE -- the list selection cursor
0x276F: _art([ # HEAVY RIGHT ANGLE QUOTE: the mark of the highlight in a list
"......", "##....", ".##...", "..##..", "...##.", "....##",
"....##", "...##.", "..##..", ".##...", "##....", "......"]),
0x25B8: _art([ # SMALL RIGHT-POINTING TRIANGLE -- grabbed/reorder cursor
0x25B8: _art([ # SMALL RIGHT-POINTING TRIANGLE: the mark of a grabbed row (reorder)
"......", "......", "......", ".#....", ".##...", ".###..",
".###..", ".##...", ".#....", "......", "......", "......"]),
0x2026: _art([ # HORIZONTAL ELLIPSIS -- "opens further prompts", truncation
0x2026: _art([ # HORIZONTAL ELLIPSIS: "opens further prompts", truncation
"......", "......", "......", "......", "......", "......",
"......", "......", "#.#.#.", "#.#.#.", "......", "......"]),
0x26A0: _art([ # WARNING SIGN -- the warn status mark
0x26A0: _art([ # WARNING SIGN: the warn status mark
"......", "..##..", ".#..#.", ".#..#.", "#....#", "#.##.#",
"#.##.#", "#....#", "#.##.#", "######", "......", "......"]),
0x232B: _art([ # ERASE TO THE LEFT -- the backspace key in footer hints
0x232B: _art([ # ERASE TO THE LEFT: the Backspace key in footer hints
"......", "......", "......", "..####", ".#...#", ".##.##",
"#..#.#", ".##.##", ".#...#", "..####", "......", "......"]),
0x21E7: _art([ # UPWARDS WHITE ARROW -- the shift key in footer hints
0x21E7: _art([ # UPWARDS WHITE ARROW: the Shift key in footer hints
"......", "......", "..##..", ".#..#.", "#....#", "##..##",
".#..#.", ".#..#.", ".#..#.", ".####.", "......", "......"]),
0x2699: _art([ # GEAR -- parameter/config
0x2699: _art([ # GEAR: parameter/config
"......", "......", "..##..", ".####.", "######", "##..##",
"##..##", "######", ".####.", "..##..", "......", "......"]),
0x21BB: _art([ # CLOCKWISE OPEN CIRCLE ARROW -- re-read/refresh
0x21BB: _art([ # CLOCKWISE OPEN CIRCLE ARROW: re-read/refresh
"......", "....#.", ".#####", "#...#.", "#.....", "#.....",
"#.....", "#....#", ".####.", "......", "......", "......"]),
0x25F7: _art([ # WHITE CIRCLE UPPER RIGHT QUADRANT -- the clock face (sync/time)
0x25F7: _art([ # WHITE CIRCLE UPPER RIGHT QUADRANT: the clock face (sync/time)
"......", "......", ".####.", "#..#.#", "#..#.#", "#..###",
"#....#", "#....#", ".####.", "......", "......", "......"]),
0x2316: _art([ # POSITION INDICATOR -- the crosshair (trace/map position)
0x2316: _art([ # POSITION INDICATOR: the crosshair (trace/map position)
"......", "..##..", "..##..", "......", "......", "#.##.#",
"#.##.#", "......", "......", "..##..", "..##..", "......"]),
0x26BF: _art([ # SQUARED KEY (drawn as a padlock) -- private channel
0x26BF: _art([ # SQUARED KEY (drawn as a padlock): private channel
"......", "......", ".####.", ".#..#.", "######", "######",
"##..##", "##..##", "######", "######", "......", "......"]),
}
# fmt: on
#: Which byte bit lights which pixel columns, and which pixel rows, for each braille dot:
#: two columns of three pixels and four rows of three, tiling the whole cell.
#: For each braille dot: the byte bits that turn on its pixel columns, and its pixel rows.
#: There are two columns of three pixels and four rows of three pixels. Together, they
#: cover the whole cell.
_BRAILLE_COLS = (0xE0, 0x1C)
_BRAILLE_ROWS = ((0, 1, 2), (3, 4, 5), (6, 7, 8), (9, 10, 11))
#: Dot bit per (column, row) within a braille cell — the Unicode standard layout.
#: The dot bit for each (column, row) in a braille cell: the standard layout of Unicode.
_BRAILLE_DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
def _braille(value: int) -> bytes:
"""The braille cell whose low byte is ``value``, each dot a solid 3×3 tile."""
"""The braille cell whose low byte is ``value``. Each dot is a solid 3×3 tile."""
rows = [0] * CELL_H
for col, bits in enumerate(_BRAILLE_DOTS):
for row, bit in enumerate(bits):
@@ -122,20 +129,22 @@ def _braille(value: int) -> bytes:
return bytes(rows)
#: Every braille cell, drawn over the base font as solid tiles with no gap between dots or
#: between neighbouring cells — the same tiles as the PicoCalc's font build
#: (``scripts/picocalc-lyra/calculinux-console-font-6x12.sh``, ``BANDS12``). MeshTerm never sets
#: braille to be read: every braille cell is pixels, a chart, the map, a QR code, and a
#: dotted one breaks every line it draws into beads.
#: All the braille cells, drawn over the base font as solid tiles. There is no gap between
#: the dots, or between cells that are next to each other. The tiles are the same as in the
#: PicoCalc's font build (``scripts/picocalc-lyra/calculinux-console-font-6x12.sh``,
#: ``BANDS12``). MeshTerm never uses braille as text to read. Each braille cell is pixels:
#: a chart, the map, a QR code. A braille cell with separate dots breaks each line that it
#: draws into a row of separate dots.
BRAILLE: dict[int, bytes] = {0x2800 + value: _braille(value) for value in range(256)}
#: Codepoints drawn as another's glyph, as the PicoCalc's font aliases them.
#: Codepoints that are drawn with the glyph of a different codepoint, 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.
#: The glyph for a character that the font has no glyph for: a hollow box. Thus a gap in
#: the font is visible, and not a blank cell that gives no warning.
MISSING = _art(
["......", "#####.", "#...#.", "#...#.", "#...#.", "#...#."]
+ ["#...#.", "#...#.", "#...#.", "#####.", "......", "......"]
@@ -143,15 +152,16 @@ MISSING = _art(
def load_bdf(path: Path) -> dict[int, bytes]:
"""The glyphs of a BDF font sized to the 6×12 cell, by codepoint.
"""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.
Each glyph is one byte for each pixel row, and the leftmost pixel is the high bit. The
bounding box of the glyph and the ascent of the font set its position in the cell.
Thus a glyph that is narrower or shorter than the cell is at the position that the
designer of the font gave it.
Raises:
OSError: The file can't be read.
ValueError: It isn't a BDF font.
OSError: The file cannot be read.
ValueError: The file is not a BDF font.
"""
glyphs: dict[int, bytes] = {}
ascent = CELL_H - 2
@@ -186,7 +196,7 @@ def load_bdf(path: Path) -> dict[int, bytes]:
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."""
"""The BDF rows of one glyph, 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)
@@ -194,7 +204,8 @@ def _place(bitmap: list[int], box: tuple[int, int, int, int], ascent: int) -> by
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.
# Align the row to the left of its byte. Then shift it right by the x offset of
# the glyph.
value = bits >> (row_bits - 8) if row_bits > 8 else bits
rows[y] = (value >> max(0, x_off)) & 0xFF
return bytes(rows)
@@ -202,7 +213,7 @@ def _place(bitmap: list[int], box: tuple[int, int, int, int], ascent: int) -> by
@dataclass(frozen=True, slots=True)
class Font:
"""A regular and a bold face, each a codepoint → 12-byte glyph table."""
"""A regular face and a bold face. Each face is a codepoint → 12-byte glyph table."""
regular: dict[int, bytes]
bold: dict[int, bytes]
@@ -227,9 +238,10 @@ class Font:
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 and braille over both.
A missing bold face reuses the regular one, emboldened by a one-pixel smear — all but
the braille, which is pixels rather than letters, so bold has no weight to add to it
and the smear would only close the seam between a cell's two columns.
If the bold face is missing, the font uses the regular face, made bold by a one-pixel
smear. The braille is not smeared. Braille is pixels, not letters, so bold has no
weight to add to it. On braille, the smear only closes the seam between the two
columns of a cell.
"""
regular = {**regular, **MARKS, **BRAILLE}
if bold is None:
@@ -241,16 +253,17 @@ def build_font(regular: dict[int, bytes], bold: dict[int, bytes] | None = None)
def font_dir() -> Path:
"""Where :func:`find_font` looks: ``$MESHTERM_EMULATOR_FONTS``, else the config ``fonts``."""
"""Where :func:`find_font` looks: ``$MESHTERM_EMULATOR_FONTS``, else ``fonts`` in config."""
override = os.environ.get(FONT_ENV, "").strip()
return Path(override) if override else default_config_dir() / "fonts"
def find_font() -> Font:
"""Load the emulator's font from :func:`font_dir`.
"""Read the font of the emulator from :func:`font_dir`.
Raises:
FileNotFoundError: There is no regular face there, with how to fetch one.
FileNotFoundError: There is no regular face in that directory. The error tells how
to download one.
"""
directory = font_dir()
regular_path = directory / REGULAR_BDF
+62 -49
View File
@@ -1,28 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
"""On the Cardputer Zero: pixels to the framebuffer, keys from the keyboard's event device.
"""On the Cardputer Zero: pixels to the framebuffer, key events from the keyboard's device file.
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, and SIGKILL 3 s after that). This front end
follows that contract: Esc goes through :mod:`~meshterm.services.hold_to_quit`, which shows
the hold on the panel the launcher has stopped drawing, and the SIGTERM is taken as the
quit it is (:func:`~meshterm.services.hold_to_quit.leave_on_sigterm`).
The launcher starts an app with the screen for that app only, and tells it where things
are. ``APPLAUNCH_LINUX_FBDEV_DEVICE`` names the framebuffer of the display, and
``APPLAUNCH_LINUX_KEYBOARD_DEVICE`` names its keyboard. The launcher shares the keyboard
and does not grab it. Thus the app reads the key events itself, while the launcher
watches the same stream for a held Esc key (3 s, then SIGTERM to the process group of the
app, and SIGKILL 3 s after that).
**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. **Run on the device
since 2026-10-05**, where the key codes and the Sym layer checked out against the keyboard
and M5's installed keymap, and the panel turned out to need its pixels written through a
mapping (:class:`Framebuffer`).
This front end obeys that contract. The Esc key goes through
:mod:`~meshterm.services.hold_to_quit`, which shows the hold on the display, where the
launcher does not draw any more. The front end takes the SIGTERM as a quit, because that is
what it is (:func:`~meshterm.services.hold_to_quit.leave_on_sigterm`).
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.
**Written before the hardware was available** (2026-09-30), from the published sources of
M5:
* the framebuffer and keyboard paths, and the Esc policy, from the launcher,
* the key codes, from the keymaps of the keyboard driver, and
* the characters of the Sym layer, from the console keymap of M5
(``tca8418_keypad_m5stack_keymap.map``).
The key mapping has unit tests. **Run on the handheld since 2026-10-05.** There, the key
codes and the Sym layer agreed with the keyboard and with the installed keymap of M5. We
also found there that the display must get its pixels through a memory mapping
(:class:`Framebuffer`).
The keyboard driver does the Fn layer in the kernel. Thus Fn+4 arrives as a plain
``KEY_F4``, and the arrows arrive as real arrow keys. Shift is a real ``KEY_LEFTSHIFT``
that stays down while the sticky Shift is armed. Sym is the exception. Its layer sends
placeholder key codes (``KEY_LEFTBRACE`` for ``!``, …), and the keymap of M5 gives them
their symbols. This module gives them the same symbols, and it is the only place in
MeshTerm that knows them.
"""
from __future__ import annotations
@@ -43,14 +52,15 @@ 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.
#: The keyboard device file in the launcher's own default, when no variable names one.
DEFAULT_KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event"
#: ``struct input_event`` on a 64-bit kernel: timeval, then type, code, value.
#: ``struct input_event`` on a 64-bit kernel: timeval, then type, code, and value.
_EVENT = struct.Struct("llHHi")
_EV_KEY = 0x01
#: ``KEY_ESC``: held, the launcher's way out (see :mod:`~meshterm.services.hold_to_quit`).
#: ``KEY_ESC``. When it is held, it is the launcher's way out (refer to
#: :mod:`~meshterm.services.hold_to_quit`).
_ESC = 1
# Modifier key codes (linux/input-event-codes.h).
@@ -58,7 +68,7 @@ _SHIFT = {42, 54}
_CTRL = {29, 97}
_ALT = {56, 100}
#: Key codes that are not text, by the name :mod:`.keys` spells them.
#: Key codes that are not text, mapped to the names that :mod:`.keys` spells them with.
_NAMED: dict[int, str] = {
1: "escape",
14: "backspace",
@@ -80,7 +90,7 @@ _NAMED: dict[int, str] = {
88: "f12",
}
#: The base layer's characters.
#: The characters of the base layer.
_TEXT: dict[int, str] = {
**{2 + n: "1234567890"[n] for n in range(10)},
**{16 + n: "qwertyuiop"[n] for n in range(10)},
@@ -89,7 +99,8 @@ _TEXT: dict[int, str] = {
57: " ",
}
#: The Sym layer's placeholder codes and the characters M5's console keymap gives them.
#: The placeholder codes of the Sym layer, and the characters that the console keymap of
#: M5 gives them.
_SYM: dict[int, str] = {
26: "!", 27: "@", 39: "#", 40: "$", 41: "%", 43: "^", 51: "&", 52: "*",
53: "(", 94: ")", 55: "~", 69: "`", 70: "_", 71: "-", 72: "+", 73: "=",
@@ -99,7 +110,7 @@ _SYM: dict[int, str] = {
class KeyState:
"""Turns the keyboard's events into :class:`~.keys.Key` presses, tracking modifiers."""
"""Changes keyboard events into :class:`~.keys.Key` presses, and tracks the modifiers."""
def __init__(self) -> None:
"""Start with no modifier held."""
@@ -107,11 +118,11 @@ class KeyState:
@property
def shift(self) -> bool:
"""Whether a Shift key is held — or armed, which the driver reports the same way."""
"""Whether a Shift key is held or armed. The driver reports the two in 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."""
"""The key press that one ``EV_KEY`` event makes (``value`` 1 down, 2 repeat, 0 up)."""
if code in _SHIFT | _CTRL | _ALT:
if value:
self._held.add(code)
@@ -135,21 +146,23 @@ class KeyState:
class Framebuffer:
"""The panel's framebuffer, written a band of pixel rows at a time through a mapping.
"""The display's framebuffer, written a band of pixel rows at a time through a memory mapping.
**Through a mapping, never ``write()``.** The panel is a DRM driver (``panel-mipi-dbi``)
whose ``/dev/fb0`` is the kernel's fbdev emulation: a buffer the kernel copies to the
panel only where it knows something changed, and what it watches is the *mapping* —
a write through it faults the page, and the page goes on the next refresh. Bytes sent
with ``pwrite`` land in the same buffer, where reading ``/dev/fb0`` back shows them, and
never reach the glass: on the device MeshTerm ran with the launcher's loading screen
still showing over it, and the reader quit a splash they couldn't see. M5's own apps
draw through the mapping too (LVGL's ``lv_linux_fbdev``). Found on the hardware,
2026-10-05.
**Through a memory mapping, never ``write()``.** The display has a DRM driver
(``panel-mipi-dbi``), and its ``/dev/fb0`` is the fbdev emulation of the kernel. That
is a buffer that the kernel copies to the display only where it knows that something
changed. What the kernel watches is the memory mapping. A write through the mapping
causes a page fault, and the kernel sends that page at the next update of the display.
Bytes from ``pwrite`` go into the same buffer, and a read of ``/dev/fb0`` shows them.
But they never get to the display. On the handheld, MeshTerm ran while the loading
screen of the launcher stayed visible over it, and the user quit a splash that they
could not see. The apps of M5 also draw through the memory mapping (LVGL's
``lv_linux_fbdev``). We found this on the hardware, 2026-10-05.
"""
def __init__(self, path: str, *, width: int, height: int) -> None:
"""Open and map the framebuffer at ``path`` for a ``width`` × ``height`` panel."""
"""Open and map the framebuffer at ``path``, for a ``width`` × ``height`` display."""
self.path = path
self._fd = os.open(path, os.O_RDWR)
stride = self._sysfs("stride")
@@ -159,7 +172,7 @@ class Framebuffer:
self._map = mmap.mmap(
self._fd,
self.stride * height,
mmap.MAP_SHARED, # type: ignore[attr-defined] # Linux only, as the panel is
mmap.MAP_SHARED, # type: ignore[attr-defined] # Linux only, as is the display
mmap.PROT_READ | mmap.PROT_WRITE, # type: ignore[attr-defined]
)
@@ -184,7 +197,7 @@ class Framebuffer:
class Device:
"""The front end on the device: a framebuffer writer and a keyboard reader."""
"""The front end on the handheld: a framebuffer writer and a keyboard reader."""
def __init__(
self,
@@ -196,7 +209,7 @@ class Device:
framebuffer: str,
keyboard: str,
) -> None:
"""Open the framebuffer, and start the drawing and key-reading threads."""
"""Open the framebuffer, then start two threads: one draws, one reads the keyboard."""
self._lock = lock
self._type = type_text
self._raster = Raster(
@@ -210,7 +223,7 @@ class Device:
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."""
"""Wake the thread that draws: a frame is complete."""
self._ready.set()
def close(self) -> None:
@@ -243,7 +256,7 @@ class Device:
if kind != _EV_KEY:
continue
if code == _ESC:
# Down and its repeats are one hold; a tap is typed as it comes up.
# Down and its repeats are one hold. A tap is typed when it comes up.
if value:
esc.down()
else:
@@ -261,14 +274,14 @@ class Device:
def front_end():
"""The device as a :data:`~.run.FrontEndFactory`, from the launcher's environment."""
"""The handheld 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
# From here, not only from the CLI: the launcher's SIGTERM can land while MeshTerm is
# still importing, and there it should stop as an interrupt rather than unhandled.
# From here, not only from the CLI: the launcher's SIGTERM can arrive while MeshTerm
# still imports its modules. Then MeshTerm must stop as an interrupt, not unhandled.
hold_to_quit.leave_on_sigterm()
def build(terminal: Terminal, lock: threading.Lock, type_text: Callable[[str], None]):
+15 -14
View File
@@ -1,26 +1,27 @@
# SPDX-License-Identifier: Apache-2.0
"""Keys to the bytes a terminal sends for them — what the emulator types into MeshTerm.
"""The bytes that a terminal sends for each keyboard key: what the emulator types into MeshTerm.
Both front ends meet here: the device reads key codes off the keyboard's event
device, the window reads them off the desktop, 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-zero`` sends too, so the TUI cannot tell the three apart:
Both front ends use this module. The handheld reads key codes from the event device file
of the keyboard, and the window reads them from the desktop. Each front end changes its
own events into a :class:`Key`, and :func:`encode` spells it as xterm does. The spelling
of xterm is the contract, because prompt_toolkit parses it. A desktop terminal that runs
``--platform cardputer-zero`` sends it too. Thus 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
reads as F16–F20. These are the Shift bank of the Cardputer deck
(:data:`~meshterm.ui.tui.fkeys.CARDPUTER_ZERO_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.
* A modified arrow, Home, End, Page key, or F-key has the modifier parameter of xterm
(``1 + Shift + 2·Alt + 4·Ctrl``). Thus Ctrl+PgUp is ``CSI 5;5 ~``, the section jump.
* Ctrl with a letter is the C0 control of that letter. Alt with any keyboard key is ESC,
then that keyboard 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 ~``.
#: The keyboard keys that are not text, by name. Arrows, Home, and End take the modified
#: form ``CSI 1;m X`` of xterm. The others take ``CSI n;m ~``.
_CURSOR_FINAL = {"up": "A", "down": "B", "right": "C", "left": "D", "home": "H", "end": "F"}
_TILDE = {
"insert": 2,
@@ -42,7 +43,7 @@ _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."""
"""One key press: a named keyboard key (``"up"``, ``"f4"``, ``"enter"``) or its text."""
name: str = ""
text: str = ""
@@ -52,7 +53,7 @@ class Key:
def encode(key: Key) -> str:
"""The bytes a terminal sends for ``key``, or ``""`` for one it has no spelling for."""
"""The bytes that a terminal sends for ``key``, or ``""`` if no spelling exists for it."""
modifier = 1 + key.shift + 2 * key.alt + 4 * key.ctrl
name = key.name
if name in _CURSOR_FINAL:
+34 -31
View File
@@ -1,18 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
"""Cells to pixels: the panel the emulator shows, drawn from a :class:`~.vt.Terminal`.
"""Cells to pixels: the display that the emulator shows, drawn from a :class:`~.vt.Terminal`.
The Cardputer Zero's 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. A
Linux console (the PicoCalc's) instead starts its grid at the panel's top-left corner, and
draws bold as *bright* — a dim palette colour becomes its bright twin, in the same glyph —
which ``top_left`` and ``bold_is_bright`` reproduce. 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.
The Cardputer Zero's display is 320×170. Its grid of 53 columns of 6 pixels and 14 rows of
12 pixels leaves a two-pixel margin at the right and at the bottom. This module divides
the margin equally, so that the grid is at the centre. A Linux console (the PicoCalc's)
starts its grid at the top-left corner of the display instead. It also draws bold as
bright: a dim palette colour becomes the matching bright colour, in the same glyph.
``top_left`` and ``bold_is_bright`` copy these two behaviours.
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.
The pixels are packed directly into the byte layout that the destination wants: 16-bit
RGB565 for the Cardputer's framebuffer, 24-bit RGB for a desktop window. Thus nothing
converts a full frame on its way out.
Only the rows that the terminal reports as dirty are drawn again. A glyph row is drawn by
lookup, not pixel by pixel: each triple of (6-bit row pattern, foreground, background) is
packed one time and used again. This is what makes a full paint affordable on a 1 GHz
core.
"""
from __future__ import annotations
@@ -22,33 +25,33 @@ 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.
#: The size of the Cardputer Zero's display, in pixels.
PANEL_W = 320
PANEL_H = 170
#: A packer: one colour to the destination's bytes for one pixel.
#: A packer: one colour to the bytes of the destination, for one pixel.
Packer = Callable[[RGB], bytes]
def rgb565(colour: RGB) -> bytes:
"""``colour`` as one little-endian RGB565 pixel (the Cardputer framebuffer's format)."""
"""``colour`` as one little-endian RGB565 pixel (the format of the Cardputer's framebuffer)."""
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)."""
"""``colour`` as one 24-bit RGB pixel (the format of a desktop window)."""
return bytes(colour)
def _blend(a: RGB, b: RGB) -> RGB:
"""Halfway from ``a`` to ``b`` — how a dim cell's ink is drawn."""
"""Halfway from ``a`` to ``b``: how the ink of a dim cell 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.
"""The pixels of the display, kept in step with a terminal.
Args:
terminal: The grid to draw.
@@ -56,13 +59,13 @@ class Raster:
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.
top_left: Start the grid at the panel's top-left corner, as a Linux console does,
rather than centring it.
bold_is_bright: Draw bold the Linux console's way: a foreground from the palette's
eight dim slots takes its bright twin (slot ``n + 8``) and the glyph stays
regular, since a console font has no bold face.
width: The width of the display in pixels.
height: The height of the display in pixels.
top_left: Start the grid at the top-left corner of the display, as a Linux
console does, instead of at the centre.
bold_is_bright: Draw bold as the Linux console does. A foreground from the eight
dim slots of the palette takes the matching bright colour (slot ``n + 8``),
and the glyph stays regular, because a console font has no bold face.
"""
def __init__(
@@ -78,7 +81,7 @@ class Raster:
top_left: bool = False,
bold_is_bright: bool = False,
) -> None:
"""Lay the grid out on the panel and paint it blank."""
"""Lay out the grid on the display, and fill it with the default paper."""
self.terminal = terminal
self.font = font
self.pack = pack
@@ -96,7 +99,7 @@ class Raster:
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)."""
"""The six pixels of one glyph row, packed (cached by pattern and colours)."""
key = (bits, fg, bg)
span = self._spans.get(key)
if span is None:
@@ -108,11 +111,11 @@ class Raster:
return span
def update(self) -> list[tuple[int, int]]:
"""Redraw the rows the terminal says changed.
"""Draw again the rows that the terminal reports as changed.
Returns:
The pixel row bands ``(first_y, last_y_exclusive)`` that changed, in order,
for a destination that writes only what moved.
The bands of pixel rows ``(first_y, last_y_exclusive)`` that changed, in
order. They are for a destination that writes only the parts that changed.
"""
rows = sorted(self.terminal.take_dirty())
for row in rows:
@@ -127,7 +130,7 @@ class Raster:
return bands
def redraw(self) -> None:
"""Mark every row dirty and redraw the whole panel."""
"""Mark each row as dirty, and draw the whole display again."""
self.terminal.take_dirty()
for row in range(self.terminal.rows):
self._draw_row(row)
+31 -26
View File
@@ -1,13 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
"""Run MeshTerm inside the emulator: the TUI unchanged, its terminal ours.
"""Run MeshTerm in the emulator: the TUI does not change, and the emulator supplies its terminal.
:func:`run` builds the three pieces every front end shares — the :class:`~.vt.Terminal` the
TUI's bytes land in, the prompt_toolkit output that writes to it (:func:`panel_output`), and
a pipe input the keys go into — sets them as prompt_toolkit's app session, and calls the
ordinary CLI with ``--platform`` set to the emulated device, 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.
:func:`run` builds the three parts that all the front ends share:
* the :class:`~.vt.Terminal` that the bytes of the TUI go to,
* the prompt_toolkit output that writes to that terminal (:func:`panel_output`), and
* a pipe input that the key presses go into.
Then :func:`run` sets them as the app session of prompt_toolkit. It calls the ordinary CLI
with ``--platform`` set to the emulated handheld, exactly as a shell does. A *front end* is
the part that is different: where the pixels go, and where the key presses come from. The
front end gets the terminal, the lock that guards it, and a function to type with. It
starts the threads that it uses, and it gets a call each time a frame is complete.
"""
from __future__ import annotations
@@ -33,24 +37,25 @@ def _hex(colour: str) -> RGB:
def _palette() -> tuple[RGB, ...]:
"""The 16 named colours: the same RGBs the PicoCalc's console is programmed with."""
"""The 16 named colours: the same RGB values that the PicoCalc's console is set to."""
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.
#: The ink of a cell whose style names no ink: the default foreground of a desktop
#: terminal. The truecolour theme that this platform runs was designed against it.
DEFAULT_FG: RGB = (204, 204, 204)
#: The paper of a cell whose style names none.
#: The paper of a cell whose style names no paper.
DEFAULT_BG: RGB = (0, 0, 0)
def default_colours(device: EmulatedDevice) -> tuple[RGB, RGB]:
"""The ink and paper of a cell whose style names none, on ``device``.
"""The ink and the paper of a cell whose style names none, on ``device``.
A Linux console's are its own palette's slot 7 on slot 0; MeshTerm drawing a panel
itself uses a desktop terminal's, which its truecolour theme was designed against.
A Linux console uses slot 7 on slot 0 of its own palette. When MeshTerm draws a
display itself, it uses the colours of a desktop terminal, because its truecolour
theme was designed against them.
"""
if device.console:
palette = _palette()
@@ -59,7 +64,7 @@ def default_colours(device: EmulatedDevice) -> tuple[RGB, RGB]:
class _Sink:
"""The text stream a :class:`Vt100_Output` writes to: bytes into the terminal."""
"""The text stream that a :class:`Vt100_Output` writes to: bytes into the terminal."""
encoding = "utf-8"
@@ -95,7 +100,7 @@ def panel_output(
on_frame: Callable[[], None],
depth: ColorDepth = ColorDepth.TRUE_COLOR,
) -> Vt100_Output:
"""A prompt_toolkit output drawing into ``terminal``: fixed size, ``depth`` colours."""
"""A prompt_toolkit output that draws into ``terminal``: a fixed size, ``depth`` colours."""
size = Size(rows=terminal.rows, columns=terminal.cols)
return Vt100_Output(
_Sink(terminal, lock, on_frame), # type: ignore[arg-type]
@@ -107,13 +112,13 @@ def panel_output(
class FrontEnd(Protocol):
"""Where the pixels go and its keys come from."""
"""Where the pixels go, and where the key presses come from."""
def frame_ready(self) -> None:
"""A complete frame has landed in the terminal; draw it when convenient."""
"""A complete frame is in the terminal. Draw it when it is convenient."""
def close(self) -> None:
"""The TUI has ended; stop."""
"""The TUI has ended. Stop."""
#: Builds a front end from the terminal, its lock, and a function that types into the TUI.
@@ -127,12 +132,12 @@ def run(
) -> int:
"""Run the MeshTerm CLI with ``argv`` as it runs on ``device``, drawn by ``front_end``.
The CLI resolves ``device``'s platform as one MeshTerm draws itself
(:func:`~meshterm.platforms.drawn_by_meshterm`): a PicoCalc's console is drawn by the
emulator here, not by the terminal the emulator was started from.
The CLI resolves the platform of ``device`` as a platform that MeshTerm draws itself
(:func:`~meshterm.platforms.drawn_by_meshterm`). Thus here the emulator draws the
console of a PicoCalc, not the terminal from which the emulator was started.
Returns:
The CLI's exit status.
The exit status of the CLI.
"""
from .. import cli
@@ -148,8 +153,8 @@ def run(
output = panel_output(terminal, lock, end.frame_ready, device.color_depth)
try:
with create_app_session(input=pipe, output=output), platforms.drawn_by_meshterm():
# Standalone, as a shell would run it: the CLI reports its own errors and
# ends by raising SystemExit with the status.
# Standalone, as a shell runs it: the CLI reports its own errors. At the
# end, it raises SystemExit with the status.
cli.app(args=["--platform", device.id, *argv], windows_expand_args=False)
return 0
except SystemExit as done:
+43 -41
View File
@@ -1,18 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
"""The terminal the emulator draws: a VT byte stream parsed into a grid of cells.
"""The terminal that the emulator 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.
This module is small on purpose. Its only producer is MeshTerm itself: the
``Vt100_Output`` of prompt_toolkit, and the fast row writer of the session. Thus the
module understands what those two write, and the small number of similar sequences that a
stray library can write. It ignores the rest of the VT220/xterm sequences, with no
message. It understands cursor movement and addressing, erase and insert/delete, scroll
regions, the alternate screen, autowrap, and SGR in all three colour depths (16, 256, and
truecolour, with ``;`` or ``:`` separators).
The grid is the source of truth for the pixels: :class:`Terminal` records which rows
changed since the emulator last drew (:meth:`Terminal.take_dirty`), so a keystroke that moves
the highlight one row repaints two rows of the panel, not fourteen.
The module skips what it does not model by the grammar: a whole escape sequence, never a
part of one. Thus an unknown sequence can never put its parameters on the screen as text.
The grid is the source of truth for the pixels. :class:`Terminal` keeps a list of the rows
that changed since the emulator last drew (:meth:`Terminal.take_dirty`). Thus a key press
that moves the highlight one row draws two rows of the display again, not fourteen.
"""
from __future__ import annotations
@@ -25,7 +27,7 @@ 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).
#: Style flags. :attr:`Style.flags` is a bitmask of these flags.
BOLD = 1
DIM = 2
ITALIC = 4
@@ -37,23 +39,23 @@ STRIKE = 64
@dataclass(frozen=True, slots=True)
class Style:
"""How a cell is drawn: its colours (``None`` = the terminal's default) and flags."""
"""How a cell is drawn: its colours (``None`` = the terminal's default) and its flags."""
fg: RGB | None = None
bg: RGB | None = None
flags: int = 0
#: The style a fresh or erased cell has.
#: The style of a new or erased cell.
PLAIN = Style()
#: A cell: the character drawn there (``""`` for the right half of a wide character)
#: and its style.
#: A cell: the character that is 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 an emulator that passes none of its own.
#: A standard 16-colour palette, for an emulator that gives no palette 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),
@@ -68,7 +70,7 @@ _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."""
"""The colour that the xterm 256-colour index ``index`` names."""
if index < 16:
return palette[index]
if index < 232:
@@ -83,14 +85,14 @@ def _xterm_256(index: int, palette: Sequence[RGB]) -> RGB:
class Terminal:
"""A ``cols`` × ``rows`` character grid driven by a VT byte stream.
"""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.
cols: The width in cells.
rows: The height in cells.
palette: The 16 colours that SGR 30–37/90–97 (and 256-colour indices 0–15) name.
reply: Called with the bytes that a real terminal sends as the answer to a query
(a cursor-position report, a device-attributes reply). ``None`` ignores them.
"""
def __init__(
@@ -101,7 +103,7 @@ class Terminal:
palette: Sequence[RGB] = _DEFAULT_PALETTE,
reply: Callable[[str], None] | None = None,
) -> None:
"""Start blank, the cursor home, in the main screen."""
"""Start blank, with the cursor at home, in the main screen."""
self.cols = cols
self.rows = rows
self.palette = tuple(palette)
@@ -129,22 +131,22 @@ class Terminal:
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."""
"""The rows that changed since the last call. This call forgets them."""
dirty, self._dirty = self._dirty, set()
return dirty
def line(self, row: int) -> str:
"""Row ``row`` as plain text, wide characters once each."""
"""Row ``row`` as plain text, with each wide character one time."""
return "".join(char for char, _ in self.screen[row])
def text(self) -> str:
"""The whole screen as plain text, one line per row."""
"""The whole screen as plain text, one line for each 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."""
"""Parse ``data``, and update the grid."""
for char in data:
state = self._state
if state == _GROUND:
@@ -165,7 +167,7 @@ class Terminal:
elif char >= " ":
self._params += char
else:
self._control(char) # C0 controls act even mid-sequence
self._control(char) # C0 controls act also in the middle of a sequence
elif state == _ESC:
self._escape(char)
elif state == _OSC:
@@ -178,7 +180,7 @@ class Terminal:
self._state = _ESC
elif char == "\x07":
self._state = _GROUND
else: # _CHARSET: the designator's one final character
else: # _CHARSET: the one final character of the designator
self._state = _GROUND
def _escape(self, char: str) -> None:
@@ -204,7 +206,7 @@ class Terminal:
self._reverse_index()
elif char == "c":
self.__init__(self.cols, self.rows, palette=self.palette, reply=self._reply)
# "=", ">", "\\" and anything else: nothing to draw.
# "=", ">", "\\", and all other characters: nothing to draw.
def _control(self, char: str) -> None:
if char == "\r":
@@ -218,7 +220,7 @@ class Terminal:
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.
# BEL, SO/SI, and the other controls draw nothing.
# --- drawing --------------------------------------------------------------------------
@@ -226,7 +228,7 @@ class Terminal:
width = cell_len(char)
row = self.screen[self.y]
if width == 0:
# A combining mark rides the cell before it.
# A combining mark goes into 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]
@@ -254,7 +256,7 @@ class Terminal:
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."""
"""Before ``x`` is overwritten: if it is half of a wide character, blank the other half."""
char, style = row[x]
if char == "" and x > 0:
row[x - 1] = (" ", row[x - 1][1])
@@ -262,7 +264,7 @@ class Terminal:
row[x + 1] = (" ", style)
def _erase_style(self) -> Style:
"""What an erased cell carries: the current background, nothing else."""
"""The style of an erased cell: the current background, and 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:
@@ -335,7 +337,7 @@ class Terminal:
self._private_mode(params, final == "h")
return
if private:
return # secondary DA and kin: nothing to draw
return # secondary DA and similar queries: nothing to draw
if final == "m":
self._sgr(params)
elif final in "Hf":
@@ -401,7 +403,7 @@ class Terminal:
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.
# All other sequences (window ops, cursor shape, ...) change nothing here.
def _erase_display(self, mode: int) -> None:
if mode == 0:
@@ -457,14 +459,14 @@ class Terminal:
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.
# Flatten "38:2::r:g:b" (colon sub-parameters) into the sequence of the ";" form.
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
rest = ["2", *rest[2:]] # remove 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)
+69 -59
View File
@@ -1,29 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
"""The emulator's desktop window: a device's panel in a window, keys from the desktop.
"""The emulator's desktop window: a handheld's display, with key presses from the desktop.
Everything between the two ends is the device's own code path — 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 (:meth:`~.devices.EmulatedDevice.translate`). Where the device's lane keys sit right
under its panel (the Cardputer Zero's 4 to 8), the window draws them there, at the positions
the maker's drawing gives them, so a chip that drifts off its key is visible.
All the code between the two ends is the code path of the handheld itself: the terminal,
the font, the rasterizer, and the key encoding. Thus the window shows the pixels of the
display, at a larger scale and without smoothing. The desktop keyboard types what the
keyboard of the handheld will type (:meth:`~.devices.EmulatedDevice.translate`).
The desktop's own F-keys stand in for the device's lane keys — F4–F8 for the Cardputer
Zero's Fn+4…8, F1–F5 for the PicoCalc's — 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. The mouse presses them too: a click on a drawn key, or on
a chip of the lane itself, types that slot's key, and Shift held through the click types
its Shift companion — the app reads the bank off the key that arrives, never off the
Shift it saw, so the window sends the shifted key itself (:func:`lane_press`). The drawn
keys wear the lane's own fill, and take its Shift fill while Shift is down, as the chips
above them do (:func:`lane_fills`). Closing the window leaves MeshTerm at once, as ^Q
twice would. Ctrl+Shift+S saves the panel at its true size as a PNG in the working
directory. Esc held in the window quits, as it does on either device
(:mod:`~meshterm.services.hold_to_quit`): the window sees a key come up, as a terminal
never does.
When the lane keys of the handheld are directly under its display (the Cardputer Zero's 4
to 8), the window draws them there, at the positions that the drawing of the maker gives
them. Thus a chip that moves away from its keyboard key is visible.
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.
The F-keys of the desktop act as the lane keys of the handheld: F4–F8 for the Cardputer
Zero's Fn+4…8, and F1–F5 for the PicoCalc's lane keys. Shift with them is the second
bank. The window also reports Shift to the lane when Shift goes down and up. Thus the bank
changes on the screen as it will on the handheld.
The mouse also presses the lane keys. A click on a drawn keyboard key, or on a chip of the
lane itself, types the keyboard key of that slot. If Shift is held during the click, the
click types the Shift companion of that slot. The app reads the bank from the keyboard key
that arrives, never from the Shift that it saw. Thus the window sends the shifted keyboard
key itself (:func:`lane_press`). The drawn keyboard keys have the fill of the lane, and
they take its Shift fill while Shift is down, as the chips above them do
(:func:`lane_fills`).
A close of the window quits MeshTerm immediately, the same as ^Q pressed two times.
Ctrl+Shift+S saves the display at its true size as a PNG file in the working directory. A
held Esc key in the window quits MeshTerm, as it does on each handheld
(:mod:`~meshterm.services.hold_to_quit`). The window sees a keyboard key come up, and a
terminal never does.
Tk runs on its own thread, and the TUI runs on the main thread. The two threads share only
the lock of the terminal, a flag that says that a frame is ready, and the function that
types.
"""
from __future__ import annotations
@@ -66,16 +74,17 @@ _NAMED = {
_SHIFTS = ("Shift_L", "Shift_R")
#: How long a released Esc waits to be sure it was let go. An X server repeats a held key
#: as a release and a press back to back, and the press arrives within this; a desktop
#: that repeats with presses alone never sends the release until the key is up.
#: How long a released Esc key waits, to be sure that the user let it go. An X server
#: repeats a held keyboard key as a release and a press, one directly after the other, and
#: the press arrives within this time. A desktop that repeats with presses only never sends
#: the release before the keyboard key is up.
_REPEAT_GAP_MS = 30
#: A drawn lane key's cap, and the ink of the digit printed on it.
#: The cap of a drawn lane key, and the ink of the digit that is printed on it.
_KEY_BODY = "#1d2025"
_KEY_DIGIT = "#eef1f4"
#: Tk's modifier bits: Shift and Control everywhere, Alt where the platform puts it.
#: The modifier bits of Tk: Shift and Control on all platforms, Alt where the platform puts it.
_SHIFT_BIT = 0x0001
_CTRL_BIT = 0x0004
_ALT_BIT = 0x20000 if sys.platform == "win32" else 0x0008
@@ -99,23 +108,23 @@ def key_from_tk(keysym: str, char: str, state: int) -> Key | None:
def lane_press(device: EmulatedDevice, slot: int, *, shift: bool) -> str:
"""What pressing lane slot ``slot`` types on ``device``, with Shift held or not.
"""What a press of lane slot ``slot`` types on ``device``, with Shift held or not.
The slot's plain key, spelled as the desktop's F-key standing in for it would be —
so Shift becomes the PicoCalc's F6–F10 and the Cardputer Zero's shifted F4–F8 just as
it does from the keyboard (:meth:`~.devices.EmulatedDevice.translate`).
The plain keyboard key of the slot, spelled as the desktop F-key that acts for it is
spelled. Thus Shift becomes the PicoCalc's F6–F10 and the Cardputer Zero's shifted
F4–F8, the same as from the keyboard (:meth:`~.devices.EmulatedDevice.translate`).
"""
key = Key(name=f"f{device.deck.keys[slot]}", shift=shift)
return encode(device.translate(key))
def lane_fills(device: EmulatedDevice) -> tuple[str, str]:
"""The lane's chip fills on ``device`` as Tk colours: the plain bank's, then Shift's.
"""The chip fills of the lane on ``device``, as Tk colours: the plain bank, then Shift.
Resolved from the deck's own styles in the theme the device runs, through the palette
the panel is drawn with — a numbered colour is a palette slot, as the console's every
colour is, and anything else its own RGB — so a drawn key is the colour the chip
above it is.
The fills come from the styles of the deck itself, in the theme that the handheld
runs, through the palette that the display is drawn with. A numbered colour is a
palette slot, as each colour of the console is. Any other colour is its own RGB. Thus a
drawn keyboard key has the same colour as the chip above it.
"""
theme = MESH_THEME if device.platform.truecolor else MESH_THEME_16
palette = _palette()
@@ -137,11 +146,11 @@ def lane_fills(device: EmulatedDevice) -> tuple[str, str]:
def chip_at(terminal: Terminal, deck: LaneDeck, col: int, row: int) -> int | None:
"""The lane slot whose chip is drawn at cell ``(col, row)``, or ``None``.
The lane is the frame's footer, its last row, and every chip there opens on its key's
caption — a live chip, a dimmed one and an unassigned slot's bare caption alike — so
a caption at the chip's own column is what says the lane is drawn. A frame without
one (a bare share screen, the chromeless splash) leaves the row to its own content,
and a click there presses nothing.
The lane is the footer of the frame, its last row. Each chip there starts with the
caption of its keyboard key: a live chip, a dim chip, and the bare caption of an
unassigned slot. Thus a caption at the chip's own column shows that the lane is drawn.
A frame without a lane (a bare share screen, the chromeless splash) leaves the row to
its own content, and a click there presses nothing.
"""
if row != terminal.rows - 1:
return None
@@ -157,7 +166,7 @@ def chip_at(terminal: Terminal, deck: LaneDeck, col: int, row: int) -> int | Non
class EmulatorWindow:
"""A window showing ``device``'s panel, typing the desktop's keys into the TUI."""
"""A window that shows the display of ``device``, and types desktop key presses into the TUI."""
def __init__(
self,
@@ -169,7 +178,7 @@ class EmulatorWindow:
font: Font,
scale: int = 3,
) -> None:
"""Open the window on a thread of its own, and wait until it is up."""
"""Open the window on its own thread, and wait until it is open."""
self._terminal = terminal
self._lock = lock
self._type = type_text
@@ -204,7 +213,7 @@ class EmulatorWindow:
# --- the TUI's side ---------------------------------------------------------------
def frame_ready(self) -> None:
"""Note that a frame is complete; the window draws it on its next tick."""
"""Note that a frame is complete. The window draws it on its next tick."""
self._ready.set()
def close(self) -> None:
@@ -238,9 +247,9 @@ class EmulatorWindow:
self._draw_keys(canvas, margin, scale)
root.bind("<KeyPress>", self._on_press)
root.bind("<KeyRelease>", self._on_release)
# A Shift let go in another window never reaches this one; leaving it held here
# would keep the lane and the drawn keys in the Shift bank — and an Esc left held
# would quit three seconds later.
# When the user lets go of Shift in a different window, this window never gets the
# release. If Shift stays held here, the lane and the drawn keyboard keys stay in
# the Shift bank. Also, an Esc key that stays held quits three seconds later.
root.bind("<FocusOut>", self._on_focus_out)
root.protocol("WM_DELETE_WINDOW", self._on_close)
root.focus_force()
@@ -274,9 +283,10 @@ class EmulatorWindow:
self._paint_keys()
def _paint_keys(self) -> None:
"""Colour the drawn keys in the lane's bank: its fill, and its captions.
"""Colour the drawn keyboard keys in the bank of the lane: its fill, and its captions.
A key held down under the mouse is filled the way its chip is, white on the fill.
A keyboard key that is held down under the mouse has the same fill as its chip:
white on the fill.
"""
canvas = self._tk[1]
deck = self._device.deck
@@ -300,7 +310,7 @@ class EmulatorWindow:
self._paint_keys()
def _on_panel_click(self, event) -> None:
"""A click on the panel presses the lane chip it lands on, if it lands on one."""
"""A click on the display presses the lane chip at that position, if a chip is there."""
x = (event.x - self._margin) // self._scale - self._raster.left
y = (event.y - self._margin) // self._scale - self._raster.top
if x < 0 or y < 0:
@@ -317,7 +327,7 @@ class EmulatorWindow:
self._type(data)
def _set_shift(self, down: bool) -> None:
"""Tell the lane Shift went down or up, and flip the drawn keys' bank with it."""
"""Tell the lane that Shift went down or up, and change the drawn keys to that bank."""
modifier_watch.report_shift(down)
if down != self._shifted:
self._shifted = down
@@ -372,7 +382,7 @@ class EmulatorWindow:
self._esc_released()
def _esc_pressed(self) -> None:
"""Esc went down — or, straight after its release, the X server repeated it."""
"""The Esc key went down. Or, directly after its release, the X server repeated it."""
if self._esc_letting_go is not None:
self._tk[0].after_cancel(self._esc_letting_go)
self._esc_letting_go = None
@@ -381,7 +391,7 @@ class EmulatorWindow:
self._esc.down()
def _esc_released(self) -> None:
"""Esc is up, and no repeat followed: the hold is over."""
"""The Esc key is up, and no repeat followed: the hold is over."""
if self._esc_letting_go is not None:
self._tk[0].after_cancel(self._esc_letting_go)
self._esc_letting_go = None
@@ -389,7 +399,7 @@ class EmulatorWindow:
self._esc.up()
def _on_close(self) -> None:
# ^Q asks; a second ^Q while it asks leaves at once.
# ^Q asks. A second ^Q while it asks quits immediately.
self._type("\x11")
time.sleep(0.2)
self._type("\x11")
@@ -401,17 +411,17 @@ class EmulatorWindow:
def front_end(device: EmulatedDevice, scale: int = 3):
"""The window as a :data:`~.run.FrontEndFactory`, loading the emulator's font first.
"""The window as a :data:`~.run.FrontEndFactory`. It reads the emulator's font first.
Raises:
FileNotFoundError: The font isn't installed (see :func:`~.font.find_font`).
RuntimeError: This Python has no Tk to open a window with — the one-file builds
leave it out, so the emulator wants MeshTerm installed with pip or pipx.
FileNotFoundError: The font is not installed (refer to :func:`~.font.find_font`).
RuntimeError: This Python has no Tk to open a window with. The one-file builds do
not include Tk, so the emulator must have MeshTerm installed with pip or pipx.
"""
from .font import find_font
try:
import tkinter # noqa: F401 - only asking whether it is there
import tkinter # noqa: F401 - only a check that it is there
except ImportError:
raise RuntimeError(
"the emulator's window needs Tk, which this copy of MeshTerm doesn't include "