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:
+74
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ──────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user