A hint is drawn once per frame

The footer line already carries the top screen's hint, so a floating dialog was
saying the same sentence twice on one frame: once in its own border, once at the
bottom of the terminal. On the desktop the border now says nothing and the
footer is the single place hints live; its clip arrows fall back to the base
frame's own "more" wording, so a silent border still says the one thing every
border here says.

The PicoCalc keeps the border hint, because it has no hint line to defer to —
the lane stands where the footer would be and speaks only for its five chips.
But what the lane does say it says already, one row below, so the border drops
every atom whose keys are all chips: the packet viewer's "PgUp/PgDn scroll" and
"Home/End ends" go, its arrows and Esc stay. An atom survives unless *every* key
it names is covered, so a half-covered pair never tells half a truth, and one
riding in the verb half (the map's Home/^U) keeps the atom whole.

Resolved per paint rather than folded into a screen's hint, since both halves
move: the viewer rewrites its hint as the list grows past one entry, and every
screen reads its lane fresh each frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-31 01:38:59 -04:00
1 parent 69cdd8f07e
commit 332f81583a
5 files changed
+250 -6

No files matched your search

+11
View File
@@ -170,6 +170,17 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
of the generic three (`pick`, `choose`, `commit`) is not. The filter atom is always
`type to filter`.
- A hint is drawn **once per frame**, and which surface draws it is the footer's call.
Where the footer is the hint line (regular) it carries the *top* screen's hint, so a
floating dialog's border stays silent — the same sentence in the border and at the
bottom of the terminal was one of them wasted — and its clip arrows fall back to the
base frame's `↑↓ more`. Where the footer is the F-key lane (picocalc) there is no hint
line, so the border is the only place Enter/Esc/the arrows can be named and it keeps
the hint, less every atom whose keys are all chips on the lane one row below
(`fkeys.strip_lane_atoms` via `frame._dialog_hint`, resolved **per paint** — a screen
rewrites its hint as its content changes and reads its lane fresh every frame). The
chromeless splash has no footer row of any kind and keeps its hint in its own border on
both platforms.
- Key notation: `↑↓ move` (include the verb), `^R`/`^End` for Ctrl chords, `⌫` for
backspace, `⇧` for shift.
+94
View File
@@ -208,3 +208,97 @@ def lane_text(lane: Lane, *, shifted: bool = False) -> Text:
# key is for once it becomes available.
row.append(_chip(number, label), style=fill if live else "muted")
return row
#: The screen action each key notation in a footer hint dispatches — the vocabulary the
#: hint line and the lane have in common, and the only place the two can say the same
#: thing twice. Arrows, Enter, Esc and the letter chords are absent deliberately: no lane
#: slot ever claims them (Enter and Esc are barred outright), so an atom naming one can
#: never be a duplicate of a chip.
_HINT_KEY_ACTIONS: dict[str, str] = {
"PgUp": "pageup",
"PgDn": "pagedown",
"Home": "home",
"End": "end",
"^PgUp": "ctrl_pageup",
"^PgDn": "ctrl_pagedown",
"^Home": "ctrl_home",
"^End": "ctrl_end",
"Tab": "tab",
}
#: Marks a word as *some* key notation even where it isn't one this module can map: a
#: chord (``^U``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). An atom whose
#: verb half carries one of these names more than the keys it opened with, so it is never
#: dropped on the strength of the ones it did name (the map's ``Home/^U region/you``).
_KEYISH = ("^", "⇧", "⌫", "↑", "↓", "←", "→")
def _hint_keys(atom: str) -> tuple[str, ...]:
"""The key notations ``atom`` opens with, or ``()`` where it names something else too.
A hint atom is *keys then verb* — ``PgUp/PgDn scroll``, ``Home/End ends`` — so the
keys are the leading run of words whose every ``/``-separated part this module knows.
The run stops at the verb, and the answer is empty (meaning "keep this atom") both
when the atom opens with a key we can't map (``↑↓ PgUp/PgDn scroll`` documents the
arrows too) and when anything after the run still looks like a key notation
(:data:`_KEYISH`) — dropping such an atom would take an unrepresented key with it.
"""
keys: list[str] = []
verb = False # past the leading key run: everything from here is the atom's verb half
for word in atom.split():
parts = word.split("/")
mappable = all(part in _HINT_KEY_ACTIONS for part in parts)
if not verb and mappable:
keys.extend(parts)
continue
verb = True
if mappable or any(mark in word for mark in _KEYISH):
return () # a second key rides in the verb half — the atom says more than these
return tuple(keys)
def strip_lane_atoms(hint: str, lane: Lane) -> str:
"""``hint`` with every atom the lane already advertises removed.
Where both a hint line and the lane are drawn at once — a floating dialog on the
PicoCalc, whose box carries the hint in its border while the frame's footer row below
carries *its* lane — an atom whose keys are all chips is the same statement twice, in
two notations, and the cells are the dialog's to spend on what the lane doesn't say.
An atom survives unless **every** key it names is a slot's action: a lane covering
only half of ``Home/End ends`` leaves the whole atom standing rather than telling half
a truth.
Dimmed slots count as covering. A dim chip keeps its label and means *a thing here,
just not right now* (see the module docstring) — the reader has been told where the
action lives either way, which is all the hint atom was for.
Runs per paint, never once at construction: a screen rewrites its hint as its content
changes (the packet viewer drops to a bare ``Esc close`` on a single packet) and reads
its lane fresh on every frame, so the overlap between the two is a property of *this*
paint.
Args:
hint: The screen's footer hint, in the standard ``a · b · Esc x`` grammar.
lane: The lane drawn on the same frame.
Returns:
The kept atoms, rejoined in order; ``""`` when the lane says all of it.
"""
covered = {
action
for pair in lane
if pair is not None
for action in (pair.action, pair.opp_action)
if action
}
kept = []
for raw in hint.split("·"):
atom = raw.strip()
if not atom:
continue
keys = _hint_keys(atom)
if keys and all(_HINT_KEY_ACTIONS[key] in covered for key in keys):
continue
kept.append(atom)
return " · ".join(kept)
+56 -6
View File
@@ -19,6 +19,7 @@ from rich.text import Text
from ...platforms import Platform, get_platform, on_platform
from ..logo import load_logo, logo_width
from ..theme import fold_text, hint_style, title_style
from . import fkeys
from .render import crop_cells, render_lines, render_to_ansi
from .screen import Screen
@@ -518,6 +519,56 @@ _DIALOG_CACHE: "OrderedDict[tuple, str]" = OrderedDict()
_DIALOG_CACHE_MAX = 12
def _dialog_hint(screen: Screen) -> str:
"""The hint a floating dialog's own border carries — often none at all.
A dialog is drawn over a frame that already has a footer row, and *what that row is*
decides whether the box needs to speak at all:
- Where the footer is the **hint line** (the desktop), it draws the top screen's hint
— this dialog's — in full, on the row every screen's keys have always been read
from. A subtitle repeating it was the same sentence twice on one frame, once in the
border and once at the bottom of the terminal (JP, 2026-08-31), so the border says
nothing and the box leaves the reader's eye on the one place hints live.
- Where the footer is the **F-key lane** (the PicoCalc), there is no hint line at all
and the lane speaks only for its five chips, so the border is the only thing that
can name Enter, Esc or the arrows — it keeps the hint. What the lane *does* say it
says already, one row below: :func:`~meshterm.ui.tui.fkeys.strip_lane_atoms` lifts
out the atoms whose every key is a chip on that row.
Resolved on each paint rather than folded into a screen's hint once, because both
halves move: a screen rewrites its hint as its content changes (the packet viewer
drops to a bare ``Esc close`` on a single packet) and its lane is read fresh every
frame.
The chromeless startup splash is not this case and never passes through here — it has
no footer row of any kind, so :func:`compose_startup` keeps drawing the hint in its
own panel's subtitle on both platforms.
"""
if not get_platform().footer_fkeys:
return ""
return fkeys.strip_lane_atoms(screen.footer_hint, screen.fkey_lane)
def _dialog_subtitle(
hint: str, more_above: bool, more_below: bool, border: str
) -> Optional[str]:
"""A floating box's bottom-border legend: the clip arrows, the hint, or both.
With no hint to carry (the desktop — see :func:`_dialog_hint`) the arrows keep the
base frame's own wording, ``↑↓ more``, exactly as :func:`_panel_box` labels them: a
border that has fallen silent still says the one thing every border here says, in the
vocabulary the frame around it uses.
"""
arrow = ""
if more_above or more_below:
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
if not hint:
return f"[{hint_style(border)}]{arrow} more[/]" if arrow else None
legend = f"{arrow} · {hint}" if arrow else hint
return f"[{hint_style(border)}]{legend}[/]"
def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
"""Compose a centered dialog panel for a floating screen, bounded to the terminal.
@@ -532,8 +583,11 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows)
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
border = getattr(screen, "border_style", "accent")
# The resolved hint, not the screen's own: it is what the border actually draws, and
# it moves with the lane below as well as with the screen's hint (see _dialog_hint).
hint = _dialog_hint(screen)
key = (
max_w, vpad, screen.title, screen.footer_hint, border,
max_w, vpad, screen.title, hint, border,
more_above, more_below, *visible,
)
cached = _DIALOG_CACHE.get(key)
@@ -541,11 +595,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
_DIALOG_CACHE.move_to_end(key)
return cached
body = Text.from_ansi("\n".join(visible))
hint = hint_style(border)
subtitle = f"[{hint}]{screen.footer_hint}[/]"
if more_above or more_below:
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]"
subtitle = _dialog_subtitle(hint, more_above, more_below, border)
panel = Panel(
body,
title=f"[{title_style(border)}]{screen.title}[/]" if screen.title else None,
+64
View File
@@ -13,6 +13,7 @@ from meshterm.ui.tui.fkeys import (
action_for,
default_lane,
lane_text,
strip_lane_atoms,
)
from meshterm.ui.tui.screen import ScrollScreen
@@ -301,3 +302,66 @@ def test_dialog_gate_shrinks_on_picocalc() -> None:
assert _dialog_max_cells() == 43
set_platform(REGULAR)
assert _dialog_max_cells() == 76
# --- the dialog border's hint, against the lane one row below it ------------------------
def _dialog(hint: str) -> ScrollScreen:
"""A floating screen carrying ``hint``, on the shared pager lane."""
screen = ScrollScreen(Text("body"), title="Probe")
screen._footer_hint = hint
return screen
def test_a_dialog_keeps_its_hint_where_the_lane_is_the_footer() -> None:
"""There is no hint line here, so a floating box's border is the only place keys read.
The desktop drops the border hint entirely (the footer row below repeats it word for
word); this platform's footer row is the lane, which speaks only for its five chips,
so Enter, Esc and the arrows have nowhere else to be said.
"""
screen = _dialog("↑↓ move · Enter select · Esc back")
set_platform(REGULAR)
assert frame._dialog_hint(screen) == ""
set_platform(PICOCALC)
assert "Esc back" in _plain(frame.compose_dialog(screen, 53, 26))
def test_the_dialog_hint_drops_what_the_lane_already_says() -> None:
"""An atom whose every key is a chip on the very next row is the same claim twice."""
set_platform(PICOCALC)
screen = _dialog("↑↓ move · PgUp/PgDn scroll · Home/End ends · Enter select · Esc back")
# The shared lane pages on F4/F5 and jumps to either end behind their Shift halves.
assert frame._dialog_hint(screen) == "↑↓ move · Enter select · Esc back"
assert "PgUp" not in _plain(frame.compose_dialog(screen, 53, 26))
def test_the_dialog_hint_is_resolved_on_every_paint() -> None:
"""Never folded into a screen's hint once: both halves move while the screen is up.
The packet viewer rewrites its own hint as the list it pages through grows past one
entry (JP, 2026-08-31), and every screen reads its lane fresh each frame.
"""
set_platform(PICOCALC)
screen = _dialog("Esc close")
assert frame._dialog_hint(screen) == "Esc close"
screen._footer_hint = "↑↓ newer/older · PgUp/PgDn scroll · Home/End ends · Esc close"
assert frame._dialog_hint(screen) == "↑↓ newer/older · Esc close"
def test_an_atom_the_lane_only_half_covers_stands() -> None:
"""Half a truth is worse than the whole atom: it survives unless every key is a chip."""
pager_only = (None, None, None, FPair("Page ↓", "pagedown"), FPair("Page ↑", "pageup"))
assert strip_lane_atoms("PgUp/PgDn scroll", pager_only) == ""
# No Shift bank here, so Home/End are nowhere on the lane.
assert strip_lane_atoms("Home/End ends", pager_only) == "Home/End ends"
# The atom documents the arrows too, and no slot ever claims those.
assert strip_lane_atoms("↑↓ PgUp/PgDn scroll", DEFAULT_LANE) == "↑↓ PgUp/PgDn scroll"
# A second key riding in the verb half keeps the atom whole (the map's region/you).
assert strip_lane_atoms("Home/^U region/you", DEFAULT_LANE) == "Home/^U region/you"
def test_a_dimmed_chip_still_covers_its_atom() -> None:
"""Dim says *a thing here, just not right now* — the reader has been told where it is."""
assert strip_lane_atoms("PgUp/PgDn scroll", default_lane(nav=False)) == ""
+25
View File
@@ -922,6 +922,31 @@ def _border_colours(lines: list[str]) -> set[tuple[int, int, int]]:
}
def test_a_dialog_leaves_its_hint_to_the_footer_line() -> None:
"""On the desktop the box says nothing about keys — the footer row below already does.
The frame draws the *top* screen's hint on its footer line, so a dialog that also
carried the hint in its own border put the same sentence on one frame twice (JP,
2026-08-31). The border falls silent and the footer is the single place hints live.
"""
hint = "←→ choose · Enter select · Esc cancel"
screen = ScrollScreen(Text("Delete this contact?"), title="Delete contact")
screen._footer_hint = hint
box = _plain(frame.compose_dialog(screen, 72, 20))
assert "Esc cancel" not in box and "Enter select" not in box
base = ScrollScreen(Text("row"), title="Contacts", floating=False)
footer = _plain(frame.compose_base(Text("h"), base, hint, 72, 20).split("\n")[-1])
assert footer.strip() == hint
def test_a_silent_dialog_border_still_says_there_is_more() -> None:
"""With no hint to carry, the clip arrows keep the base frame's own ``more`` wording."""
tall = ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Packet")
tall._footer_hint = "↑↓ newer/older · Esc close"
assert "↓ more" in _plain(frame.compose_dialog(tall, 72, 16))
def test_a_frame_draws_in_one_colour() -> None:
"""A border is its own colour the whole way round — no corner lit, no edge fading.