diff --git a/meshterm/emulator/__init__.py b/meshterm/emulator/__init__.py index cd14415..b4257e2 100644 --- a/meshterm/emulator/__init__.py +++ b/meshterm/emulator/__init__.py @@ -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`). """ diff --git a/meshterm/emulator/__main__.py b/meshterm/emulator/__main__.py index 0d862f1..72f9cc3 100644 --- a/meshterm/emulator/__main__.py +++ b/meshterm/emulator/__main__.py @@ -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: diff --git a/meshterm/emulator/devices.py b/meshterm/emulator/devices.py index 171d182..4fd154e 100644 --- a/meshterm/emulator/devices.py +++ b/meshterm/emulator/devices.py @@ -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: diff --git a/meshterm/emulator/fetch.py b/meshterm/emulator/fetch.py index 5a5b355..fa04f78 100644 --- a/meshterm/emulator/fetch.py +++ b/meshterm/emulator/fetch.py @@ -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() diff --git a/meshterm/emulator/font.py b/meshterm/emulator/font.py index 5d1608a..ab69242 100644 --- a/meshterm/emulator/font.py +++ b/meshterm/emulator/font.py @@ -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 diff --git a/meshterm/emulator/framebuffer.py b/meshterm/emulator/framebuffer.py index cc60b21..3afaee5 100644 --- a/meshterm/emulator/framebuffer.py +++ b/meshterm/emulator/framebuffer.py @@ -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]): diff --git a/meshterm/emulator/keys.py b/meshterm/emulator/keys.py index d531c79..eaf70cb 100644 --- a/meshterm/emulator/keys.py +++ b/meshterm/emulator/keys.py @@ -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: diff --git a/meshterm/emulator/raster.py b/meshterm/emulator/raster.py index 1b8d10d..dcf1b02 100644 --- a/meshterm/emulator/raster.py +++ b/meshterm/emulator/raster.py @@ -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) diff --git a/meshterm/emulator/run.py b/meshterm/emulator/run.py index 2ac9eb9..8f3acfe 100644 --- a/meshterm/emulator/run.py +++ b/meshterm/emulator/run.py @@ -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: diff --git a/meshterm/emulator/vt.py b/meshterm/emulator/vt.py index aae2a0d..39ae802 100644 --- a/meshterm/emulator/vt.py +++ b/meshterm/emulator/vt.py @@ -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) diff --git a/meshterm/emulator/window.py b/meshterm/emulator/window.py index 7e661fa..ee7953b 100644 --- a/meshterm/emulator/window.py +++ b/meshterm/emulator/window.py @@ -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("", self._on_press) root.bind("", 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("", 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 "