P4: PicoCalc chrome & input - borderless frame, slim header, F-key lane

- compose_base grows a borderless mode (Platform.frame_border=False): a
  one-row title bar (rule + title + clip arrows) replaces the Panel's
  border and padding, so the body wins back four columns and one row on
  the 53-column console. Regular keeps its Panel untouched.
- The header composes only Platform.header_atoms: picocalc drops the
  version mark and the activity pulse, keeping device, badges, battery.
- The F-key lane (ui/tui/fkeys): five FPair slots per screen, F1-F5
  primary and F6-F10 their opposites - which is physical Shift+F1..F5,
  translated to plain keycodes by the keyboard MCU. The session binds
  all ten and resolves a press against the top screen's lane into a
  normal screen action, so screens gain F-keys with no key handling of
  their own. Default lane duplicates the Fn-layered nav cluster plus
  OK/Back. Alt+Fn stays unbound (kernel VT switch).
- services/modifier_watch: a hand-rolled evdev reader (no keyboard-lib
  dependency) tracking physical Shift so the lane flips to the F6-F10
  labels mid-chord; engages only when the platform asks and the device
  exists, degrades to the static lane on any failure.
- The popup-qualifying width gate (surface._dialog_max_cells) shrinks
  to 43 on picocalc, matching the 47-outer dialog cap; the dialog frame
  itself already clamps to cols-6, so prompt.py needed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-08-01 12:56:49 -04:00
1 parent 84caae53f8
commit 2b658f47a9
8 files changed
+433 -35

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
"""The optional Shift-state watcher behind the PicoCalc F-key lane's live flip.
The PicoCalc keyboard's MCU translates Shift+F1..F5 into plain F6–F10 keycodes, so the
terminal never sees the Shift that produced them — but the kernel input device does, and
the ``LSHIFT DOWN`` event arrives *before* the translated F-code (measured in P0). This
watcher reads the raw evdev stream and tracks whether a Shift key is physically held, so
the footer lane can flip to the F6–F10 bank's labels while the user is mid-chord.
Strictly an experiment layered over a working static lane, and built to disappear: it
only ever engages when the platform asks for it (``Platform.modifier_watch``), the input
device exists, and it is readable (the deploy user is in the ``input`` group on the device) — an ssh
session on any other machine, a permissions change, or any read error at all just means
:func:`shift_down` stays ``False`` and the lane stays static. Hand-rolled 30-line evdev
reader instead of the ``keyboard`` library: no dependency to build on the stripped image,
and we need exactly two keycodes.
"""
from __future__ import annotations
import struct
import threading
from pathlib import Path
from typing import Callable, Optional
#: struct input_event on 32-bit ARM: struct timeval (2 × long = 8 bytes), then
#: __u16 type, __u16 code, __s32 value.
_EVENT_FORMAT = "llHHi"
_EVENT_SIZE = struct.calcsize(_EVENT_FORMAT)
_EV_KEY = 0x01
_KEY_LEFTSHIFT = 42
_KEY_RIGHTSHIFT = 54
#: Where the PicoCalc's keyboard MCU registers (same i2c chip as the battery; P0).
_SYS_INPUT = Path("/sys/class/input")
_shift_down = False
_thread: Optional[threading.Thread] = None
def shift_down() -> bool:
"""Whether a Shift key is physically held right now (``False`` when not watching)."""
return _shift_down
def _find_keyboard() -> Optional[Path]:
"""The PicoCalc keyboard's event device, or ``None`` when it isn't this machine."""
try:
for entry in sorted(_SYS_INPUT.glob("event*")):
name = (entry / "device" / "name").read_text().strip().lower()
if "picocalc" in name:
return Path("/dev/input") / entry.name
except OSError:
pass
return None
def start(on_change: Callable[[], None]) -> bool:
"""Start the watcher thread if this machine has the keyboard; ``True`` if it engaged.
Args:
on_change: Called (from the watcher thread) whenever the Shift state flips —
the session wraps this in a thread-safe repaint request.
"""
global _thread
if _thread is not None:
return True
device = _find_keyboard()
if device is None:
return False
try:
stream = open(device, "rb", buffering=0)
except OSError:
return False
def _watch() -> None:
global _shift_down
held = {_KEY_LEFTSHIFT: False, _KEY_RIGHTSHIFT: False}
while True:
try:
data = stream.read(_EVENT_SIZE)
except OSError:
break
if not data or len(data) < _EVENT_SIZE:
break
_, _, etype, code, value = struct.unpack(_EVENT_FORMAT, data)
if etype != _EV_KEY or code not in held:
continue
held[code] = value != 0 # 1 down, 2 auto-repeat, 0 up
now = any(held.values())
if now != _shift_down:
_shift_down = now
try:
on_change()
except Exception: # noqa: BLE001 - a repaint hiccup must not kill the watch
pass
_thread = threading.Thread(target=_watch, name="modifier-watch", daemon=True)
_thread.start()
return True
+38 -27
View File
@@ -155,10 +155,14 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text:
single line (see ``frame.compose_base``), so a too-narrow terminal chops the
tail rather than wrapping.
"""
# Which items compose at all is platform data (see Platform.header_atoms): the
# PicoCalc's 53 columns drop the version mark and the pulse, keeping device, badges
# and battery. Read live — set_platform runs before the session, but tests swap.
atoms = get_platform().header_atoms
# The battery gauge is pinned to the row's right edge, so reserve its width (plus a
# leading separator) before the pulse claims the rest; a wider separator estimate for
# the fit decision is harmless slack.
battery = _battery_segment(ctx)
battery = _battery_segment(ctx) if "battery" in atoms else Text()
# The separator choice changes only the *joins*, never the segments themselves, so the
# segments are built once and each candidate width is arithmetic: one separator per
# segment (each is preceded by one, and a trailing one leads into the pulse). Building
@@ -183,6 +187,8 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text:
header.append(sep)
# Two dot columns per cell: every cell left of the reserved tail shows two minutes.
room = width - header_w - reserve
if "pulse" not in atoms:
room = 0
if room > 0:
# Buckets seeded from a previous session's stored history draw grey; only
# traffic this session actually heard pulses green.
@@ -261,37 +267,42 @@ def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
The segments in display order — the app mark, the device, then whichever badges
have something to report. :func:`_header` writes one separator after each.
"""
atoms = get_platform().header_atoms
segments: list[Text] = []
# Each segment starts as an unstyled Text and takes its styles per append. A base style
# passed to the constructor would instead blanket everything appended after it, layering
# the mark's brand under the version's muted.
mark = Text()
mark.append("MeshTerm", style="brand")
mark.append(f" v{__version__}", style="muted")
segments = [mark]
if "version" in atoms:
mark = Text()
mark.append("MeshTerm", style="brand")
mark.append(f" v{__version__}", style="muted")
segments.append(mark)
device = Text()
if ctx.mock:
device.append("simulator", style="warn")
else:
name, where = _device_label(ctx, cache)
device.append(name or "no device", style=None if name else "muted")
if where:
device.append(f" ({where})", style="muted")
segments.append(device)
if "device" in atoms:
device = Text()
if ctx.mock:
device.append("simulator", style="warn")
else:
name, where = _device_label(ctx, cache)
device.append(name or "no device", style=None if name else "muted")
if where:
device.append(f" ({where})", style="muted")
segments.append(device)
unread = ctx.chat.unread_total()
if unread:
badge = Text()
badge.append("●", style="err")
badge.append(f" {unread}", style="warn")
segments.append(badge)
alerts = ctx.watchtower.unacked_count()
if alerts:
# The Watchtower's badge: a triangle so it never reads as unread mail.
badge = Text()
badge.append("▲", style="err")
badge.append(f" {alerts}", style="warn")
segments.append(badge)
if "badges" in atoms:
unread = ctx.chat.unread_total()
if unread:
badge = Text()
badge.append("●", style="err")
badge.append(f" {unread}", style="warn")
segments.append(badge)
alerts = ctx.watchtower.unacked_count()
if alerts:
# The Watchtower's badge: a triangle so it never reads as unread mail.
badge = Text()
badge.append("▲", style="err")
badge.append(f" {alerts}", style="warn")
segments.append(badge)
return segments
+13 -1
View File
@@ -42,6 +42,18 @@ _DIALOG_MAX_LINES = 3
_DIALOG_MAX_CELLS = 76
def _dialog_max_cells() -> int:
"""The popup-qualifying width for the active platform.
The regular platform keeps the long-standing 76. On the PicoCalc the dialog frame
itself is capped at ``cols - 6 = 47`` outer (43 inner), so a line qualifying at 76
would wrap inside the box it was promoted into — the gate shrinks to match.
"""
from ..platforms import get_platform
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
def _collapse_to_message(buffered: list[RenderableType]) -> Optional[Text]:
"""Collapse small, text-only buffered output into one dialog message.
@@ -65,7 +77,7 @@ def _collapse_to_message(buffered: list[RenderableType]) -> Optional[Text]:
lines.extend(item.split("\n") or [item])
if len(lines) > _DIALOG_MAX_LINES:
return None
if any(line.cell_len > _DIALOG_MAX_CELLS for line in lines):
if any(line.cell_len > _dialog_max_cells() for line in lines):
return None
return Text("\n").join(lines)
+95
View File
@@ -0,0 +1,95 @@
"""The PicoCalc F-key lane: a fixed footer row mapping F1–F5 (and their opposites).
The PicoCalc keyboard has five dedicated function keys; its MCU translates Shift+F1..F5
into plain F6–F10 keycodes (measured in P0), so "Shift+key = the opposite action" is
literal hardware behaviour and the app simply binds all ten. One footer row teaches both
banks: each slot shows its digit and primary label, and — while the optional modifier
watch reports Shift held — flips to the opposite bank's labels live.
A screen describes its lane as five :class:`FPair` slots (``None`` = unassigned); the
session resolves a pressed F-key to the slot's action string and dispatches it through
the normal action funnel, so screens gain F-key support without any key handling of
their own. The defaults duplicate the nav cluster (which sits on an Fn layer on this
keyboard) plus commit/leave — the pairs that mean something on every screen.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Sequence
from rich.text import Text
@dataclass(frozen=True, slots=True)
class FPair:
"""One F-key slot: the primary action (Fn) and its opposite (F(n+5)).
Attributes:
label: Hint text for the primary, ≤7 cells (the lane budgets 5 slots into 53).
action: The screen action the primary dispatches (``"home"``, ``"enter"``, …).
opp_label: Hint text for the opposite bank.
opp_action: The action the opposite dispatches.
"""
label: str
action: str
opp_label: str
opp_action: str
#: A lane is five slots, F1..F5 left to right; ``None`` leaves a slot unassigned.
Lane = Sequence[Optional[FPair]]
#: The screen-agnostic default lane. Top/End and the page pair mirror the nav cluster
#: (Fn-layered on the physical keyboard, so the dedicated F-keys earn their duplicate);
#: F5 commits and its opposite leaves — the one inversion every screen understands.
DEFAULT_LANE: tuple[Optional[FPair], ...] = (
FPair("Top", "home", "End", "end"),
FPair("PgUp", "pageup", "PgDn", "pagedown"),
None,
None,
FPair("OK", "enter", "Back", "escape"),
)
def action_for(lane: Lane, number: int) -> Optional[str]:
"""The action F-key ``number`` (1–10) resolves to on this lane, or ``None``.
F1–F5 take the slot's primary action, F6–F10 its opposite (the physical Shift bank).
"""
index = (number - 1) % 5
if index >= len(lane) or lane[index] is None:
return None
pair = lane[index]
return pair.opp_action if number > 5 else pair.action
def lane_text(lane: Lane, *, shifted: bool = False) -> Text:
"""Render the lane as the fixed footer row: ``1 Top 2 PgUp ··· 5 OK``.
Args:
lane: The five slots.
shifted: Show the opposite bank's digits and labels (the modifier watch saw
Shift go down); the static fallback always shows the primary bank.
Returns:
A one-line :class:`Text`, ≤53 cells with ≤7-cell labels.
"""
row = Text()
for index in range(5):
pair = lane[index] if index < len(lane) else None
if row.plain:
row.append(" ")
number = index + 6 if shifted else index + 1
if pair is None:
row.append(f"{number}", style="track")
continue
label = pair.opp_label if shifted else pair.label
# The row's budget is exact at 53: five 9-cell slots and four 2-cell gaps. A
# label is clipped to its slot rather than pushing the lane over — and F10's
# two-digit key costs its own label a cell.
budget = 6 if number >= 10 else 7
row.append(f"{number}", style="accent")
row.append(f" {label[:budget]}", style="muted")
return row
+50 -5
View File
@@ -164,12 +164,38 @@ def _panel(screen: Screen, inner_w: int, viewport: int, active: bool) -> Panel:
)
def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) -> Text:
"""The borderless frame's one-row title bar: title, a muted rule, clip arrows.
The Panel border's whole vocabulary — where you are (title) and whether the list
continues (the ``↑↓ more`` subtitle) — compressed into a single row so the body wins
back three rows and four columns on the PicoCalc. Shape: ``─ Title ───────── ↑↓``,
echoing :func:`~meshterm.ui.menus.section_heading`'s heading language.
"""
bar = Text()
bar.append("─ ", style="hint.accent")
if screen.title:
bar.append(screen.title, style=title_style("accent"))
bar.append(" ", style=None)
tail = ""
if more_above or more_below:
tail = ("↑" if more_above else " ") + ("↓" if more_below else " ")
fill = cols - bar.cell_len - (len(tail) + 1 if tail else 0)
if fill > 0:
bar.append("─" * fill, style="hint.accent")
if tail:
bar.append(" " + tail, style="hint.accent")
bar.truncate(cols)
return bar
def compose_base(
header: RenderableType,
base: Screen,
footer_hint: str,
cols: int,
rows: int,
footer_lane: RenderableType | None = None,
) -> str:
"""Compose the full-screen ANSI view: header, the base screen's panel, and a footer.
@@ -179,25 +205,44 @@ def compose_base(
footer_hint: The active screen's key hint, shown at the very bottom.
cols: Terminal width.
rows: Terminal height.
footer_lane: A pre-built footer row (the PicoCalc F-key lane) that replaces the
hint string when the platform runs fixed F-key hints.
Returns:
An ANSI string of exactly ``rows`` lines, each within ``cols`` columns.
"""
platform = get_platform()
# The header is a single status line: crop it to one row so a narrow terminal never
# wraps it onto a second line (which would push the panel down and misreport its height).
header_lines = render_lines(header, cols, no_wrap=True)
header_h = len(header_lines)
viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2)
panel = _panel(base, cols - 4, viewport, active=True)
footer = Text.from_markup(f"[muted]{footer_hint}[/muted]")
footer = footer_lane if footer_lane is not None else Text.from_markup(
f"[muted]{footer_hint}[/muted]"
)
if platform.frame_border:
viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2)
panel: RenderableType = _panel(base, cols - 4, viewport, active=True)
body = render_lines(Group(panel, footer), cols)
else:
# Borderless chrome: a one-row title bar instead of the Panel's border and
# padding — the body wins the full terminal width and one extra row.
viewport = max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1)
base.note_viewport(viewport)
body_lines = base.render_body(cols)
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
bar = _title_bar(base, cols, more_above, more_below)
body = (
render_lines(bar, cols, no_wrap=True)
+ visible
+ render_lines(footer, cols, no_wrap=True)
)
# The glow pass lights the outer frame *and* any tool panels nested in the body. It only
# ever recolours truecolor foregrounds (see glow._advance_fg), so on a platform without
# effects — which is also a platform without truecolor — it would scan every line of
# every frame and hand back the identical list. Skipping it outright is the same picture
# for none of the work. Read live rather than bound at import: set_platform() runs in the
# CLI callback, long after this module is imported (see meshterm.platforms).
body = render_lines(Group(panel, footer), cols)
lines = header_lines + (apply_corner_glow(body) if get_platform().effects else body)
lines = header_lines + (apply_corner_glow(body) if platform.effects else body)
# Guarantee we never exceed the terminal height (pt would otherwise clip unpredictably).
if len(lines) > rows:
lines = lines[:rows]
+12
View File
@@ -59,6 +59,18 @@ class Screen:
banner: Optional[Sequence[str]] = None
footnote: Optional[str] = None
@property
def fkey_lane(self):
"""The screen's F-key lane (the PicoCalc footer; see :mod:`~meshterm.ui.tui.fkeys`).
Five slots, F1–F5 with their F6–F10 opposites (the physical Shift bank). The
default duplicates the nav cluster — Fn-layered on the PicoCalc keyboard — plus
commit/leave; a screen with richer verbs overrides this property.
"""
from .fkeys import DEFAULT_LANE
return DEFAULT_LANE
def __init__(self) -> None:
"""Initialize scroll state and the (later-assigned) result future."""
self.scroll = 0
+30 -2
View File
@@ -30,7 +30,8 @@ from rich.console import RenderableType
from rich.text import Text
from ...platforms import get_platform
from . import frame
from ...services import modifier_watch
from . import fkeys, frame
from .overlay import BusyOverlay
from .progress import TuiProgress
from .prompt import (
@@ -92,6 +93,12 @@ _KEY_ACTIONS: dict[Any, str] = {
Keys.Delete: "delete",
Keys.Tab: "tab",
Keys.BackTab: "shift_tab",
# The function keys, for the PicoCalc's F-key lane (F6–F10 are its Shift bank —
# the MCU translates Shift+F1..F5 into these plain keycodes). The session resolves
# them against the top screen's lane in _dispatch; on platforms without the lane
# they resolve to nothing and fall away. Alt+Fn is the kernel's VT switch on the
# device and must never be bound.
**{getattr(Keys, f"F{n}"): f"f{n}" for n in range(1, 11)},
# The Ctrl-letter chords, generated from the one table above so a chord can never be
# bound without its right-Ctrl rescue (or rescued into an action nothing binds).
**{
@@ -1048,6 +1055,12 @@ class TuiSession:
main: The coroutine driving the session (typically the menu loop).
"""
self._app = self._build_app()
if get_platform().modifier_watch:
# The Shift watcher flips the F-key lane's labels live. It reports from its
# own thread; hop onto the app loop for the repaint. Failure to engage (no
# device, no permission) just leaves the lane static — see the module doc.
loop = asyncio.get_running_loop()
modifier_watch.start(lambda: loop.call_soon_threadsafe(self.invalidate))
box: dict[str, BaseException] = {}
async def driver() -> None:
@@ -1286,7 +1299,13 @@ class TuiSession:
if not base.chrome:
return self._emit(frame.compose_startup(base, cols, rows))
footer = self.top.footer_hint if self.top else base.footer_hint
return self._emit(frame.compose_base(self._header(cols), base, footer, cols, rows))
lane = None
if get_platform().footer_fkeys:
active = self.top or base
lane = fkeys.lane_text(active.fkey_lane, shifted=modifier_watch.shift_down())
return self._emit(
frame.compose_base(self._header(cols), base, footer, cols, rows, footer_lane=lane)
)
def _render_float_layer(self, index: int) -> ANSI:
"""Render the ``index``-th floating dialog (bottom-to-top) as a centered box."""
@@ -1366,6 +1385,15 @@ class TuiSession:
the terminal may draw narrower than pt reserves for it (an emoji) upgrades itself to a
full repaint at compose time — see :meth:`_emit`.
"""
# An F-key resolves against the top screen's lane (see ui.tui.fkeys) into a
# normal screen action; an unassigned slot, or a platform without the lane,
# drops the press here.
if len(action) in (2, 3) and action[0] == "f" and action[1:].isdigit():
top = self.top or self._base_screen()
resolved = fkeys.action_for(top.fkey_lane, int(action[1:])) if top else None
if resolved is None:
return
action = resolved
# The key-state probe comes last in each test, so it only runs for a key that could
# be a chord at all — not on every keystroke.
if action in _CTRL_CHORDS and _right_ctrl_down():
+95
View File
@@ -0,0 +1,95 @@
"""P4 chrome contracts: borderless frame, slim header, the F-key lane, dialog gate."""
from __future__ import annotations
from rich.cells import cell_len
from rich.text import Text
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui import frame
from meshterm.ui.tui.fkeys import DEFAULT_LANE, FPair, action_for, lane_text
from meshterm.ui.tui.screen import ScrollScreen
from tests.conftest import plain as _plain
def _screen(lines: int = 40) -> ScrollScreen:
body = Text("\n".join(f"row {i}" for i in range(lines)))
screen = ScrollScreen(body, title="Chrome probe", floating=False)
return screen
def test_borderless_frame_swaps_the_panel_for_a_title_bar() -> None:
set_platform(PICOCALC)
composed = frame.compose_base(Text("hdr"), _screen(), "hint", 53, 26).split("\n")
assert len(composed) == 26
plain = [_plain(line) for line in composed]
# Row 1 (under the one-line header) is the title bar: rule + title + clip arrow.
assert "Chrome probe" in plain[1] and "─" in plain[1]
assert "↓" in plain[1] # 40 rows into a 23-row viewport: more below
# No Panel borders anywhere — the body owns the full width.
assert not any("│" in line or "╭" in line for line in plain)
assert all(cell_len(line) <= 53 for line in plain)
# The body's first row starts at column 0 of row 2 (no panel padding).
assert plain[2].startswith("row 0")
def test_bordered_frame_is_unchanged_on_regular() -> None:
set_platform(REGULAR)
composed = frame.compose_base(Text("hdr"), _screen(), "hint", 72, 24).split("\n")
plain = [_plain(line) for line in composed]
assert any("╭" in line or "┌" in line for line in plain), "regular keeps its Panel"
def test_picocalc_header_drops_version_and_pulse() -> None:
"""The header_atoms wiring: no brand/version mark, no pulse row under picocalc."""
from meshterm.ui.menu import _header_segments
class _Badges:
def unread_total(self) -> int:
return 0
def unacked_count(self) -> int:
return 0
class _Ctx:
mock = True
chat = _Badges()
watchtower = _Badges()
set_platform(PICOCALC)
segments = _header_segments(_Ctx(), {})
text = "".join(seg.plain for seg in segments)
assert "MeshTerm" not in text and "v" not in text.split()[0]
assert "simulator" in text
set_platform(REGULAR)
segments = _header_segments(_Ctx(), {})
assert "MeshTerm" in "".join(seg.plain for seg in segments)
def test_fkey_lane_resolution_and_banks() -> None:
lane = DEFAULT_LANE
assert action_for(lane, 1) == "home" and action_for(lane, 6) == "end"
assert action_for(lane, 2) == "pageup" and action_for(lane, 7) == "pagedown"
assert action_for(lane, 3) is None and action_for(lane, 8) is None
assert action_for(lane, 5) == "enter" and action_for(lane, 10) == "escape"
def test_fkey_lane_text_fits_and_flips() -> None:
primary = lane_text(DEFAULT_LANE)
shifted = lane_text(DEFAULT_LANE, shifted=True)
assert cell_len(primary.plain) <= 53 and cell_len(shifted.plain) <= 53
assert "1 Top" in primary.plain and "5 OK" in primary.plain
assert "6 End" in shifted.plain and "10 Back" in shifted.plain
wide = [FPair("Muchtoolonglabel", "a", "Muchtoolonglabel", "b")] * 5
assert cell_len(lane_text(wide).plain) <= 53 # clipped to the slot budget
assert cell_len(lane_text(wide, shifted=True).plain) <= 53
def test_dialog_gate_shrinks_on_picocalc() -> None:
from meshterm.ui.surface import _dialog_max_cells
set_platform(PICOCALC)
assert _dialog_max_cells() == 43
set_platform(REGULAR)
assert _dialog_max_cells() == 76