The emulator's lane keys can be clicked, and wear the lane's colours

A click on one of the Cardputer Zero's keys drawn under the panel, or on a
chip of the lane along the panel's bottom row, types that slot's key. The
PicoCalc has no keys drawn, so its chips are where you click. Shift held
through the click types the slot's Shift companion. The app reads the bank
off the key that arrives, not off the Shift it saw, so the window spells the
shifted key itself, the way the desktop's Shift+F-key already was: F6 to F10
on the PicoCalc, xterm's shifted F4 to F8 on the Cardputer.

The drawn keys take the lane's own fills, resolved from the deck's styles
through the panel's palette, so a key and the chip above it are one colour.
They turn blue with the lane while Shift is down, and fill solid while
pressed. Losing focus lets go of Shift, which a release in another window
never would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-04 17:24:10 -04:00
1 parent 4922ae8fd5
commit af815cf2f2
5 files changed
+262 -20

No files matched your search

+2
View File
@@ -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
+3 -1
View File
@@ -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
+15 -2
View File
@@ -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:
+150 -17
View File
@@ -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", "<ButtonPress-1>", self._on_panel_click)
self._tk = (root, canvas, base, shown)
self._draw_keys(canvas, margin, scale)
root.bind("<KeyPress>", self._on_press)
root.bind("<KeyRelease>", self._on_release)
# A Shift let go in another window never reaches this one; leaving it held here
# would keep the lane and the drawn keys in the Shift bank.
root.bind("<FocusOut>", 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, "<ButtonPress-1>", lambda event, n=slot: self._press_key(n, event))
canvas.tag_bind(key, "<ButtonRelease-1>", lambda _: self._release_key())
canvas.tag_bind(key, "<Enter>", lambda _: canvas.configure(cursor="hand2"))
canvas.tag_bind(key, "<Leave>", 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"]
+92
View File
@@ -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