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
+74 -4
View File
@@ -1630,10 +1630,80 @@ now spelled twice — `src/ui/grid.rs` and `web/shell.js::gridPlan` — which fa
the `indicator()` precedent (the collapse glyph is spelled in both ends while the `indicator()` precedent (the collapse glyph is spelled in both ends while
the *fact* is shared) and because the alternative is a protocol-level string the *fact* is shared) and because the alternative is a protocol-level string
table for one word; `invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key)` table for one word; `invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key)`
is where it belongs if a second such string ever appears. And the page bar is is where it belongs if a second such string ever appears.
**display-only**: it shows the cycle position so `[`/`]` have an affordance, but
clicking an entry does nothing, because composing a page change from a click ### The page bar is a control (improvise-rw6l)
needs a `click_page` on the wasm surface that does not exist yet.
It was display-only until improvise-rw6l, and this file said so, naming the
missing `click_page` on the wasm surface as the reason. That is now built, and
it cost less than the note implied: `PageAxis::items` was already projected and
`ModelCommand::SetPageSelection` was already on the wire. The only gap was that
`RenderPageAxis` **dropped the names** — it carried a position and a count,
which is everything improvise-p1ym needed for `[`/`]` and nothing a menu needs.
**The widening puts `items` inside `cycle`, not beside it.** That is the whole
design: one predicate, not two. A Filter axis has no `cycle`, so it has no
items, no down-arrow, is painted as a `<span>` rather than a `<button>`, and
composes nothing if a click somehow reaches it — four answers off the one home
gotcha 27 already named. The tempting alternative, a separate `items` field on
`RenderPageAxis`, gives the page a second thing to test and a way to disagree
with itself.
**The arrow follows `navigable`, never "is this on the page bar".** A Filter
axis *is* on the bar and must get no arrow: offering a menu that cannot do
anything is worse than offering none, because the user spends a click finding
out. A fixture with only a Page axis cannot see that distinction, which is why
every test of it — Rust, `node --test`, and the two in the harness — carries a
Filter axis beside the Page one wherever the shape allows.
**The menu lives outside `#pages`**, in its own `#page-menu` element. `#pages`
has its children replaced on every repaint, and a menu rebuilt under the pointer
is a menu that closes itself mid-click. Being outside also means a repaint is
free to *close* it explicitly, which is the honest answer: the chip it was
anchored to has just been rebuilt, and re-anchoring would be a guess.
**Two surfaces, one meaning.** improvise-b8ot's ruling is one delegated handler
per surface on a stable root, operands off data attributes, no structural
inference — so the chip carries `data-axis` and a menu row carries `data-axis`
*and* `data-item`, and `shell.js::pageItemAction` is one function over both.
That single attribute is the entire disambiguation. The case that has to be got
right is an **empty** `data-item`: `Number("")` is `0`, so a naive read turns
"no item" into "item zero", and clicking the chip of an axis already on its
first item would silently re-pick it instead of opening anything.
**The menu owns the keyboard while it is open, which is the third state of the
kind `editing` already was.** `app.js`'s window `keydown` handler returns early
for `editing`; it now returns early for `pageMenu` too, into `menuKey`. That is
the same mechanism rather than a new one, and the reason is specific: the menu
hangs over the grid, so letting `hjkl` through would scroll the thing it is
pointing at out from under it. `menuKey` therefore answers **every** key, with
`ignore` as the default — an unlisted key is swallowed, not passed on — and
`count: 0` closes rather than wrapping modulo zero, because a repaint can empty
an axis under an open menu.
`active` and `selected` are two different facts and both are drawn: `selected`
is what the *document* is fixed to (a check mark), `active` is where the
keyboard is. They start together and part on the first arrow key.
### The tile bar says "Page", not "Pag" (improvise-771e)
`Axis::short_label` returns three characters so the TUI's tile bar aligns in a
fixed-width terminal (gotcha 26); `"·"` for `None` is an answer there and not a
truncation. A browser has no column to fit, so the abbreviation buys nothing and
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
vocabulary already on `Axis`: the decision stays in the shared layer and only
the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color`
shape §5 blesses. `short_label` is untouched, because the TUI's width is
load-bearing.
**Where the truncation was actually pinned is the useful part.** The ticket says
to check `tests/grid_frame_agreement.rs` first, in case it fixes the tile bar's
vocabulary. It does not mention the tile bar at all. What held "Quarter Pag" in
place was `harness/tests/drill.spec.js`, which asserts the tile's text exactly —
a Playwright spec in a directory the ticket never names, asserting the bug as
its expected value. A rendered string is pinned wherever anything reads the
screen, so the search is for the *string*, not for the function that makes it.
### The keyboard ### The keyboard
+63
View File
@@ -1487,6 +1487,69 @@ agreement, and it is what `no_mode_binds_a_panel_toggle_of_its_own` now keeps.
is the thing being removed, and two equal-but-separate globals would satisfy is the thing being removed, and two equal-but-separate globals would satisfy
any value comparison. any value comparison.
### The layer below it: `document_keymap` (improvise-wiew)
The global above is **all seventeen modes**, and that answer is unchanged. What
changed is that a second shared layer now sits between it and some of them.
The user asked for `[`/`]` "probably in the global keymap", and then answered
the question that left open himself: *"the help display (and import wizard,
etc.) shouldn't respond to these keys, but the 'normal' modes like
categories/tiles/formulas/etc. should."*
The distinction is **is the document the subject right now**, and it is genuinely
a new one rather than the global's argument applied again. Every key the global
binds — `?`, `:`, the three panel toggles — is a **UI** action: it opens a room,
it does not change the document. `page-next`/`page-prev` resolve to
`ModelCommand::SetPageSelection`, which is `EffectKind::Model(LayoutGeometry)`.
They are the first shared keys that *mutate*, and under the Help overlay that is
a mutation the user cannot see, because the overlay is covering the grid it
just moved.
So the chain is three deep:
```
global ? : and the panel toggles — every mode
└── document [ ] — Normal, the three
├── Normal panels, TileSelect; RecordsNormal
│ └── RecordsNormal through Normal
├── FormulaPanel / CategoryPanel / ViewPanel
└── TileSelect
├── Help global only
├── ImportWizard global only
└── the text-entry modes and SearchMode, unchanged
```
**The name is the guard.** Nothing in the code stops the next person parenting
Help to this layer; what stops them is that it is called `document_keymap` and
not `common` or `shared`, so its name answers "should my new mode inherit it?"
A name describing *who* inherits a layer invites everything to join, and the
distinction dies within two waves.
**RecordsNormal inherits it once, through Normal.** It already parents Normal,
so it reaches the layer transitively; parenting it directly as well would be a
second edge with nothing keeping the two in step. That is asserted on the chain
identity rather than on behaviour, because a duplicated edge answers `page-next`
correctly on the day it is added and only diverges later.
**The layer owes the same Char-only constraint as the global**, and for the
identical reason: it is reached by *fallthrough*, so a non-Char binding on it
would fire while a descendant's `AnyChar` was typing. No descendant binds
`AnyChar` today — which is exactly why the constraint is written down as a test
rather than left to the arrangement. An unconstrained shared layer is where the
next "just put it in the shared one" lands.
**Two things that cost nothing and were checked anyway.** Text entry is
untouched: the fallback order is `exact → Char(NONE) → AnyChar → Any → parent`,
so a text-entry mode's `AnyChar` answers `[` before any parent is consulted, and
typing a bracket into a category name still types it. And `prefix_keymaps_are_flat`
is about the *sub-keymaps a prefix opens*, not about inheritance depth, so a
third level of parents violates nothing it asserts.
The browser follows for free: `resolve.rs` dispatches on the command **name**,
so every mode that now reaches `page-next` composes it in a browser too, with no
second table to update. That is the whole point of one shared keymap.
### `TextEntry` — the spine, welded ### `TextEntry` — the spine, welded
Every text-entry mode has the same spine: characters append to one named Every text-entry mode has the same spine: characters append to one named
+96
View File
@@ -3362,3 +3362,99 @@ pin the palette, which is not the property.
Inject the fault by moving the `td:not(.editable)` block back below Inject the fault by moving the `td:not(.editable)` block back below
`td.error`, or by restoring either `color: var(--fg)`; each makes exactly the `td.error`, or by restoring either `color: var(--fg)`; each makes exactly the
corresponding assertion fail. corresponding assertion fail.
## The page bar as a control (improvise-rw6l, improvise-771e)
The user, pointing at the deployed page: *"The page items here (e.g. Quarter)
should open a menu when clicked so you can pick a different option. Also have a
down-arrow indicating there's a menu."*
Almost nothing had to be derived for it. `PageAxis::items` was already
projected, `ModelCommand::SetPageSelection` was already on the wire, and the one
gap was that `RenderPageAxis` **threw the names away**: it kept a position and a
count, which is all improvise-p1ym needed for `[`/`]`. Widening
`RenderPageCycle` with `items` is a pass-through, and the items sit *inside*
`cycle` rather than beside it so there is exactly one predicate — a Filter axis
has no `cycle`, therefore no arrow, no menu and no items, all off the one home
`PageAxis::cycle` already was (gotcha 27).
### render::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to
Fault injected: `items: Vec::new()` in place of `items.to_vec()`. The menu then
paints empty on a real document and nothing in the browser can see it, because
the down-arrow and the chip's `<button>`-ness both follow `cycle`, not the item
count. The Filter row in the same test is what stops the fix being "just always
send `axis.items`".
### render::tests::a_page_tile_reads_as_the_whole_word
improvise-771e: the browser tile bar drew "Quarter Pag". `Axis::short_label`
returns three characters so the TUI's tile bar aligns in a fixed-width terminal
(gotcha 26); a browser has no column to fit, so the abbreviation buys nothing
and reads as a rendering defect. `plain_label` is the other vocabulary already
on `Axis`, so the decision stays in the shared layer and only the *choice* of
vocabulary is local — the `AxisColor → ratatui::Color` shape §5 blesses.
**The fixture is the whole test.** The two vocabularies differ on exactly two
of five axes — `Page` ("Pag") and `None` ("·") — so `client_with_panels`, whose
tiles are `Row` and `None`, could see the `None` half and **not** the `Page`
half the ticket is about. This test projects a `Page` tile and a `Column` one
beside it: the second is what says this is a change of vocabulary rather than a
rewrite, since `Col` is spelled the same either way.
**What actually pinned the truncation was not where the ticket said to look.**
improvise-771e says to check `tests/grid_frame_agreement.rs` before changing
anything. It does not mention the tile bar at all. The assertion that held
"Quarter Pag" in place was `harness/tests/drill.spec.js`, which asserts the tile
text exactly, twice — a Playwright spec, in a directory the ticket never names,
asserting the bug as the expected value. The lesson is the search, not the file:
a *rendered string* is pinned wherever anything reads the screen, so the grep is
for the string, not for the function that produces it.
### a page axis offers its items, and a filter axis offers none
`web/tests/dispatch.test.js`. `pageEntries` relays `cycle.items` and gives a
Filter axis `[]`. Fault injected: `items: axis.cycle ? ... : axis.items`, i.e.
falling back to the raw list — the Filter row then offers items nothing can act
on, which is the "a menu that cannot do anything" case the ticket names as
worse than no menu at all.
### a chip opens the menu and a menu row picks, one attribute apart
improvise-b8ot's shape: one delegated handler per surface on a stable root,
operands off data attributes, and **no structural inference**. A chip carries
`data-axis`; a menu row carries `data-axis` *and* `data-item`. That single
difference is the whole disambiguation, so `pageItemAction` is one function over
both surfaces and `app.js` registers the same listener on each.
The row worth having is `{ axis: "0", item: "" }` → `open`. `Number("")` is `0`,
so a naive read turns an absent item into item zero — which picks Q1 whenever
the user clicks the chip for an axis already on Q1, i.e. silently does nothing,
which is indistinguishable from the menu failing to open.
### the menu answers its own keys and swallows the rest
The menu is the **third** state of the kind `editing` already was: `app.js`'s
window `keydown` handler returns early for it. That is the same treatment and
not a new mechanism, and the reason is the same — the menu is anchored to a
chip above the grid, so `hjkl` reaching the grid would scroll the thing the menu
is pointing at out from under it.
`menuKey` therefore answers **every** key, with `ignore` as the default rather
than a fallthrough: an unlisted key is swallowed, not passed on. The
`count: 0` row is a real state, not defensive padding — a repaint can empty the
axis under an open menu — and it closes rather than wrapping modulo zero.
### the menu is driven by the keyboard and does not leak keys to the grid
`harness/tests/pages.spec.js`, and the assertion that earns the slow test is
the **negative** one: `j` moves the menu's active row *and* leaves
`#grid td.selected` where it was. A spec that only checked the menu moved would
pass with the key leaking, because both handlers would have run.
### the help overlay pages nothing behind itself
improvise-wiew's product call, executed. The overlay is open, `]` is pressed,
and the page bar still reads `Quarter = Q1 (1/4)`. Its sibling — a panel open,
`]` pressed, bar moves to Q2 *and the panel is still open* — is what stops the
first one passing because the key does nothing anywhere.
+87
View File
@@ -821,3 +821,90 @@ Faults injected against the fixed code:
duplication improvise-636d removes, and it is invisible to every check that duplication improvise-636d removes, and it is invisible to every check that
asks what a key *does*. asks what a key *does*.
## `src/keymap.rs` — the layer between the global and the modes (improvise-wiew)
The user asked for `[`/`]` "probably in the global keymap", and then ruled on
the question that ask left open: *"maybe we need an intermediate keymap: the
help display (and import wizard, etc.) shouldn't respond to these keys, but the
'normal' modes like categories/tiles/formulas/etc. should."*
So the distinction is **is the document the subject right now**, and it is a new
distinction rather than the global's. Every key the global already binds — `?`,
`:`, the three panel toggles — is a **UI** action. `page-next`/`page-prev`
resolve to `ModelCommand::SetPageSelection`, which is
`EffectKind::Model(LayoutGeometry)`: the first key on a shared layer that
*changes the document*. Under the Help overlay that is a real mutation the user
cannot see, because the overlay is covering the grid it just moved.
`document_keymap` is therefore a third level: `global → document → {Normal, the
three panels, TileSelect}`, with Help, ImportWizard and the text-entry modes
taking `global` directly. RecordsNormal reaches it **through Normal**, which it
already parents — parenting it directly as well would be a second edge to keep
in step, and `records_normal_inherits_the_document_layer_once_through_normal`
asserts the chain is exactly four long.
Two things about the name. It is `document_keymap` and not `common`/`shared`
deliberately: the name is the only thing that tells the next person whether a
new mode should parent it, and a name that describes *who inherits it* invites
everything to join. And the layer is **derived** in every test off
`Normal.parent()` rather than exported, so a test cannot restate a membership
the table disagrees with.
### the_page_keys_reach_the_document_modes_and_no_overlay
The six document modes answer `]`/`[` with `page-next`/`page-prev`; Help
resolves **neither at all** (`lookup` is `None`, not "shadowed into something
harmless"), ImportWizard answers `handle-wizard-key` through its `Any`, and
CategoryAdd answers `append-char` — so typing a bracket into a name still types
it, which is the fallback order (`AnyChar` before the parent) doing its job.
Fault injected: parenting Help to `document_keymap` instead of to `global`.
`]` in Help then resolves to `page-next` and the test fails. Note the assertion
that discriminates is the Help one — the six positive rows pass either way.
### records_normal_inherits_the_document_layer_once_through_normal
Fault injected: `Keymap::with_parent(document.clone())` for RecordsNormal in
place of `with_parent(normal)`. The test above **still passes** — RecordsNormal
answers `page-next` either way — and this one fails on the parent identity.
That pair is the reason it is a separate test: a duplicated inheritance edge is
invisible to every behavioural assertion on the day it is added, and only shows
up the first time Normal and the layer disagree.
### the_document_layer_binds_only_unmodified_char_keys
The sibling of `the_global_binds_only_unmodified_char_keys`, and it is owed for
exactly the same reason: the layer is reached by **fallthrough**, so a non-Char
binding on it would fire while a descendant mode's `AnyChar` was typing. No
descendant binds `AnyChar` today, which is precisely why the constraint has to
be written down rather than relied on — an unconstrained new layer is where the
next "just put it in the shared one" lands.
### resolve::tests::picking_a_page_item_moves_that_axis_and_only_that_axis
improvise-rw6l. `Click::PageItem { axis, item }` is the browser's "the user
picked straight off the bar", and the property that separates it from `]` is
that it names **one** axis: `page_cycle` is an odometer that restates every
navigable axis and carries between them, so on a two-axis fixture `]` from
`Year=2024, Quarter=Q4` emits both `Year=2025` and `Quarter=Q1`. The click emits
`Quarter=Q1` alone. A fixture with one page axis cannot tell the two apart.
The basis it claims is `state.basis.claim([])` — the same claim `page_cycle`
makes, because `SetPageSelection` names no record (gotcha 43). It is written as
that call rather than as the `published()` fixture so the test says *which*
claim it means; the fixture carries a record tier this command must not state.
### resolve::tests::a_filter_axis_offers_no_item_to_pick
Gotcha 27, from the click side. A Filter axis is on the page bar and carries no
items, so `PageAxis::cycle` is `None` and the click composes nothing — the same
one home the arrow and the menu follow. The two out-of-range rows beside it
(an axis past the end of the bar, an item past the end of the list) are there
because the page names both by index, so a stale bar under a repaint is the
realistic way either arrives.
Fault injected: resolving `items` off `axis.items` directly instead of through
`cycle()`. The Filter arm then indexes an empty `Some(vec![])` for a Page axis
with no items — and, more to the point, the `items.is_some()` spelling is the
one gotcha 27 says is *not* the split.
@@ -831,6 +831,114 @@ mod tests {
} }
} }
/// The layer between the global and the modes whose subject is the
/// document. Derived off Normal's parent, never restated (improvise-wiew).
fn document_layer(ks: &KeymapSet) -> Arc<Keymap> {
ks.maps()
.get(&ModeKey::Normal)
.expect("Normal has a keymap")
.parent()
.expect("Normal inherits the document layer")
.clone()
}
fn chain_of(km: &Arc<Keymap>) -> Vec<Arc<Keymap>> {
let mut chain = vec![km.clone()];
while let Some(parent) = chain.last().expect("non-empty").parent() {
chain.push(parent.clone());
}
chain
}
/// improvise-wiew: `[`/`]` page the DOCUMENT, so they reach every mode
/// whose subject IS the document — and no overlay with one of its own.
#[test]
fn the_page_keys_reach_the_document_modes_and_no_overlay() {
let ks = KeymapSet::default_keymaps();
for mode in [
ModeKey::Normal,
ModeKey::RecordsNormal,
ModeKey::CategoryPanel,
ModeKey::ViewPanel,
ModeKey::FormulaPanel,
ModeKey::TileSelect,
] {
assert_eq!(
bound(&ks, &mode, KeyCode::Char(']')).0,
"page-next",
"{mode:?} is about the document, so it pages"
);
assert_eq!(bound(&ks, &mode, KeyCode::Char('[')).0, "page-prev");
}
// Help draws over the grid it would be mutating, so the keys must not
// reach it at all — not merely be shadowed into something harmless.
let help = ks.maps().get(&ModeKey::Help).expect("Help has a keymap");
for key in [KeyCode::Char('['), KeyCode::Char(']')] {
assert!(
help.lookup(key, KeyModifiers::NONE).is_none(),
"{key:?} resolves in Help; it would page the document under the overlay"
);
}
assert_eq!(
bound(&ks, &ModeKey::ImportWizard, KeyCode::Char(']')).0,
"handle-wizard-key",
"the wizard swallows everything, which is its own subject answering"
);
assert_eq!(
bound(&ks, &ModeKey::CategoryAdd, KeyCode::Char('[')).0,
"append-char",
"and typing a bracket into a name still types it"
);
}
/// improvise-wiew: RecordsNormal reaches the layer THROUGH Normal. Parented
/// directly as well it would inherit twice and drift on the next change.
#[test]
fn records_normal_inherits_the_document_layer_once_through_normal() {
let ks = KeymapSet::default_keymaps();
let document = document_layer(&ks);
let normal = ks.maps().get(&ModeKey::Normal).unwrap().clone();
let rn = ks.maps().get(&ModeKey::RecordsNormal).unwrap();
assert!(
Arc::ptr_eq(rn.parent().expect("RecordsNormal has a parent"), &normal),
"RecordsNormal must parent Normal, not the layer directly"
);
let chain = chain_of(rn);
assert_eq!(
chain.iter().filter(|km| Arc::ptr_eq(km, &document)).count(),
1,
"the layer appears exactly once in RecordsNormal's chain"
);
assert_eq!(chain.len(), 4, "rn -> normal -> document -> global");
}
/// The layer is reached by fallthrough exactly as the global is, so it owes
/// the same shape — improvise-636d's argument, one level down.
#[test]
fn the_document_layer_binds_only_unmodified_char_keys() {
let ks = KeymapSet::default_keymaps();
let document = document_layer(&ks);
assert!(
!Arc::ptr_eq(&document, &root_of(&document)),
"the layer is not the global: it must have the global as its parent"
);
for (pattern, binding) in document.bindings() {
let KeyPattern::Key(code, mods) = pattern else {
panic!("the layer binds {pattern:?}; only exact Char keys are shadowable");
};
assert!(
matches!(code, KeyCode::Char(_)),
"the layer binds {code:?}, which AnyChar cannot shadow while typing"
);
assert_eq!(*mods, KeyModifiers::NONE, "{code:?} is held with {mods:?}");
assert!(
matches!(binding, Binding::Cmd { .. }),
"{code:?}: a prefix here would break prefix_keymaps_are_flat"
);
}
}
/// The keys the global binds, read off the global rather than restated — /// The keys the global binds, read off the global rather than restated —
/// a second list here would be the duplication improvise-636d removed. /// a second list here would be the duplication improvise-636d removed.
fn global_keys(global: &Keymap) -> Vec<KeyCode> { fn global_keys(global: &Keymap) -> Vec<KeyCode> {
+20 -8
View File
@@ -403,6 +403,16 @@ fn global_keymap() -> Keymap {
km km
} }
/// The keys every mode answers **whose subject is the document itself**, and
/// no overlay with one of its own: `context/code/protocol.md` §6.
fn document_keymap(global: Arc<Keymap>) -> Keymap {
let none = KeyModifiers::NONE;
let mut km = Keymap::with_parent(global);
km.bind(KeyCode::Char(']'), none, "page-next");
km.bind(KeyCode::Char('['), none, "page-prev");
km
}
/// Bind every panel's toggle key. One call site -- the global -- so a new panel /// Bind every panel's toggle key. One call site -- the global -- so a new panel
/// reaches every mode from one `Panel::ALL` entry. /// reaches every mode from one `Panel::ALL` entry.
fn bind_panel_toggles(km: &mut Keymap) { fn bind_panel_toggles(km: &mut Keymap) {
@@ -499,7 +509,10 @@ impl Keymaps {
// ── Normal mode ────────────────────────────────────────────────── // ── Normal mode ──────────────────────────────────────────────────
// The global every mode falls through to (improvise-636d). // The global every mode falls through to (improvise-636d).
let global = Arc::new(global_keymap()); let global = Arc::new(global_keymap());
let mut normal = Keymap::with_parent(global.clone()); // The layer between: the modes whose subject is the document. Help
// and the wizard take `global` directly — §6 (improvise-wiew).
let document = Arc::new(document_keymap(global.clone()));
let mut normal = Keymap::with_parent(document.clone());
// Navigation // Navigation
for (key, dr, dc) in [ for (key, dr, dc) in [
@@ -611,9 +624,8 @@ impl Keymaps {
); );
normal.bind(KeyCode::Char('N'), none, "search-or-category-add"); normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
// Page navigation // Page navigation is the global's, exactly as `?` and `:` are: a
normal.bind(KeyCode::Char(']'), none, "page-next"); // mode that re-bound it would be the asymmetry §6 removed.
normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide // Group / hide
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor"); normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
@@ -709,7 +721,7 @@ impl Keymaps {
set.insert( set.insert(
ModeKey::FormulaPanel, ModeKey::FormulaPanel,
Arc::new( Arc::new(
PanelKeymap::new(Panel::Formula, global.clone()) PanelKeymap::new(Panel::Formula, document.clone())
.bind_args( .bind_args(
KeyCode::Char('a'), KeyCode::Char('a'),
"enter-mode", "enter-mode",
@@ -733,7 +745,7 @@ impl Keymaps {
set.insert( set.insert(
ModeKey::CategoryPanel, ModeKey::CategoryPanel,
Arc::new( Arc::new(
PanelKeymap::new(Panel::Category, global.clone()) PanelKeymap::new(Panel::Category, document.clone())
.bind(KeyCode::Enter, "filter-to-item") .bind(KeyCode::Enter, "filter-to-item")
.bind(KeyCode::Char(' '), "cycle-axis-at-cursor") .bind(KeyCode::Char(' '), "cycle-axis-at-cursor")
.bind_args( .bind_args(
@@ -751,7 +763,7 @@ impl Keymaps {
set.insert( set.insert(
ModeKey::ViewPanel, ModeKey::ViewPanel,
Arc::new( Arc::new(
PanelKeymap::new(Panel::View, global.clone()) PanelKeymap::new(Panel::View, document.clone())
.bind(KeyCode::Enter, "switch-view-at-cursor") .bind(KeyCode::Enter, "switch-view-at-cursor")
.bind(KeyCode::Char('n'), "create-and-switch-view") .bind(KeyCode::Char('n'), "create-and-switch-view")
.bind(KeyCode::Char('o'), "create-and-switch-view") .bind(KeyCode::Char('o'), "create-and-switch-view")
@@ -761,7 +773,7 @@ impl Keymaps {
); );
// ── Tile select ────────────────────────────────────────────────── // ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::with_parent(global.clone()); let mut ts = Keymap::with_parent(document.clone());
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]); ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]); ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]); ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
+66
View File
@@ -84,6 +84,9 @@ pub enum Click {
Tile(usize), Tile(usize),
/// A grid cell, in **viewport** coordinates as the page painted it. /// A grid cell, in **viewport** coordinates as the page painted it.
Cell { row: usize, col: usize }, Cell { row: usize, col: usize },
/// An item of a page axis, both named by index: `axis` into
/// `GridCache::page_coords`, `item` into that axis's own item list.
PageItem { axis: usize, item: usize },
} }
/// One rule for all the widgets: **go there, then do what `Enter` does /// One rule for all the widgets: **go there, then do what `Enter` does
@@ -97,6 +100,7 @@ pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
Click::FormulaRow(index) => click_formula_row(state, index), Click::FormulaRow(index) => click_formula_row(state, index),
Click::Tile(index) => click_tile(state, index), Click::Tile(index) => click_tile(state, index),
Click::Cell { row, col } => click_cell(state, row, col), Click::Cell { row, col } => click_cell(state, row, col),
Click::PageItem { axis, item } => click_page_item(state, axis, item),
}; };
resolved.unwrap_or_default() resolved.unwrap_or_default()
} }
@@ -720,6 +724,22 @@ fn page_cycle(state: &ClientState, way: Odometer) -> Option<Vec<Command>> {
.collect() .collect()
} }
/// The user picked an item off a page axis directly. `PageAxis::cycle` is
/// asked, so a Filter axis composes nothing (gotcha 27).
fn click_page_item(state: &ClientState, axis: usize, item: usize) -> Option<Vec<Command>> {
let page = state.grid.page_coords.get(axis)?;
let (items, _) = page.cycle()?;
let picked = items.get(item)?;
Some(vec![Command::Model {
basis: state.basis.claim([])?,
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: page.cat.clone(),
item: picked.clone(),
},
}])
}
/// Which way `CommitAndAdvance` moves after writing. /// Which way `CommitAndAdvance` moves after writing.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
enum Advance { enum Advance {
@@ -2715,6 +2735,52 @@ mod tests {
assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}"); assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}");
} }
/// improvise-rw6l: picking straight off the bar, which is what a menu
/// does. It names ONE axis, so the odometer's carry must not appear.
#[test]
fn picking_a_page_item_moves_that_axis_and_only_that_axis() {
let state = paged();
let commands = click(&state, Click::PageItem { axis: 1, item: 0 });
assert_eq!(
commands,
vec![Command::Model {
// The claim `]` makes for the same document: this command
// names no record, so the record tier is omitted (gotcha 43).
basis: state.basis.claim([]).expect("the basis is published"),
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: "Quarter".into(),
item: "Q1".into(),
},
}],
"Q4 to Q1 directly: Year is untouched, where `]` would have carried into it"
);
}
/// A Filter axis is on the bar and offers nothing to pick (gotcha 27).
/// Offering a menu that cannot act is worse than offering none.
#[test]
fn a_filter_axis_offers_no_item_to_pick() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis {
cat: "Region".into(),
selected: "East".into(),
items: None,
});
assert!(
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
"a Filter axis has no item list, so there is nothing a click could mean"
);
assert!(
click(&state, Click::PageItem { axis: 9, item: 0 }).is_empty(),
"and an axis that is not on the bar is not one either"
);
assert!(
click(&state, Click::PageItem { axis: 0, item: 9 }).is_empty(),
"nor an item past the end of the list the bar was painted from"
);
}
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]` /// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
/// and every part shows up here — including the odd-looking trailing clear. /// and every part shows up here — including the odd-looking trailing clear.
#[test] #[test]
+7
View File
@@ -139,6 +139,13 @@ impl Client {
self.clicked(Click::Tile(index)) self.clicked(Click::Tile(index))
} }
/// The user picked item `item` off page axis `axis`, both indexing what
/// the bar was painted from. A Filter axis composes nothing (gotcha 27).
#[wasm_bindgen]
pub fn click_page_item(&mut self, axis: usize, item: usize) -> Result<Option<String>, JsError> {
self.clicked(Click::PageItem { axis, item })
}
/// The user clicked a grid cell at a **viewport** position: move the /// The user clicked a grid cell at a **viewport** position: move the
/// selection there. Opening an editor is the page's own affair. /// selection there. Opening an editor is the page's own affair.
#[wasm_bindgen] #[wasm_bindgen]
+72 -9
View File
@@ -162,12 +162,15 @@ pub struct RenderPageAxis {
pub cycle: Option<RenderPageCycle>, pub cycle: Option<RenderPageCycle>,
} }
/// A position in a navigable page axis, as a named pair: two bare counts read /// A position in a navigable page axis, and what it may be moved to. `items`
/// as "how many page axes are there". /// is RELAYED, never derived here — `context/code/clients.md`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderPageCycle { pub struct RenderPageCycle {
pub at: usize, pub at: usize,
pub count: usize, pub count: usize,
/// Every item this axis can be fixed to, in the document's own order, so
/// a page may offer them. `count` is `items.len()` and stays for `[`/`]`.
pub items: Vec<String>,
} }
/// One row of the category panel. `item_count` and `expanded` are a category /// One row of the category panel. `item_count` and `expanded` are a category
@@ -206,8 +209,8 @@ pub struct RenderFormulaRow {
pub selected: bool, pub selected: bool,
} }
/// One tile: a category the view may move between axes, with `Axis`'s own /// One tile: a category the view may move between axes, with `Axis`'s PLAIN
/// short label (`"·"` for None is an answer, not a truncation — gotcha 26). /// label — the three-column one is the TUI's (improvise-771e, clients.md).
#[derive(Debug, Clone, Default, PartialEq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderTile { pub struct RenderTile {
pub category: String, pub category: String,
@@ -330,6 +333,7 @@ fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
cycle: axis.cycle().map(|(items, at)| RenderPageCycle { cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
at, at,
count: items.len(), count: items.len(),
items: items.to_vec(),
}), }),
}) })
.collect() .collect()
@@ -383,7 +387,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
.enumerate() .enumerate()
.map(|(i, tile)| RenderTile { .map(|(i, tile)| RenderTile {
category: tile.category.clone(), category: tile.category.clone(),
axis: tile.axis.short_label(), axis: tile.axis.plain_label(),
selected: selecting && i == state.view.tile_cat_idx, selected: selecting && i == state.view.tile_cat_idx,
}) })
.collect() .collect()
@@ -829,8 +833,8 @@ mod tests {
let normal = client.model(); let normal = client.model();
assert_eq!(normal.tiles.len(), 2); assert_eq!(normal.tiles.len(), 2);
assert_eq!( assert_eq!(
normal.tiles[1].axis, "·", normal.tiles[1].axis, "None",
"None is an answer, not a truncation" "the browser has no column to fit, so the word is the answer"
); );
assert!( assert!(
!normal.tiles.iter().any(|t| t.selected), !normal.tiles.iter().any(|t| t.selected),
@@ -992,7 +996,11 @@ mod tests {
let bar = client.model().page_coords; let bar = client.model().page_coords;
assert_eq!( assert_eq!(
bar[0].cycle, bar[0].cycle,
Some(RenderPageCycle { at: 2, count: 3 }), Some(RenderPageCycle {
at: 2,
count: 3,
items: vec!["Q1".into(), "Q2".into(), "Q3".into()],
}),
"a Page axis says where `[`/`]` currently sit" "a Page axis says where `[`/`]` currently sit"
); );
assert_eq!( assert_eq!(
@@ -1005,6 +1013,61 @@ mod tests {
); );
} }
/// improvise-rw6l: the names were projected and the browser dropped them,
/// so the bar could say WHERE the cycle sits and not WHAT it may move to.
#[test]
fn a_navigable_page_axis_relays_the_items_it_may_be_fixed_to() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
vec![
page_axis("Quarter", "Q3", Some(vec!["Q1", "Q2", "Q3"])),
page_axis("Region", "North", None),
],
))));
let bar = client.model().page_coords;
let cycle = bar[0].cycle.as_ref().expect("a Page axis cycles");
assert_eq!(
cycle.items,
vec!["Q1", "Q2", "Q3"],
"the document's own order, relayed — a menu offers these and the page derives none of them"
);
assert_eq!(cycle.at, 2, "and which of them it is fixed to now");
assert!(
bar[1].cycle.is_none(),
"a Filter axis offers NO items: there is nothing a menu could pick (gotcha 27)"
);
}
/// improvise-771e: `short_label` is three columns so the TUI's tile bar
/// aligns; a browser has no column to fit and read "Pag" as a bug.
#[test]
fn a_page_tile_reads_as_the_whole_word() {
let mut client = init();
client
.state_mut()
.fold(&Command::Project(Projection::TileBar(vec![
TileEntry {
category: "Quarter".into(),
axis: Axis::Page,
},
TileEntry {
category: "Region".into(),
axis: Axis::Column,
},
])));
let tiles = client.model().tiles;
assert_eq!(
tiles[0].axis, "Page",
"the ONE label the two vocabularies disagree on among the visible axes"
);
assert_eq!(
tiles[1].axis, "Col",
"and the ones they agree on are unchanged, so this is a choice of vocabulary and not a rewrite"
);
}
/// The totals strip is relayed, never summed here: the total runs over /// The totals strip is relayed, never summed here: the total runs over
/// rows this viewport scrolled past (gotcha 44). improvise-p7lh. /// rows this viewport scrolled past (gotcha 44). improvise-p7lh.
#[test] #[test]
+3 -3
View File
@@ -47,7 +47,7 @@ test.describe("every field the render model projects reaches the screen", () =>
await expect(page.locator("#status")).toContainText("in-page worker"); await expect(page.locator("#status")).toContainText("in-page worker");
// `tiles`: drawn on every frame, as the TUI draws them. The axis label is // `tiles`: drawn on every frame, as the TUI draws them. The axis label is
// `Axis::short_label` — "Pag", and "·" for None, an answer not a stub. // `Axis::plain_label` here; the three-column one is the TUI's (771e).
await expect(page.locator("#tiles .tile")).toHaveCount(5); await expect(page.locator("#tiles .tile")).toHaveCount(5);
await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText( await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText(
"Product Row", "Product Row",
@@ -151,7 +151,7 @@ test.describe("every field the render model projects reaches the screen", () =>
// `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about. // `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about.
await expect(page.locator("#pages")).toBeVisible(); await expect(page.locator("#pages")).toBeVisible();
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q1 (1/4)"); await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q1 \(1\/4\)/);
// Page is navigable where Filter is not (gotcha 27), and that is what // Page is navigable where Filter is not (gotcha 27), and that is what
// says `[`/`]` will move it. // says `[`/`]` will move it.
await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/); await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/);
@@ -167,7 +167,7 @@ test.describe("every field the render model projects reaches the screen", () =>
// BOTH move, and the numbers are Q2's: a bar that showed a stale quarter // BOTH move, and the numbers are Q2's: a bar that showed a stale quarter
// over fresh numbers is the exact lie this bead names. // over fresh numbers is the exact lie this bead names.
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q2 (2/4)", { await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, {
timeout: 10_000, timeout: 10_000,
}); });
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400"); await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400");
+2 -2
View File
@@ -17,14 +17,14 @@ async function anAggregateGrid(page) {
await page.keyboard.press("Shift+T"); await page.keyboard.press("Shift+T");
const tiles = page.locator("#tiles .tile"); const tiles = page.locator("#tiles .tile");
const quarter = tiles.filter({ hasText: "Quarter" }); const quarter = tiles.filter({ hasText: "Quarter" });
await expect(quarter).toHaveText("Quarter Pag"); await expect(quarter).toHaveText("Quarter Page");
const names = await tiles.allTextContents(); const names = await tiles.allTextContents();
for (let i = 0; i < names.findIndex((t) => t.startsWith("Quarter")); i++) { for (let i = 0; i < names.findIndex((t) => t.startsWith("Quarter")); i++) {
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
} }
await expect(quarter).toHaveClass(/selected/); await expect(quarter).toHaveClass(/selected/);
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(quarter).toHaveText("Quarter ·", { timeout: 10_000 }); await expect(quarter).toHaveText("Quarter None", { timeout: 10_000 });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
// The precondition, asserted rather than assumed: nothing is editable now, // The precondition, asserted rather than assumed: nothing is editable now,
+125
View File
@@ -0,0 +1,125 @@
// The page bar as a control: the arrow, the menu, and the keys that reach it.
// The RULES behind each decision are `node --test web/tests` — clients.md.
import { test, expect } from "@playwright/test";
/// The shipped demo, focused. `#grid` must have focus or the page's keydown
/// listener never sees a key and every key assertion would pass vacuously.
async function theDemo(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
}
const chip = (page) => page.locator("#pages .page-axis");
const menu = (page) => page.locator("#page-menu");
test.describe("a page axis can be picked from a menu", () => {
// improvise-rw6l. The demo has Quarter on Page, so the chip is navigable and
// the arrow is the affordance that says a menu is behind it.
test("the chip carries a down-arrow and opens a menu of every item", async ({
page,
}) => {
await theDemo(page);
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
// A <button> and not a <span>: the affordance is the element as much as
// the glyph, and a Filter axis gets neither.
await expect(chip(page)).toHaveAttribute("aria-haspopup", "listbox");
await expect(chip(page).locator(".page-arrow")).toHaveText("▾");
await expect(menu(page)).toBeHidden();
await chip(page).click();
await expect(menu(page)).toBeVisible();
await expect(menu(page).locator(".page-item")).toHaveText([
"✓ Q1",
" Q2",
" Q3",
" Q4",
]);
// Which item the document is fixed to is MARKED, so the menu says where
// you are as well as where you may go.
await expect(menu(page).locator(".page-item.selected")).toHaveText("✓ Q1");
});
// The whole point: the picked item reaches the host and the grid it filters
// changes. Nothing on the page moves the bar itself.
test("picking an item moves the page axis and the numbers under it", async ({
page,
}) => {
await theDemo(page);
const firstCell = page.locator("#grid td").first();
const q1 = await firstCell.textContent();
await chip(page).click();
await menu(page).getByText("Q4", { exact: false }).click();
await expect(chip(page)).toHaveText(/Quarter = Q4 \(4\/4\)/);
await expect(menu(page)).toBeHidden();
// Espresso Revenue is 18,400 in Q1 and 20,300 in Q4, so a menu that moved
// only the label would leave this unchanged.
await expect(firstCell).not.toHaveText(q1 ?? "");
await expect(firstCell).toHaveText("20,300");
});
// The menu is operable without a mouse, and while it is open it owns the
// keyboard: a grid key must not reach the grid behind it.
test("the menu is driven by the keyboard and does not leak keys to the grid", async ({
page,
}) => {
await theDemo(page);
await chip(page).click();
await expect(menu(page)).toBeVisible();
// `j` is the grid's own "move down" and here it moves the MENU. If it
// leaked, the selection under the menu would move as well.
const selected = page.locator("#grid td.selected");
const before = await selected.textContent();
await page.keyboard.press("j");
await expect(menu(page).locator(".page-item.active")).toHaveText(" Q2");
await expect(selected).toHaveText(before ?? "");
await page.keyboard.press("Enter");
await expect(menu(page)).toBeHidden();
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
});
test("Escape closes the menu and changes nothing", async ({ page }) => {
await theDemo(page);
await chip(page).click();
await expect(menu(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu(page)).toBeHidden();
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
// And the keyboard is the grid's again: `]` is the page key, so the bar
// moving here proves the menu gave it back.
await page.keyboard.press("]");
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
});
});
test.describe("the page keys follow the document, not the grid", () => {
// improvise-wiew, end to end: `[`/`]` are on the layer between the global
// and the modes whose subject IS the document, so a panel pages too.
test("a panel is still somewhere the page axis can be moved", async ({ page }) => {
await theDemo(page);
await expect(chip(page)).toHaveText(/Quarter = Q1/);
await page.keyboard.press("C");
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await page.keyboard.press("]");
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
// The key did its own job and no other: the panel is still open.
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
});
// …and the help overlay is not, because it covers the grid it would be
// changing. That is the one case the layer exists to exclude.
test("the help overlay pages nothing behind itself", async ({ page }) => {
await theDemo(page);
await page.keyboard.press("?");
await expect(page.locator("#help")).toBeVisible();
await page.keyboard.press("]");
await expect(page.locator("#help")).toBeVisible();
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
});
});
+136 -11
View File
@@ -17,7 +17,10 @@ import {
indexAction, indexAction,
indicator, indicator,
isFatal, isFatal,
MENU_ARROW,
menuKey,
NOTICE_KEY, NOTICE_KEY,
pageItemAction,
noticeOpen, noticeOpen,
noticeState, noticeState,
noticeText, noticeText,
@@ -41,6 +44,7 @@ const el = {
views: document.getElementById("views"), views: document.getElementById("views"),
tiles: document.getElementById("tiles"), tiles: document.getElementById("tiles"),
pages: document.getElementById("pages"), pages: document.getElementById("pages"),
pageMenu: document.getElementById("page-menu"),
mode: document.getElementById("mode"), mode: document.getElementById("mode"),
minibuffer: document.getElementById("minibuffer"), minibuffer: document.getElementById("minibuffer"),
hint: document.getElementById("hint"), hint: document.getElementById("hint"),
@@ -233,6 +237,9 @@ function repaint() {
} }
if (model.revision === painted) return; if (model.revision === painted) return;
painted = model.revision; painted = model.revision;
// The bar is about to be rebuilt, so the chip this menu is anchored to is
// gone. Closing is the honest answer; re-anchoring would be a guess.
closePageMenu();
el.mode.textContent = model.mode; el.mode.textContent = model.mode;
// The mode's own hint, off `help::hint_line`. The page spells no keys // The mode's own hint, off `help::hint_line`. The page spells no keys
@@ -328,22 +335,94 @@ function paintHelp(model) {
* (improvise-p7lh). An empty list is what says there are none. */ * (improvise-p7lh). An empty list is what says there are none. */
function paintPages(model) { function paintPages(model) {
const entries = pageEntries(model); const entries = pageEntries(model);
pageAxes = entries;
el.pages.hidden = entries.length === 0; el.pages.hidden = entries.length === 0;
el.pages.replaceChildren( el.pages.replaceChildren(...entries.map(pageChip));
...entries.map((entry) => { }
const span = document.createElement("span");
span.className = "page-axis"; /** One page chip. A navigable axis is a <button> carrying the arrow, because
// Only a Page axis is navigable; a Filter one constrains without being * it opens a menu; a Filter axis is a <span> with neither (improvise-rw6l). */
// something `[`/`]` can move (gotcha 27). function pageChip(entry) {
span.classList.toggle("navigable", entry.navigable); const label = entry.position
span.textContent = entry.position ? `${entry.text} (${entry.position})`
? `${entry.text} (${entry.position})` : entry.text;
: entry.text; if (!entry.navigable) {
return span; // Only a Page axis is navigable; a Filter one constrains without being
// something `[`/`]` or a menu can move (gotcha 27).
const span = document.createElement("span");
span.className = "page-axis";
span.textContent = label;
return span;
}
const button = document.createElement("button");
button.type = "button";
button.className = "page-axis navigable";
button.dataset.axis = String(entry.index);
button.setAttribute("aria-haspopup", "listbox");
const text = document.createElement("span");
text.textContent = label;
const arrow = document.createElement("span");
arrow.className = "page-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = MENU_ARROW;
button.append(text, arrow);
return button;
}
// The open page menu: which axis it belongs to and which row the keyboard is
// on. `null` is closed, exactly as `editing` is.
let pageAxes = [];
let pageMenu = null;
function openPageMenu(axis, anchor) {
const entry = pageAxes[axis];
// A Filter axis carries no items, so there is nothing to open — the chip is
// not a button either, and both answers come off the same `cycle`.
if (!entry || entry.items.length === 0) return;
const active = entry.items.findIndex((item) => item.selected);
pageMenu = { axis, active: active === -1 ? 0 : active };
const box = anchor.getBoundingClientRect();
el.pageMenu.style.left = `${Math.round(box.left)}px`;
el.pageMenu.style.top = `${Math.round(box.bottom + 2)}px`;
paintPageMenu();
}
function closePageMenu() {
pageMenu = null;
el.pageMenu.hidden = true;
el.pageMenu.replaceChildren();
}
function paintPageMenu() {
if (!pageMenu) return;
const entry = pageAxes[pageMenu.axis];
el.pageMenu.hidden = false;
el.pageMenu.replaceChildren(
...entry.items.map((item) => {
const row = document.createElement("button");
row.type = "button";
row.className = "page-item";
row.setAttribute("role", "option");
// `selected` is which item the DOCUMENT is fixed to; `active` is where
// the keyboard is. They start together and part on the first arrow key.
row.setAttribute("aria-selected", String(item.selected));
row.classList.toggle("selected", item.selected);
row.classList.toggle("active", item.item === pageMenu.active);
row.dataset.axis = String(entry.index);
row.dataset.item = String(item.item);
row.textContent = item.selected ? `✓ ${item.label}` : ` ${item.label}`;
return row;
}), }),
); );
} }
/** Pick an item and close. The value that comes back is the server's: nothing
* here moves the page bar, exactly as an editor does not move a cell. */
function pickPageItem(axis, item) {
closePageMenu();
send(client.click_page_item(axis, item));
}
/** The grid, painted from a plan in which every decision is already made /** The grid, painted from a plan in which every decision is already made
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything: * (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
* which labels repeat and are therefore blank, how many header levels there * which labels repeat and are therefore blank, how many header levels there
@@ -586,6 +665,31 @@ function delegateIndex(root, selector, act) {
}); });
} }
// The page bar and its menu are two surfaces and ONE meaning: an action with
// an axis and maybe an item, decided in `shell.js` (improvise-rw6l, b8ot).
for (const root of [el.pages, el.pageMenu]) {
root.addEventListener("click", (event) => {
const node = event.target.closest("[data-axis]");
if (!node || !root.contains(node)) return;
const action = pageItemAction(node.dataset);
if (!action) return;
// A live editor closes first, before the click is composed — the order
// the grid's own handler keeps, and for the same reason.
if (editing) editing.close(false);
if (action.kind === "pick") pickPageItem(action.axis, action.item);
else if (pageMenu && pageMenu.axis === action.axis) closePageMenu();
else openPageMenu(action.axis, node);
});
}
// A click anywhere else dismisses it. `pointerdown` rather than `click`, so a
// press that lands on the grid closes this before the grid acts on it.
window.addEventListener("pointerdown", (event) => {
if (!pageMenu) return;
if (el.pages.contains(event.target) || el.pageMenu.contains(event.target)) return;
closePageMenu();
});
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i)); delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i)); delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i)); delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
@@ -617,10 +721,31 @@ el.grid.addEventListener("mousedown", (event) => {
// The keyboard. `Client::on_key_event` folds locally where it can; what it // The keyboard. `Client::on_key_event` folds locally where it can; what it
// cannot answer is sent and does nothing, by design (context/code/clients.md). // cannot answer is sent and does nothing, by design (context/code/clients.md).
/** The menu's own keys, decided in `shell.js`. Every one of them is claimed:
* an unhandled key must not reach the grid behind the menu. */
function pageMenuKeydown(e) {
const items = pageAxes[pageMenu.axis].items;
const action = menuKey(e.key, { count: items.length, active: pageMenu.active });
if (action.kind === "close") closePageMenu();
else if (action.kind === "pick") pickPageItem(pageMenu.axis, action.item);
else if (action.kind === "move") {
pageMenu.active = action.item;
paintPageMenu();
}
// Ctrl and Alt combinations stay the browser's, exactly as below.
if (!e.ctrlKey && !e.altKey) e.preventDefault();
}
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
// The inline editor owns the keyboard while it is open. It stops // The inline editor owns the keyboard while it is open. It stops
// propagation, so this is belt and braces for a focus we did not predict. // propagation, so this is belt and braces for a focus we did not predict.
if (editing) return; if (editing) return;
// …and so does the page menu, which is the third state of that kind: it is
// anchored to the grid, so letting `hjkl` through would scroll it away.
if (pageMenu) {
pageMenuKeydown(e);
return;
}
// BrowserKey drops anything held with the platform meta key precisely so // BrowserKey drops anything held with the platform meta key precisely so
// Cmd-R does not arrive as a bare `r`; do not claim it here either. // Cmd-R does not arrive as a bare `r`; do not claim it here either.
if (e.metaKey) return; if (e.metaKey) return;
+7
View File
@@ -108,6 +108,13 @@
--> -->
<div id="pages" aria-label="Page axes" hidden></div> <div id="pages" aria-label="Page axes" hidden></div>
<!--
The page menu (improvise-rw6l). It lives OUTSIDE #pages because that
element's children are replaced on every repaint, and a menu rebuilt
under the pointer is a menu that closes itself.
-->
<div id="page-menu" role="listbox" aria-label="Page items" hidden></div>
<main> <main>
<aside id="categories" aria-label="Categories"></aside> <aside id="categories" aria-label="Categories"></aside>
<aside id="formulas" aria-label="Formulas" hidden></aside> <aside id="formulas" aria-label="Formulas" hidden></aside>
+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 /** 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. */ * Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
export function pageEntries(model) { 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, // The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
// exactly as `indicator`'s is. // exactly as `indicator`'s is.
text: `${axis.cat} = ${axis.selected}`, 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. // navigable: there is no position to show and no key that would move it.
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "", position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
navigable: Boolean(axis.cycle), 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 /** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many * decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive * header levels there are and which columns share a group all arrive
+68 -2
View File
@@ -269,12 +269,78 @@ header h1 {
font-size: 0.85em; font-size: 0.85em;
} }
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without /* A Page axis can be moved with `[`/`]` and picked from a menu; a Filter one
being navigable (gotcha 27), which is why only one of them lights up. */ constrains without being navigable (gotcha 27), so only one of them lights
up and only one of them is a button at all. */
#pages .navigable { #pages .navigable {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
display: inline-flex;
gap: 0.3rem;
align-items: center;
font: inherit;
font-weight: 600;
cursor: pointer;
}
#pages .navigable:hover {
background: var(--accent-soft);
}
/* The arrow says a menu is there. It follows `navigable`, never "is this on
the page bar" — an arrow over nothing to pick is worse than none. */
#pages .page-arrow {
font-size: 1.15em;
line-height: 1;
margin-left: -0.05rem;
}
/* The menu itself. Absolutely positioned from the chip it was opened on, and
outside #pages so a repaint cannot rebuild it under the pointer. */
#page-menu[hidden] {
display: none;
}
#page-menu {
position: fixed;
z-index: 20;
min-width: 9rem;
max-height: 60vh;
overflow-y: auto;
padding: 0.2rem;
display: flex;
flex-direction: column;
background: var(--raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
scrollbar-width: thin;
}
#page-menu .page-item {
text-align: left;
padding: 0.2rem 0.5rem;
border: 0;
border-radius: 3px;
background: none;
color: var(--fg);
font: inherit;
font-size: 0.82rem;
white-space: pre;
cursor: pointer;
}
/* `selected` is what the document is fixed to; `active` is where the keyboard
is. They start together and part on the first arrow key. */
#page-menu .page-item.selected {
color: var(--accent);
font-weight: 600;
}
#page-menu .page-item.active,
#page-menu .page-item:hover {
background: var(--selected);
} }
/* ── The frame: panels and grid ──────────────────────────────────────── */ /* ── The frame: panels and grid ──────────────────────────────────────── */
+88 -2
View File
@@ -4,7 +4,15 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { cellAction, cellAttrs, gridPlan, indexAction, pageEntries } from "../shell.js"; import {
cellAction,
cellAttrs,
gridPlan,
indexAction,
menuKey,
pageEntries,
pageItemAction,
} from "../shell.js";
const cell = (text, extra = {}) => ({ const cell = (text, extra = {}) => ({
text, text,
@@ -85,7 +93,13 @@ test("an index surface reads its row back, and nothing else", () => {
// moved it in silence. The bar is what says which quarter this is. // moved it in silence. The bar is what says which quarter this is.
test("the page bar names the category and the item it is fixed to", () => { test("the page bar names the category and the item it is fixed to", () => {
const [entry] = pageEntries({ const [entry] = pageEntries({
page_coords: [{ cat: "Quarter", selected: "Q2", cycle: { at: 1, count: 4 } }], page_coords: [
{
cat: "Quarter",
selected: "Q2",
cycle: { at: 1, count: 4, items: ["Q1", "Q2", "Q3", "Q4"] },
},
],
}); });
assert.equal(entry.text, "Quarter = Q2"); assert.equal(entry.text, "Quarter = Q2");
assert.equal(entry.position, "2/4", "one-based, as a user counts"); assert.equal(entry.position, "2/4", "one-based, as a user counts");
@@ -106,3 +120,75 @@ test("a filter axis is shown, and is not offered as something to cycle", () => {
test("no fixed categories is an empty bar, not a blank one", () => { test("no fixed categories is an empty bar, not a blank one", () => {
assert.deepEqual(pageEntries({ page_coords: [] }), []); assert.deepEqual(pageEntries({ page_coords: [] }), []);
}); });
// improvise-rw6l: the arrow and the menu follow `cycle`, which is the Page /
// Filter split decided in Rust — never "is there an entry on the bar".
test("a page axis offers its items, and a filter axis offers none", () => {
const [page, filter] = pageEntries({
page_coords: [
{
cat: "Quarter",
selected: "Q3",
cycle: { at: 2, count: 3, items: ["Q1", "Q2", "Q3"] },
},
{ cat: "Region", selected: "North", cycle: null },
],
});
assert.deepEqual(page.items, [
{ item: 0, label: "Q1", selected: false },
{ item: 1, label: "Q2", selected: false },
{ item: 2, label: "Q3", selected: true },
]);
assert.equal(page.index, 0, "the axis names itself by its place on the bar");
assert.deepEqual(
filter.items,
[],
"a Filter axis constrains without being navigable: nothing to pick",
);
assert.equal(filter.index, 1);
});
// One meaning across two surfaces (improvise-b8ot): a chip carries an axis, a
// menu row carries an axis AND an item. Nothing infers from structure.
test("a chip opens the menu and a menu row picks, one attribute apart", () => {
assert.deepEqual(pageItemAction({ axis: "1" }), { kind: "open", axis: 1 });
assert.deepEqual(pageItemAction({ axis: "1", item: "0" }), {
kind: "pick",
axis: 1,
item: 0,
});
assert.equal(pageItemAction({}), null, "the gaps between the chips are not a chip");
assert.equal(pageItemAction({ axis: "" }), null);
assert.equal(pageItemAction({ axis: "-1" }), null);
assert.deepEqual(
pageItemAction({ axis: "0", item: "" }),
{ kind: "open", axis: 0 },
"an empty item attribute is NO item, where Number('') would say 0",
);
});
// The menu owns the keyboard while it is open, so every key is answered and
// nothing falls through to the grid it is anchored to.
test("the menu answers its own keys and swallows the rest", () => {
const at = (active) => ({ count: 3, active });
assert.deepEqual(menuKey("ArrowDown", at(0)), { kind: "move", item: 1 });
assert.deepEqual(menuKey("j", at(2)), { kind: "move", item: 0 }, "and it wraps");
assert.deepEqual(menuKey("ArrowUp", at(0)), { kind: "move", item: 2 }, "both ways");
assert.deepEqual(menuKey("k", at(1)), { kind: "move", item: 0 });
assert.deepEqual(menuKey("Home", at(2)), { kind: "move", item: 0 });
assert.deepEqual(menuKey("End", at(0)), { kind: "move", item: 2 });
assert.deepEqual(menuKey("Enter", at(1)), { kind: "pick", item: 1 });
assert.deepEqual(menuKey(" ", at(1)), { kind: "pick", item: 1 });
assert.deepEqual(menuKey("Escape", at(1)), { kind: "close" });
assert.deepEqual(menuKey("Tab", at(1)), { kind: "close" });
assert.deepEqual(
menuKey("h", at(1)),
{ kind: "ignore" },
"a grid key is SWALLOWED, not passed on — the grid is behind the menu",
);
assert.deepEqual(
menuKey("ArrowDown", { count: 0, active: 0 }),
{ kind: "close" },
"a menu with nothing in it cannot be moved around",
);
});