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:
1 parent
4922ae8fd5
commit
af815cf2f2
5 files changed
+262
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user