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:
1 parent
84caae53f8
commit
2b658f47a9
8 files changed
+433
-35
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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():
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user