diff --git a/CLAUDE.md b/CLAUDE.md index 518495e..87ea3d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,37 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"), `_format_age` for aligned columns ("now", "5m"). +### Navigation — the stack + +Navigation is a **strict stack**: entering a screen or a dialog pushes one frame, Esc pops +exactly one, and the frame you land back on is the one you left — same object, so its +cursor, sort, scroll and typed filter are simply still there. Exceptions are added +deliberately, one at a time, and say why in the code. + +- A screen that **owns a loop** is a hub, and a hub *stays pushed* for the whole visit: + `async with session.stay(screen) as visit:` / `while True: x = await visit.result()`. + One push, one pop, however many rounds. Never pop-and-re-push a screen to give a dialog + a backdrop — a screen that never left the stack already is one. `run_screen` remains the + one-shot form: push, await, pop, for a dialog or a prompt. +- A **sub-view nests**; nothing flattens the stack to spare the reader a climb. Two peers + may open each other (trace ↔ trophy case) and that cycle is fine — ^W is the climb. +- A list whose **content is data** (an editor's staged values, a queue that lost a row) + refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row + by value and keeps the filter. Rebuilding the screen is for when the rows it was holding + a place in are genuinely gone (a purge, a delete) — and then say so. +- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are + answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no + screen to belong to, and the F-key lane has only three free slots per screen. They are + stated once, on the About page. `test_navigation` walks every string literal in the + package to keep them out of the UI. +- `modal` is *owning the keyboard* (a prompt, progress, a busy splash); `floating` is only + *drawn as a box*. They are not the same flag: a select list floats and is not modal, the + busy splash is modal and does not float. ^W declines to unwind past anything modal. +- A caller tests one thing for "the user left": `CANCEL` (or the `None` that `ui.select` + folds it to). `POP_ALL` never reaches a caller — the navigation boundary turns it into + `PopToMenu`, which derives from `BaseException` so it crosses the app's `except Exception` + tool guards; only the menu loop catches it. + ### Screens and lists - **No screen carries an exit row.** Esc leaves — it is on both platforms' keyboards and @@ -73,8 +104,6 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash): it *initiates* the app's terminal action behind a confirm, and on the splash it is the only statement that the app can be left at all. -- A caller therefore tests one thing for "the user left": `CANCEL` (or the `None` that - `ui.select` folds it to) — never a `_BACK`/`"__back__"` sentinel of its own. - Grouped-list section headings use `section_heading("Label")` → `── Label ──` accent. That is also what makes a heading *sticky* (it pins to the top row while its section scrolls, and the ^PgUp/^PgDn jumps step by it), so build them through it — a hand-rolled diff --git a/docs/survey-navigation.md b/docs/survey-navigation.md index 5a0d054..035d3ca 100644 --- a/docs/survey-navigation.md +++ b/docs/survey-navigation.md @@ -7,8 +7,36 @@ makes it a question. **Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated -in CLAUDE.md. Everything else below still stands, **D included** — Esc's peel/leave split -is now the only thing left telling a user what Esc does. +in CLAUDE.md. + +**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is +a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*. + +- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the + whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=` + restore — carries the cursor, the sort, the scroll and the filter. Where the rows are + genuinely data (the editors, the outbox, the watchtower) they are swapped in place with + `SelectScreen.replace_items`, which follows the highlighted row by value and clamps to + its position when that row is gone. A1's vanishing-row problem is answered by that + clamp, everywhere, rather than by watchtower's one hand-written case. A screen is + rebuilt only when the rows it was holding a place in no longer exist — a purge, a + removal, a record count that moved — and each such site says so. +- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep + stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's + peers all nest, and Esc from any of them is one pop back onto the screen that opened + them. The trace screen takes an `open_trophy_case` callback instead of resolving a + sentinel its opener had to interpret, which is also what lets the two open each other. +- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled + push/await/pop loops the survey paired them with — the same need, spelled one way. + Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a + static stand-in for the real one. `admin_node_visit` keeps the real list up instead. +- **F.** Still the one place the stack is cleared rather than unwound, and it now also + disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for. + +**Still open: D** (Esc's peel/leave split) and **G** (`popup` declared by hand). D is now +the only thing left telling a reader what Esc does, and it is the reason `Esc Esc` was +rejected as the pop-all key — on the map, the mesh walk and chat, Esc-Esc is already an +ordinary peel-then-leave. The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions are few enough to list exhaustively, which means they can be decided one at a time rather @@ -358,13 +386,13 @@ Every other `Cancel` in the app is one half of a real two-way choice. ## 11. Summary — the shortlist -| # | Finding | Sites | Weight | -|---|---|---|---| -| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | -| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | -| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | -| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | -| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | -| G | `popup` declared once by hand while the result presenter infers it | 1 | low | -| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | -| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | +| # | Finding | Sites | Weight | Status | +|---|---|---|---|---| +| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 | +| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | open | +| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 | +| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 | +| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 | +| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open | +| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 | +| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept | diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 1560788..395f295 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -35,9 +35,9 @@ POP_ALL = object() class PopToMenu(BaseException): """Raised through every awaiting navigation frame to unwind to the main menu. - The whole realized path is a stack of ``await``\ s — a screen's caller awaiting its - result, that caller's caller awaiting *it* — so the one way to leave every frame at - once is to raise through them all. Each frame pops its own screen in a ``finally`` on + The whole realized path is a stack of pending ``await`` expressions — a screen's caller + awaiting its result, that caller's caller awaiting *it* — so the one way to leave every + frame at once is to raise through them all. Each frame pops its own screen in a ``finally`` on the way out, which is exactly the strict single-pop discipline applied N times: no screen is ever dropped without its owner unwinding. diff --git a/meshterm/ui/watchtower_screen.py b/meshterm/ui/watchtower_screen.py index 616d293..4687840 100644 --- a/meshterm/ui/watchtower_screen.py +++ b/meshterm/ui/watchtower_screen.py @@ -15,7 +15,6 @@ is opened before that ever happened). Nothing transmits. from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Any, Callable, Optional from rich.text import Text