Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default

This commit is contained in:
Edward Langley
2026-09-21 23:50:06 -07:00
26 changed files with 1441 additions and 80 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
+6 -2
View File
@@ -122,9 +122,13 @@ enum, and only the last translation is local.**
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and `mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
`Color` are ratatui types and `improvise-command`, where `AppMode` lives, `Color` are ratatui types and `improvise-command`, where `AppMode` lives,
carries no rendering-framework dependency), and since `improvise-zx8t` it is carries no rendering-framework dependency), and since `improvise-zx8t` it is
**exhaustive**: the fourteen variants that take the default chrome are spelled **exhaustive**: the thirteen variants that take the default chrome are spelled
out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the
build here until someone chooses its colour. build here until someone chooses its colour. The arithmetic is the check a
prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` +
`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until
2026-09-22, and nothing could have caught that — a count written in prose is
checked by no one.
It is still not quite the same shape as `axis_fg` and its two siblings, and the It is still not quite the same shape as `axis_fg` and its two siblings, and the
difference is deliberate rather than pending. Those three map a *semantic enum* difference is deliberate rather than pending. Those three map a *semantic enum*
+125
View File
@@ -3362,3 +3362,128 @@ 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.
### a modified key is the browser's, and the menu neither acts on it nor claims it
Found by ChatGPT Codex on PR #123, and correct. The page menu's early return
sat **above** `app.js`'s `metaKey` guard, so with the menu open `Cmd-R` reached
`menuKey`, was answered `ignore`, and then hit
`if (!e.ctrlKey && !e.altKey) e.preventDefault()` — which is true for a Meta
combination. Reload was blocked. `Ctrl-j` and `Alt-Home` had the mirror problem
in the other direction: they *moved* the menu, although the comment two lines
below said Ctrl and Alt combinations stay the browser's.
The repair is ordering plus one bail: the `metaKey` guard moves back above the
menu branch (so the menu is not even consulted), `pageMenuKeydown` returns
immediately on `ctrlKey || altKey`, and what remains then claims
**unconditionally** — every unmodified key including `ignore`, which is the rule
the menu exists to keep.
**Why the assertion is shaped the way it is.** The listener that records
`defaultPrevented` is installed by `page.evaluate` *after* the page has loaded,
so it runs **second** on the same target and observes what `app.js` decided. One
registered first would read `false` for every key and pass against any code at
all. And the test ends by pressing an **unmodified** `j` and asserting
`["j", true]`: without that row, "nothing was defaulted away" is equally
satisfied by a page that claims nothing, so the modifier would not be what the
test is about.
Fault injected: the reviewed code restored exactly — menu branch above the
`metaKey` guard, no `ctrlKey || altKey` bail, conditional `preventDefault`. The
test fails on `Control+j` moving the active row from `✓ Q1` to ` Q2`.
+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.
+89 -2
View File
@@ -199,6 +199,93 @@ category values separately, never joined with `/`. It is the assertion
--- ---
## `src/ui/text.rs` — the one display-width-safe truncator (improvise-8l0c)
`src/ui/formula_panel.rs` cut a formula by **bytes** and subtracted 1 from an
unchecked `inner.width`, so rendering `|é| = 1` into a panel of outer width 7
panicked on a char boundary and an inner width of 0 panicked on the subtraction.
Formula identifiers are pipe-quoted and may carry any character, so an ordinary
window resize over an ordinary document crashed the whole TUI.
The repair is not a third truncator. `src/ui/grid.rs` already had a
display-width one and `src/ui/import_wizard_ui.rs` has a deliberately
byte-measured one (`context/code/tui.md` argues why that one stays bytes: it
travels with the hint-line padding, and changing its measure moves where every
wizard line wraps). The grid's version moved to `src/ui/text.rs` unchanged in
behaviour for ASCII, and the formula panel now calls it. Two things about the
move: `prefix_by_width` replaced the `chars().take(max_width)` tail, which
over-ran by a cell for a wide character at `max_width == 1`; and the width
invariant is now asserted rather than assumed.
Widths 0, 1 and 2 are in the loop because the comment on improvise-8l0c
reported the same line panicking on pure ASCII at inner width 0.
### ui::text::tests::no_cut_point_of_a_multibyte_string_panics
Every cut point of `Ünïcödé = 1`, and each result asserted to be a **prefix** of
the input — which is what says the search walked *down* to a boundary rather
than up past one.
### ui::text::tests::a_result_never_exceeds_the_width_it_was_given
The invariant the panel's arithmetic depends on, over a wide-character string as
well as ASCII.
Rival fix injected 2026-09-21: the obvious "unicode-safe" repair — measure and
cut by `chars().count()`/`char_indices().nth()` — passes both panic tests and
fails this one on `日本語 = 1`, because a char count is not a cell count. That is
the whole reason the shared truncator is the width-measured one and not a
character-measured one.
### ui::text::tests::ascii_truncation_is_unchanged
The grid's eight exact-frame characterization fixtures are ASCII, so this is
what says the move cost no evidence.
### ui::formula_panel::tests::a_cut_inside_a_multibyte_formula_does_not_panic
The ticket's repro, driven through the real `Panel` widget rather than a copied
substring expression.
### ui::formula_panel::tests::no_panel_width_panics_the_formula_panel
Widths 0 through 12 over an ASCII formula and a multi-byte one.
Rival fix injected 2026-09-21: repairing only the subtraction
(`(inner.width as usize).saturating_sub(1)`, byte slice kept) fails both of
these — the first on the char boundary, the second at width 1, where a
`saturating_sub` yields a slice bound of 0 for a string that needs one. A test
written only at width 0 would have accepted it.
#### render_formulas / drawn (helpers)
`render_formulas` returns the `Buffer` rather than a joined string because
`chunks(0)` panics, and a test that cannot be *run* at width 0 cannot see the
width-0 bug.
### ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name
### ui::view_panel::tests::the_axis_summary_sits_beside_a_multibyte_view_name
The same measure confusion two files over, found by searching for the *class*
rather than the line: both panels placed a second string at
`inner.x + name.len()`, a **byte** count used as a column. Neither panics —
`Buffer::set_string` clips — so the symptom is a gap that widens with every
non-ASCII character in the name, and the axis tag drifting toward and eventually
past the panel's border.
Both tests draw the row twice, once with `Cafe` and once with `Café`, and
require the two to differ *only* by that character. Comparing against a
hard-coded frame would have pinned the current spacing; comparing the two
renderings names the question — does a character that is one cell wide and two
bytes long move anything. Before the fix the `é` row put the tag one cell
further right; both were re-checked by reverting each one-line change
independently.
Not fixed here, and filed as improvise-n8vh: neither panel truncates at all, so a name
longer than the panel overwrites its right border. That is a different bug from
the one 8l0c names — no truncation, rather than wrong truncation — and
`src/ui/text.rs::truncate` is now sitting there for whoever takes it.
## `src/ui/import_wizard_ui.rs` ## `src/ui/import_wizard_ui.rs`
### a_cut_inside_a_multibyte_character_does_not_panic ### a_cut_inside_a_multibyte_character_does_not_panic
@@ -212,7 +299,7 @@ accented header crashed the TUI rather than eliding a name.
`"Régions"` is 8 bytes for 7 characters: the é occupies bytes 1..3, so `max = 3` `"Régions"` is 8 bytes for 7 characters: the é occupies bytes 1..3, so `max = 3`
puts `max - 1 = 2` inside it. puts `max - 1 = 2` inside it.
### no_cut_point_of_a_multibyte_string_panics ### ui::import_wizard_ui::truncate_tests::no_cut_point_of_a_multibyte_string_panics
Not one cut point, every cut point. The interesting indices are the ones inside Not one cut point, every cut point. The interesting indices are the ones inside
a character, and which those are depends on the string — so the test walks them a character, and which those are depends on the string — so the test walks them
@@ -220,7 +307,7 @@ rather than picking one and hoping it was the bad one. Each result must also be
a *prefix* of the input, which is what says the boundary search walked down a *prefix* of the input, which is what says the boundary search walked down
rather than dropping the character it landed in. rather than dropping the character it landed in.
### ascii_truncation_is_unchanged ### ui::import_wizard_ui::truncate_tests::ascii_truncation_is_unchanged
ASCII is unaffected, which is what lets the characterization fixtures in this ASCII is unaffected, which is what lets the characterization fixtures in this
file keep asserting exact frames written before the boundary fix. file keep asserting exact frames written before the boundary fix.
@@ -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.00"); await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400.00");
+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,
+163
View File
@@ -0,0 +1,163 @@
// 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\)/);
});
// Found by ChatGPT Codex on PR #123: the menu branch sat ABOVE the page's
// `metaKey` guard, so Cmd-R was answered `ignore` and then defaulted away.
test("a modified key is the browser's, and the menu neither acts on it nor claims it", async ({
page,
}) => {
await theDemo(page);
// Registered AFTER `app.js`'s own window listener, so it runs second and
// reads what the page did — a listener added first would see `false` always.
await page.evaluate(() => {
window.__claimed = [];
window.addEventListener("keydown", (e) =>
window.__claimed.push([e.key, e.defaultPrevented]),
);
});
await chip(page).click();
await expect(menu(page)).toBeVisible();
await expect(menu(page).locator(".page-item.active")).toHaveText("✓ Q1");
// `Ctrl-j` is not `j`: it must not move the menu the way the bare key does.
await page.keyboard.press("Control+j");
await expect(menu(page).locator(".page-item.active")).toHaveText("✓ Q1");
await page.keyboard.press("Meta+r");
await expect(menu(page)).toBeVisible();
const claimed = await page.evaluate(() => window.__claimed);
expect(claimed, `nothing reached the handler: ${JSON.stringify(claimed)}`).not.toEqual([]);
for (const [key, prevented] of claimed) {
expect(prevented, `${key} was defaulted away; it is the browser's`).toBe(false);
}
// The precondition the rows above lean on: an UNMODIFIED key still is
// claimed, so "nothing was prevented" is a fact about the modifier.
await page.keyboard.press("j");
await expect(menu(page).locator(".page-item.active")).toHaveText(" Q2");
const after = await page.evaluate(() => window.__claimed.at(-1));
expect(after).toEqual(["j", true]);
});
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\)/);
});
});
+45 -2
View File
@@ -4,6 +4,8 @@ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
}; };
use unicode_width::UnicodeWidthStr;
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree}; use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
use crate::ui::app::Panel as PanelKind; use crate::ui::app::Panel as PanelKind;
use crate::ui::panel::PanelContent; use crate::ui::panel::PanelContent;
@@ -81,9 +83,9 @@ impl PanelContent for CategoryContent {
let axis_part = format!(" [{axis_str}]"); let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style); buf.set_string(inner.x, y, &name_part, base_style);
if name_part.len() + axis_part.len() < inner.width as usize { if name_part.width() + axis_part.width() < inner.width as usize {
buf.set_string( buf.set_string(
inner.x + name_part.len() as u16, inner.x + name_part.width() as u16,
y, y,
&axis_part, &axis_part,
if is_selected { if is_selected {
@@ -101,3 +103,44 @@ impl PanelContent for CategoryContent {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::CategoryRow;
use crate::ui::app::AppMode;
use crate::ui::panel::Panel;
use ratatui::{layout::Rect, widgets::Widget};
/// improvise-8l0c — `context/test-evidence/tui.md`.
fn drawn_row(name: &str) -> String {
let cache = CategoryTreeCache {
categories: vec![CategoryRow {
name: name.to_string(),
axis: Axis::Row,
items: vec!["x".to_string()],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(
CategoryContent::new(&cache, &Default::default()),
&AppMode::Normal,
0,
)
.render(area, &mut buf);
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
let ascii = drawn_row("Cafe");
let wide = drawn_row("Café");
assert_eq!(
ascii.replace("Cafe", "Café"),
wide,
"the axis tag moved because the name was measured in bytes"
);
}
}
+42 -7
View File
@@ -7,6 +7,7 @@ use ratatui::{
use crate::protocol::FormulaCache; use crate::protocol::FormulaCache;
use crate::ui::app::{AppMode, Panel as PanelKind}; use crate::ui::app::{AppMode, Panel as PanelKind};
use crate::ui::panel::PanelContent; use crate::ui::panel::PanelContent;
use crate::ui::text::truncate;
pub struct FormulaContent<'a> { pub struct FormulaContent<'a> {
pub formulas: &'a FormulaCache, pub formulas: &'a FormulaCache,
@@ -50,13 +51,8 @@ impl PanelContent for FormulaContent<'_> {
} else { } else {
Style::default().fg(Color::Green) Style::default().fg(Color::Green)
}; };
let text = format!(" {raw}"); let text = truncate(&format!(" {raw}"), inner.width as usize);
let truncated = if text.len() > inner.width as usize { buf.set_string(inner.x, inner.y + index as u16, &text, style);
format!("{}…", &text[..inner.width as usize - 1])
} else {
text
};
buf.set_string(inner.x, inner.y + index as u16, &truncated, style);
} }
fn footer_height(&self) -> u16 { fn footer_height(&self) -> u16 {
@@ -102,6 +98,45 @@ mod tests {
app app
} }
/// improvise-8l0c — `context/test-evidence/tui.md`.
fn render_formulas(raws: &[&str], width: u16) -> Buffer {
let cache = FormulaCache {
formulas: raws.iter().map(|s| (*s).to_string()).collect(),
};
let area = Rect::new(0, 0, width, 5);
let mut buf = Buffer::empty(area);
Panel::new(
FormulaContent::new(&cache, &AppMode::Normal),
&AppMode::Normal,
0,
)
.render(area, &mut buf);
buf
}
fn drawn(buf: &Buffer) -> String {
buf.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("")
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn a_cut_inside_a_multibyte_formula_does_not_panic() {
let text = drawn(&render_formulas(&["|\u{e9}| = 1"], 7));
assert!(text.contains('\u{2026}'), "{text}");
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn no_panel_width_panics_the_formula_panel() {
for width in 0..=12u16 {
let _ = render_formulas(&["A = 1", "|\u{e9}\u{e9}\u{e9}| = 22"], width);
}
}
/// `FormulaCache::formulas` must carry what `model.formulas()[i].raw` /// `FormulaCache::formulas` must carry what `model.formulas()[i].raw`
/// carried (improvise-35e) — `context/test-evidence/tui.md`. /// carried (improvise-35e) — `context/test-evidence/tui.md`.
#[test] #[test]
+1 -23
View File
@@ -4,10 +4,10 @@ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget}, widgets::{Block, Borders, Widget},
}; };
use unicode_width::UnicodeWidthStr;
use crate::protocol::{CellId, FrameEntry, GridCache}; use crate::protocol::{CellId, FrameEntry, GridCache};
use crate::ui::app::{AppMode, ViewCursor}; use crate::ui::app::{AppMode, ViewCursor};
use crate::ui::text::truncate;
use crate::ui::viewport::MIN_COL_WIDTH; use crate::ui::viewport::MIN_COL_WIDTH;
use crate::view::GridLayout; use crate::view::GridLayout;
@@ -485,28 +485,6 @@ fn indicator(collapsed: bool) -> &'static str {
} }
} }
fn truncate(s: &str, max_width: usize) -> String {
let w = s.width();
if w <= max_width {
s.to_string()
} else if max_width > 1 {
let mut result = String::new();
let mut width = 0;
for c in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
if width + cw + 1 > max_width {
break;
}
result.push(c);
width += cw;
}
result.push('…');
result
} else {
s.chars().take(max_width).collect()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
+1
View File
@@ -6,6 +6,7 @@ pub mod grid;
pub mod help; pub mod help;
pub mod import_wizard_ui; pub mod import_wizard_ui;
pub mod panel; pub mod panel;
pub mod text;
pub mod tile_bar; pub mod tile_bar;
pub mod view_panel; pub mod view_panel;
pub mod which_key; pub mod which_key;
+74
View File
@@ -0,0 +1,74 @@
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// The one display-width-safe truncator for the terminal widgets. Bytes are
/// never the measure here, and the argument is in `context/code/tui.md`.
pub fn truncate(s: &str, max_width: usize) -> String {
if s.width() <= max_width {
s.to_string()
} else if max_width > 1 {
format!("{}\u{2026}", prefix_by_width(s, max_width - 1))
} else {
prefix_by_width(s, max_width).to_string()
}
}
/// The longest prefix of `s` occupying at most `max_width` terminal cells. A
/// cell count and never a byte count, so no cut lands inside a character.
fn prefix_by_width(s: &str, max_width: usize) -> &str {
let mut end = 0;
let mut width = 0;
for (i, c) in s.char_indices() {
let cw = UnicodeWidthChar::width(c).unwrap_or(1);
if width + cw > max_width {
break;
}
width += cw;
end = i + c.len_utf8();
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::truncate;
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn no_cut_point_of_a_multibyte_string_panics() {
let s = "Ünïcödé = 1";
for max in 0..=s.len() + 2 {
let out = truncate(s, max);
let body = out.strip_suffix('…').unwrap_or(&out);
assert!(
s.starts_with(body),
"truncate({s:?}, {max}) = {out:?}, which is not a prefix of the input"
);
}
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn a_result_never_exceeds_the_width_it_was_given() {
use unicode_width::UnicodeWidthStr;
for s in ["A = 1", "|é| = 1", "日本語 = 1", ""] {
for max in 0..=12 {
let out = truncate(s, max);
assert!(
out.width() <= max,
"truncate({s:?}, {max}) = {out:?} is {} cells wide",
out.width()
);
}
}
}
/// ASCII is unchanged, which is what lets the grid's exact-frame fixtures
/// keep saying what they said. `context/test-evidence/tui.md`.
#[test]
fn ascii_truncation_is_unchanged() {
assert_eq!(truncate("cancel", 10), "cancel");
assert_eq!(truncate("cancel", 4), "can…");
assert_eq!(truncate("cancel", 1), "c");
assert_eq!(truncate("cancel", 0), "");
}
}
+33 -2
View File
@@ -4,6 +4,8 @@ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
}; };
use unicode_width::UnicodeWidthStr;
use crate::protocol::{ViewListCache, view_axis_summary}; use crate::protocol::{ViewListCache, view_axis_summary};
use crate::ui::app::Panel as PanelKind; use crate::ui::app::Panel as PanelKind;
use crate::ui::panel::PanelContent; use crate::ui::panel::PanelContent;
@@ -67,7 +69,7 @@ impl PanelContent for ViewContent<'_> {
// Axis summary after the name, in dim text // Axis summary after the name, in dim text
let summary_text = view_axis_summary(summary); let summary_text = view_axis_summary(summary);
if !summary_text.is_empty() { if !summary_text.is_empty() {
let summary_x = inner.x + name_text.len() as u16 + 1; let summary_x = inner.x + name_text.width() as u16 + 1;
if summary_x < inner.x + inner.width { if summary_x < inner.x + inner.width {
let summary_style = if is_selected { let summary_style = if is_selected {
style style
@@ -83,11 +85,40 @@ impl PanelContent for ViewContent<'_> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ui::app::App; use crate::ui::app::{App, AppMode};
use crate::view::Axis; use crate::view::Axis;
use crate::workbook::Workbook; use crate::workbook::Workbook;
use improvise_command::ui::project; use improvise_command::ui::project;
/// improvise-8l0c — `context/test-evidence/tui.md`.
fn drawn_row(view_name: &str) -> String {
use crate::protocol::ViewSummary;
use crate::ui::panel::Panel;
use ratatui::{buffer::Buffer, widgets::Widget};
let cache = ViewListCache {
views: vec![ViewSummary {
name: view_name.to_string(),
axes: vec![("Region".to_string(), Axis::Row)],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(ViewContent::new(&cache, view_name), &AppMode::Normal, 0).render(area, &mut buf);
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn the_axis_summary_sits_beside_a_multibyte_view_name() {
let ascii = drawn_row("Cafe");
let wide = drawn_row("Café");
assert_eq!(
ascii.replace("Cafe", "Café"),
wide,
"the axis summary moved because the name was measured in bytes"
);
}
fn app_with_two_views() -> App { fn app_with_two_views() -> App {
let mut wb = Workbook::new("Test"); let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap(); wb.add_category("Region").unwrap();
+140 -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,6 +721,25 @@ 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) {
// Ctrl and Alt combinations are the browser's, exactly as below, so the menu
// neither acts on one nor claims it — `Ctrl-j` is not `j` (improvise-rw6l).
if (e.ctrlKey || e.altKey) return;
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();
}
// Every unmodified key is CLAIMED, `ignore` included: the grid is behind
// the menu and must not also act on it.
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.
@@ -624,6 +747,12 @@ window.addEventListener("keydown", (e) => {
// 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;
// …and the page menu owns the keyboard too, which is the third state of
// that kind: `hjkl` reaching the grid would scroll it out from under it.
if (pageMenu) {
pageMenuKeydown(e);
return;
}
// The notice's <summary> is a real control and owns Enter and Space while // The notice's <summary> is a real control and owns Enter and Space while
// it has focus. Claiming them here would call `preventDefault` on the // it has focus. Claiming them here would call `preventDefault` on the
// browser's own disclosure toggle, so a keyboard visitor could focus it and // browser's own disclosure toggle, so a keyboard visitor could focus it and
+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 ──────────────────────────────────────── */
+3
View File
@@ -75,6 +75,9 @@ test("the page still reaches the client through a handful of calls", () => {
"click_category", "click_category",
"click_cell", "click_cell",
"click_formula", "click_formula",
// improvise-rw6l: picking an item off a page axis. The page holds no
// `Basis`, so composing `SetPageSelection` cannot happen above this.
"click_page_item",
"click_tile", "click_tile",
"click_view", "click_view",
"on_frame", "on_frame",
+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",
);
});