feat: the page bar becomes a control, and [/] reach the modes about the document

Three tickets on one subject: paging.

improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.

improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.

improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.

Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
  - `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
    binding. Composing the command anywhere else would put a derivation in the
    page, which design-principles §5 forbids; `Command`/`ModelCommand` are
    untouched.
  - `improvise-command/src/command/keymap.rs` gains three tests. The two
    existing guards were the mechanism that forced the wiew argument, so they
    had to be answered where they live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 23:24:38 -07:00
parent ee0f57d227
commit a913390322
17 changed files with 1076 additions and 42 deletions
+54 -1
View File
@@ -88,7 +88,8 @@ export function frameCostOf(model) {
/** The page bar: one entry per category fixing which records are visible. The
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
export function pageEntries(model) {
return model.page_coords.map((axis) => ({
return model.page_coords.map((axis, index) => ({
index,
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
// exactly as `indicator`'s is.
text: `${axis.cat} = ${axis.selected}`,
@@ -96,9 +97,61 @@ export function pageEntries(model) {
// navigable: there is no position to show and no key that would move it.
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
navigable: Boolean(axis.cycle),
// The items a menu offers, RELAYED from `cycle` — the one home of the
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
items: axis.cycle
? axis.cycle.items.map((label, item) => ({
item,
label,
selected: item === axis.cycle.at,
}))
: [],
}));
}
/** The down-arrow. Present exactly where there is a menu to open, which is
* `navigable` and never "is this on the page bar" (gotcha 27). */
export const MENU_ARROW = "▾";
/** What a delegated click on the page bar or its menu MEANS. `null` where the
* node names neither, which is every gap between the chips. */
export function pageItemAction(dataset) {
const axis = whole(dataset.axis);
if (axis === null) return null;
const item = whole(dataset.item);
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
// both: it picks. One attribute apart, so nothing infers from structure.
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
}
/** What a key does while the page menu is open. The menu owns the keyboard
* exactly as the inline editor does — `context/code/clients.md`. */
export function menuKey(key, { count, active }) {
if (count === 0) return { kind: "close" };
switch (key) {
case "Escape":
case "Tab":
return { kind: "close" };
case "Enter":
case " ":
return { kind: "pick", item: active };
case "ArrowDown":
case "j":
return { kind: "move", item: (active + 1) % count };
case "ArrowUp":
case "k":
return { kind: "move", item: (active - 1 + count) % count };
case "Home":
return { kind: "move", item: 0 };
case "End":
return { kind: "move", item: count - 1 };
// Everything else is SWALLOWED rather than passed on: a menu that let
// `hjkl` reach the grid would scroll the thing it is anchored to.
default:
return { kind: "ignore" };
}
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive