diff --git a/docs/devices/README.md b/docs/devices/README.md index b383f6c..6dde0a4 100644 --- a/docs/devices/README.md +++ b/docs/devices/README.md @@ -98,6 +98,8 @@ what the window reproduces. Your keyboard's F-keys stand in for the lane's keys: **F1** to **F5** for the PicoCalc's, and **F4** to **F8** for the Cardputer Zero's **Fn+4** to **Fn+8**. Hold **Shift** for the +second set. You can also click them: click a coloured key at the bottom of the screen, or +one of the Cardputer Zero's keys drawn under it, and hold **Shift** while you click for the second set. Everything you type after the device's name goes to the MeshTerm in the window: `--mock` for the simulated radio, or `--port`, `--ble` or `--tcp` for a real one. `--scale` sets the zoom, **Ctrl+Shift+S** saves a screenshot at the screen's real size, and closing diff --git a/docs/devices/cardputer-zero.md b/docs/devices/cardputer-zero.md index a9a3fb6..1feced1 100644 --- a/docs/devices/cardputer-zero.md +++ b/docs/devices/cardputer-zero.md @@ -88,7 +88,9 @@ meshterm emulate cardputer-zero --mock made-up nodes stay out of your real history. `--scale 4` makes the window bigger. In the window, your keyboard's **F4** to **F8** stand in for **Fn+4** to **Fn+8**, and -holding **Shift** shows the second set. **Ctrl+Shift+S** saves a screenshot at the screen's +holding **Shift** shows the second set. You can click the keys instead, either the ones +drawn under the screen or the coloured keys along its bottom row; hold **Shift** while you +click for the second set. **Ctrl+Shift+S** saves a screenshot at the screen's real size. Closing the window quits MeshTerm. ## Radios diff --git a/meshterm/emulator/devices.py b/meshterm/emulator/devices.py index c543227..171d182 100644 --- a/meshterm/emulator/devices.py +++ b/meshterm/emulator/devices.py @@ -11,12 +11,16 @@ picocalc-lyra`` and ``--platform picocalc-lyra`` say the same device. from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING from prompt_toolkit.output.color_depth import ColorDepth from ..platforms import CARDPUTER_ZERO, PICOCALC_LYRA, Platform from .keys import Key +if TYPE_CHECKING: + from ..ui.tui.fkeys import LaneDeck + @dataclass(frozen=True) class EmulatedDevice: @@ -30,8 +34,10 @@ class EmulatedDevice: 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, drawn under the window's panel so a chip that - drifts off its key shows. Empty for a device whose lane keys sit elsewhere. + 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. @@ -54,6 +60,13 @@ class EmulatedDevice: """What prompt_toolkit may send: sixteen colours to a console, truecolour otherwise.""" 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`).""" + 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`).""" if key.shift and key.name in self.shifted: diff --git a/meshterm/emulator/window.py b/meshterm/emulator/window.py index eb59795..3d2fbed 100644 --- a/meshterm/emulator/window.py +++ b/meshterm/emulator/window.py @@ -11,8 +11,14 @@ the maker's drawing gives them, so a chip that drifts off its key is visible. 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. 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. +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. 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. @@ -27,12 +33,14 @@ from collections.abc import Callable from pathlib import Path from ..services import modifier_watch +from ..ui.theme import MESH_THEME, MESH_THEME_16 +from ..ui.tui.fkeys import LaneDeck from .devices import EmulatedDevice -from .font import Font +from .font import CELL_H, CELL_W, Font from .keys import Key, encode from .raster import Raster -from .run import default_colours -from .vt import Terminal +from .run import _palette, default_colours +from .vt import Terminal, _xterm_256 _NAMED = { "Up": "up", @@ -56,6 +64,10 @@ _NAMED = { _SHIFTS = ("Shift_L", "Shift_R") +#: A drawn lane key's cap, and the ink of the digit printed on it. +_KEY_BODY = "#1d2025" +_KEY_DIGIT = "#eef1f4" + #: Tk's modifier bits: Shift and Control everywhere, Alt where the platform puts it. _SHIFT_BIT = 0x0001 _CTRL_BIT = 0x0004 @@ -79,6 +91,64 @@ def key_from_tk(keysym: str, char: str, state: int) -> Key | None: return None +def lane_press(device: EmulatedDevice, slot: int, *, shift: bool) -> str: + """What pressing 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`). + """ + 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. + + 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. + """ + theme = MESH_THEME if device.platform.truecolor else MESH_THEME_16 + palette = _palette() + deck = device.deck + + def fill(style: str) -> str: + colour = theme.styles[style].bgcolor + if colour is None: + return _KEY_BODY + if colour.number is not None: + red, green, blue = _xterm_256(colour.number, palette) + else: + red, green, blue = colour.get_truecolor() + return f"#{red:02x}{green:02x}{blue:02x}" + + return fill(deck.fill), fill(deck.shift_fill) + + +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. + """ + if row != terminal.rows - 1: + return None + cells = terminal.screen[row] + for slot, start in enumerate(deck.columns): + if not start <= col < start + deck.chip_width: + continue + for caption in (deck.captions[slot], deck.shift_captions[slot]): + if "".join(char for char, _ in cells[start : start + len(caption)]) == caption: + return slot + return None + return None + + class EmulatorWindow: """A window showing ``device``'s panel, typing the desktop's keys into the TUI.""" @@ -110,6 +180,10 @@ class EmulatorWindow: top_left=device.console, bold_is_bright=device.console, ) + self._margin = 6 * scale + self._fills = lane_fills(device) + self._shifted = False + self._pressed: int | None = None self._ready = threading.Event() self._ready.set() self._closing = False @@ -134,7 +208,7 @@ class EmulatorWindow: scale = self._scale panel_w, panel_h = self._device.panel - margin = 6 * scale + margin = self._margin keys_h = 26 * scale if self._device.lane_keys else 0 root = tk.Tk() root.title(f"MeshTerm — {self._device.name} emulator") @@ -149,10 +223,14 @@ class EmulatorWindow: base = tk.PhotoImage(width=panel_w, height=panel_h) shown = base.zoom(scale, scale) canvas.create_image(margin, margin, image=shown, anchor="nw", tags="panel") - self._draw_keys(canvas, margin, scale) + canvas.tag_bind("panel", "", self._on_panel_click) self._tk = (root, canvas, base, shown) + 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. + root.bind("", lambda _: self._set_shift(False)) root.protocol("WM_DELETE_WINDOW", self._on_close) root.focus_force() self._started.set() @@ -162,21 +240,76 @@ class EmulatorWindow: def _draw_keys(self, canvas, margin: int, scale: int) -> None: top = margin + self._device.panel[1] * scale + 4 * scale size = 18 * scale - for number, centre in self._device.lane_keys: + for slot, (number, centre) in enumerate(self._device.lane_keys): x = margin + centre * scale - canvas.create_line(x, top - 3 * scale, x, top, fill="#e34b0f", width=scale) + key = f"key{slot}" + canvas.create_line(x, top - 3 * scale, x, top, width=scale, tags="tick") canvas.create_rectangle( x - size // 2, top, x + size // 2, top + size, - fill="#1d2025", outline="#e34b0f", width=scale, + width=scale, tags=(key, f"cap{slot}"), ) # fmt: skip canvas.create_text( - x, top + 5 * scale, text=f"F{number}", fill="#ff6633", - font=("Consolas", 4 * scale, "bold"), + x, top + 5 * scale, font=("Consolas", 4 * scale, "bold"), + tags=(key, f"legend{slot}"), ) # fmt: skip canvas.create_text( - x, top + 12 * scale, text=str(number), fill="#eef1f4", - font=("Consolas", 6 * scale, "bold"), + x, top + 12 * scale, text=str(number), fill=_KEY_DIGIT, + font=("Consolas", 6 * scale, "bold"), tags=key, ) # fmt: skip + canvas.tag_bind(key, "", lambda event, n=slot: self._press_key(n, event)) + canvas.tag_bind(key, "", lambda _: self._release_key()) + canvas.tag_bind(key, "", lambda _: canvas.configure(cursor="hand2")) + canvas.tag_bind(key, "", lambda _: canvas.configure(cursor="")) + self._paint_keys() + + def _paint_keys(self) -> None: + """Colour the drawn keys in the lane's bank: its fill, and its captions. + + A key held down under the mouse is filled the way its chip is, white on the fill. + """ + canvas = self._tk[1] + deck = self._device.deck + fill = self._fills[self._shifted] + captions = deck.shift_captions if self._shifted else deck.captions + canvas.itemconfigure("tick", fill=fill) + for slot in range(len(self._device.lane_keys)): + down = slot == self._pressed + canvas.itemconfigure(f"cap{slot}", outline=fill, fill=fill if down else _KEY_BODY) + canvas.itemconfigure( + f"legend{slot}", text=captions[slot], fill=_KEY_DIGIT if down else fill + ) + + def _press_key(self, slot: int, event) -> None: + self._pressed = slot + self._paint_keys() + self._press_lane(slot, event.state) + + def _release_key(self) -> None: + self._pressed = None + 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.""" + 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: + return + with self._lock: + slot = chip_at(self._terminal, self._device.deck, x // CELL_W, y // CELL_H) + if slot is not None: + self._press_lane(slot, event.state) + + def _press_lane(self, slot: int, state: int) -> None: + data = lane_press(self._device, slot, shift=bool(state & _SHIFT_BIT)) + if data: + 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.""" + modifier_watch.report_shift(down) + if down != self._shifted: + self._shifted = down + self._paint_keys() def _tick(self) -> None: root, canvas, base, _ = self._tk @@ -199,7 +332,7 @@ class EmulatorWindow: def _on_press(self, event) -> None: if event.keysym in _SHIFTS: - modifier_watch.report_shift(True) + self._set_shift(True) return if event.keysym in ("S", "s") and event.state & _CTRL_BIT and event.state & _SHIFT_BIT: self._save() @@ -212,7 +345,7 @@ class EmulatorWindow: def _on_release(self, event) -> None: if event.keysym in _SHIFTS: - modifier_watch.report_shift(False) + self._set_shift(False) def _on_close(self) -> None: # ^Q asks; a second ^Q while it asks leaves at once. @@ -251,4 +384,4 @@ def front_end(device: EmulatedDevice, scale: int = 3): return build -__all__ = ["EmulatorWindow", "front_end", "key_from_tk"] +__all__ = ["EmulatorWindow", "chip_at", "front_end", "key_from_tk", "lane_fills", "lane_press"] diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 7469bc0..990ed54 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -6,6 +6,7 @@ from __future__ import annotations import threading import time +import pytest from prompt_toolkit.input.vt100_parser import Vt100Parser from prompt_toolkit.keys import Keys @@ -412,3 +413,94 @@ def test_emulate_hands_the_global_options_to_the_meshterm_it_runs(monkeypatch, t ((name, argv, kw),) = calls assert name == "picocalc-lyra" and "--mock" in argv and kw["scale"] == 2 assert "--platform" not in argv # the emulator names the platform itself + + +# --- the lane under the mouse -------------------------------------------------------------- + + +def test_a_clicked_lane_key_types_its_slot_or_with_shift_its_companion() -> None: + """A click types what the desktop's F-key would: the deck's plain bank, Shift its own.""" + from meshterm.emulator.devices import DEVICES + from meshterm.emulator.window import lane_press + + for emulated in DEVICES.values(): + deck = emulated.deck + for slot, (plain, shifted) in enumerate(zip(deck.keys, deck.shift_keys, strict=True)): + assert _parsed(lane_press(emulated, slot, shift=False))[0].value == f"f{plain}" + assert _parsed(lane_press(emulated, slot, shift=True))[0].value == f"f{shifted}" + + +def test_a_click_finds_the_chip_under_it_only_on_a_drawn_lane() -> None: + """Any cell of a chip is that slot, in either bank. + + A gap between chips, another row, or a frame with no lane on its last row is nothing. + """ + from meshterm.emulator.window import chip_at + from meshterm.ui.tui.fkeys import DEFAULT_LANE, PICOCALC_LYRA_DECK + + deck = PICOCALC_LYRA_DECK + term = Terminal(53, 3) + term.feed("\x1b[3;1H" + deck.lane_text(DEFAULT_LANE).plain) + assert [chip_at(term, deck, col, 2) for col in (0, 8, 11, 44, 52)] == [0, 0, 1, 4, 4] + assert chip_at(term, deck, 9, 2) is None # the gap between two chips + assert chip_at(term, deck, 0, 1) is None # not the lane's row + term.feed("\x1b[3;1H" + deck.lane_text(DEFAULT_LANE, shifted=True).plain) + assert chip_at(term, deck, 46, 2) == 4 # F10, captioned "10" + term.feed("\x1b[3;1H\x1b[2Kscan me") + assert chip_at(term, deck, 0, 2) is None # a bare frame's last row + + +def test_the_drawn_keys_wear_the_lane_s_fills() -> None: + """The Cardputer's own fn orange and Shift blue; a console's chips are palette slots.""" + from meshterm.emulator.devices import CARDPUTER_ZERO_DEVICE, PICOCALC_LYRA_DEVICE + from meshterm.emulator.run import _palette + from meshterm.emulator.window import lane_fills + + assert lane_fills(CARDPUTER_ZERO_DEVICE) == ("#e34b0f", "#0f72bd") + palette = _palette() + plain, shifted = (f"#{r:02x}{g:02x}{b:02x}" for r, g, b in (palette[7], palette[2])) + assert lane_fills(PICOCALC_LYRA_DEVICE) == (plain, shifted) + + +@pytest.mark.parametrize("name", ["cardputer-zero", "picocalc-lyra"]) +def test_a_shift_click_on_the_menu_s_quit_chip_leaves_at_once(monkeypatch, name) -> None: + """A click reaches the lane as the key would: Shift on ``Quit?`` is ``Quit!``, no question.""" + from meshterm.emulator.devices import device + from meshterm.emulator.window import chip_at, lane_press + from meshterm.ui import menu + + monkeypatch.setattr(menu, "_arm_exit_watchdog", lambda *args, **kwargs: None) + emulated = device(name) + seen: dict[str, object] = {} + closed = threading.Event() + + class Headless: + def __init__(self, terminal, lock, type_text): + def drive(): + last = terminal.rows - 1 + deadline = time.monotonic() + 30 + while time.monotonic() < deadline and "slot" not in seen: + with lock: + lane = terminal.line(last) + if "Quit?" in lane: + col = lane.index("Quit?") + seen["slot"] = chip_at(terminal, emulated.deck, col, last) + time.sleep(0.1) + type_text(lane_press(emulated, 2, shift=True)) + if not closed.wait(5): + seen["stuck"] = True # still running: leave the way the window does + type_text("\x11") + time.sleep(0.3) + type_text("\x11") + + threading.Thread(target=drive, daemon=True).start() + + def frame_ready(self): + pass + + def close(self): + closed.set() + + assert run(["--mock"], Headless, emulated) == 0 + assert seen["slot"] == 2 # the click on Quit? lands on its chip … + assert "stuck" not in seen # … and Shift with it leaves without asking