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:
1 parent
6b90e0505b
commit
3e251a9707
11 files changed
+433
-371
No files matched your search
@@ -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`).
|
||||
"""
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 "
|
||||
|
||||
Reference in new issue
Block a user