diff --git a/meshterm/services/modifier_watch.py b/meshterm/services/modifier_watch.py new file mode 100644 index 0000000..c234321 --- /dev/null +++ b/meshterm/services/modifier_watch.py @@ -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 diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index ec5379b..db4a4bc 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -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 diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index e29077d..6e76eed 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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) diff --git a/meshterm/ui/tui/fkeys.py b/meshterm/ui/tui/fkeys.py new file mode 100644 index 0000000..1dc9df0 --- /dev/null +++ b/meshterm/ui/tui/fkeys.py @@ -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 diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 913c8be..3299161 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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] diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 0610901..3d838b4 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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 diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index dd2363e..ef9f748 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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(): diff --git a/tests/test_picocalc_chrome.py b/tests/test_picocalc_chrome.py new file mode 100644 index 0000000..78dc0cb --- /dev/null +++ b/tests/test_picocalc_chrome.py @@ -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