Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default
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 *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)`
|
||||
is where it belongs if a second such string ever appears. And the page bar is
|
||||
**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
|
||||
needs a `click_page` on the wasm surface that does not exist yet.
|
||||
is where it belongs if a second such string ever appears.
|
||||
|
||||
### The page bar is a control (improvise-rw6l)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Every text-entry mode has the same spine: characters append to one named
|
||||
|
||||
+6
-2
@@ -122,9 +122,13 @@ enum, and only the last translation is local.**
|
||||
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
|
||||
`Color` are ratatui types and `improvise-command`, where `AppMode` lives,
|
||||
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
|
||||
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
|
||||
difference is deliberate rather than pending. Those three map a *semantic enum*
|
||||
|
||||
@@ -3362,3 +3362,128 @@ pin the palette, which is not the property.
|
||||
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
|
||||
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`.
|
||||
|
||||
@@ -821,3 +821,90 @@ Faults injected against the fixed code:
|
||||
duplication improvise-636d removes, and it is invisible to every check that
|
||||
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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
### 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`
|
||||
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
|
||||
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
|
||||
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
|
||||
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 —
|
||||
/// a second list here would be the duplication improvise-636d removed.
|
||||
fn global_keys(global: &Keymap) -> Vec<KeyCode> {
|
||||
|
||||
@@ -403,6 +403,16 @@ fn global_keymap() -> Keymap {
|
||||
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
|
||||
/// reaches every mode from one `Panel::ALL` entry.
|
||||
fn bind_panel_toggles(km: &mut Keymap) {
|
||||
@@ -499,7 +509,10 @@ impl Keymaps {
|
||||
// ── Normal mode ──────────────────────────────────────────────────
|
||||
// The global every mode falls through to (improvise-636d).
|
||||
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
|
||||
for (key, dr, dc) in [
|
||||
@@ -611,9 +624,8 @@ impl Keymaps {
|
||||
);
|
||||
normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
|
||||
|
||||
// Page navigation
|
||||
normal.bind(KeyCode::Char(']'), none, "page-next");
|
||||
normal.bind(KeyCode::Char('['), none, "page-prev");
|
||||
// Page navigation is the global's, exactly as `?` and `:` are: a
|
||||
// mode that re-bound it would be the asymmetry §6 removed.
|
||||
|
||||
// Group / hide
|
||||
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
||||
@@ -709,7 +721,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::FormulaPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::Formula, global.clone())
|
||||
PanelKeymap::new(Panel::Formula, document.clone())
|
||||
.bind_args(
|
||||
KeyCode::Char('a'),
|
||||
"enter-mode",
|
||||
@@ -733,7 +745,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::CategoryPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::Category, global.clone())
|
||||
PanelKeymap::new(Panel::Category, document.clone())
|
||||
.bind(KeyCode::Enter, "filter-to-item")
|
||||
.bind(KeyCode::Char(' '), "cycle-axis-at-cursor")
|
||||
.bind_args(
|
||||
@@ -751,7 +763,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::ViewPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::View, global.clone())
|
||||
PanelKeymap::new(Panel::View, document.clone())
|
||||
.bind(KeyCode::Enter, "switch-view-at-cursor")
|
||||
.bind(KeyCode::Char('n'), "create-and-switch-view")
|
||||
.bind(KeyCode::Char('o'), "create-and-switch-view")
|
||||
@@ -761,7 +773,7 @@ impl Keymaps {
|
||||
);
|
||||
|
||||
// ── 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::Tab, none, "enter-mode", vec!["normal".into()]);
|
||||
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
|
||||
|
||||
@@ -84,6 +84,9 @@ pub enum Click {
|
||||
Tile(usize),
|
||||
/// A grid cell, in **viewport** coordinates as the page painted it.
|
||||
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
|
||||
@@ -97,6 +100,7 @@ pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
|
||||
Click::FormulaRow(index) => click_formula_row(state, index),
|
||||
Click::Tile(index) => click_tile(state, index),
|
||||
Click::Cell { row, col } => click_cell(state, row, col),
|
||||
Click::PageItem { axis, item } => click_page_item(state, axis, item),
|
||||
};
|
||||
resolved.unwrap_or_default()
|
||||
}
|
||||
@@ -720,6 +724,22 @@ fn page_cycle(state: &ClientState, way: Odometer) -> Option<Vec<Command>> {
|
||||
.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.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Advance {
|
||||
@@ -2715,6 +2735,52 @@ mod tests {
|
||||
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]`
|
||||
/// and every part shows up here — including the odd-looking trailing clear.
|
||||
#[test]
|
||||
|
||||
@@ -139,6 +139,13 @@ impl Client {
|
||||
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
|
||||
/// selection there. Opening an editor is the page's own affair.
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -162,12 +162,15 @@ pub struct RenderPageAxis {
|
||||
pub cycle: Option<RenderPageCycle>,
|
||||
}
|
||||
|
||||
/// A position in a navigable page axis, as a named pair: two bare counts read
|
||||
/// as "how many page axes are there".
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
|
||||
/// A position in a navigable page axis, and what it may be moved to. `items`
|
||||
/// is RELAYED, never derived here — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RenderPageCycle {
|
||||
pub at: 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
|
||||
@@ -206,8 +209,8 @@ pub struct RenderFormulaRow {
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// One tile: a category the view may move between axes, with `Axis`'s own
|
||||
/// short label (`"·"` for None is an answer, not a truncation — gotcha 26).
|
||||
/// One tile: a category the view may move between axes, with `Axis`'s PLAIN
|
||||
/// label — the three-column one is the TUI's (improvise-771e, clients.md).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RenderTile {
|
||||
pub category: String,
|
||||
@@ -330,6 +333,7 @@ fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
|
||||
cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
|
||||
at,
|
||||
count: items.len(),
|
||||
items: items.to_vec(),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
@@ -383,7 +387,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
|
||||
.enumerate()
|
||||
.map(|(i, tile)| RenderTile {
|
||||
category: tile.category.clone(),
|
||||
axis: tile.axis.short_label(),
|
||||
axis: tile.axis.plain_label(),
|
||||
selected: selecting && i == state.view.tile_cat_idx,
|
||||
})
|
||||
.collect()
|
||||
@@ -829,8 +833,8 @@ mod tests {
|
||||
let normal = client.model();
|
||||
assert_eq!(normal.tiles.len(), 2);
|
||||
assert_eq!(
|
||||
normal.tiles[1].axis, "·",
|
||||
"None is an answer, not a truncation"
|
||||
normal.tiles[1].axis, "None",
|
||||
"the browser has no column to fit, so the word is the answer"
|
||||
);
|
||||
assert!(
|
||||
!normal.tiles.iter().any(|t| t.selected),
|
||||
@@ -992,7 +996,11 @@ mod tests {
|
||||
let bar = client.model().page_coords;
|
||||
assert_eq!(
|
||||
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"
|
||||
);
|
||||
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
|
||||
/// rows this viewport scrolled past (gotcha 44). improvise-p7lh.
|
||||
#[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");
|
||||
|
||||
// `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").filter({ hasText: "Product" })).toHaveText(
|
||||
"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.
|
||||
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
|
||||
// says `[`/`]` will move it.
|
||||
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
|
||||
// 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,
|
||||
});
|
||||
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400.00");
|
||||
|
||||
@@ -17,14 +17,14 @@ async function anAggregateGrid(page) {
|
||||
await page.keyboard.press("Shift+T");
|
||||
const tiles = page.locator("#tiles .tile");
|
||||
const quarter = tiles.filter({ hasText: "Quarter" });
|
||||
await expect(quarter).toHaveText("Quarter Pag");
|
||||
await expect(quarter).toHaveText("Quarter Page");
|
||||
const names = await tiles.allTextContents();
|
||||
for (let i = 0; i < names.findIndex((t) => t.startsWith("Quarter")); i++) {
|
||||
await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
await expect(quarter).toHaveClass(/selected/);
|
||||
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");
|
||||
|
||||
// The precondition, asserted rather than assumed: nothing is editable now,
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
};
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
|
||||
use crate::ui::app::Panel as PanelKind;
|
||||
use crate::ui::panel::PanelContent;
|
||||
@@ -81,9 +83,9 @@ impl PanelContent for CategoryContent {
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
|
||||
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(
|
||||
inner.x + name_part.len() as u16,
|
||||
inner.x + name_part.width() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
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
@@ -7,6 +7,7 @@ use ratatui::{
|
||||
use crate::protocol::FormulaCache;
|
||||
use crate::ui::app::{AppMode, Panel as PanelKind};
|
||||
use crate::ui::panel::PanelContent;
|
||||
use crate::ui::text::truncate;
|
||||
|
||||
pub struct FormulaContent<'a> {
|
||||
pub formulas: &'a FormulaCache,
|
||||
@@ -50,13 +51,8 @@ impl PanelContent for FormulaContent<'_> {
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
let text = format!(" {raw}");
|
||||
let truncated = if text.len() > inner.width as usize {
|
||||
format!("{}…", &text[..inner.width as usize - 1])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
buf.set_string(inner.x, inner.y + index as u16, &truncated, style);
|
||||
let text = truncate(&format!(" {raw}"), inner.width as usize);
|
||||
buf.set_string(inner.x, inner.y + index as u16, &text, style);
|
||||
}
|
||||
|
||||
fn footer_height(&self) -> u16 {
|
||||
@@ -102,6 +98,45 @@ mod tests {
|
||||
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`
|
||||
/// carried (improvise-35e) — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
|
||||
+1
-23
@@ -4,10 +4,10 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Widget},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::{CellId, FrameEntry, GridCache};
|
||||
use crate::ui::app::{AppMode, ViewCursor};
|
||||
use crate::ui::text::truncate;
|
||||
use crate::ui::viewport::MIN_COL_WIDTH;
|
||||
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)]
|
||||
mod tests {
|
||||
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod grid;
|
||||
pub mod help;
|
||||
pub mod import_wizard_ui;
|
||||
pub mod panel;
|
||||
pub mod text;
|
||||
pub mod tile_bar;
|
||||
pub mod view_panel;
|
||||
pub mod which_key;
|
||||
|
||||
@@ -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
@@ -4,6 +4,8 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
};
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::{ViewListCache, view_axis_summary};
|
||||
use crate::ui::app::Panel as PanelKind;
|
||||
use crate::ui::panel::PanelContent;
|
||||
@@ -67,7 +69,7 @@ impl PanelContent for ViewContent<'_> {
|
||||
// Axis summary after the name, in dim text
|
||||
let summary_text = view_axis_summary(summary);
|
||||
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 {
|
||||
let summary_style = if is_selected {
|
||||
style
|
||||
@@ -83,11 +85,40 @@ impl PanelContent for ViewContent<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
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 {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
|
||||
+140
-11
@@ -17,7 +17,10 @@ import {
|
||||
indexAction,
|
||||
indicator,
|
||||
isFatal,
|
||||
MENU_ARROW,
|
||||
menuKey,
|
||||
NOTICE_KEY,
|
||||
pageItemAction,
|
||||
noticeOpen,
|
||||
noticeState,
|
||||
noticeText,
|
||||
@@ -41,6 +44,7 @@ const el = {
|
||||
views: document.getElementById("views"),
|
||||
tiles: document.getElementById("tiles"),
|
||||
pages: document.getElementById("pages"),
|
||||
pageMenu: document.getElementById("page-menu"),
|
||||
mode: document.getElementById("mode"),
|
||||
minibuffer: document.getElementById("minibuffer"),
|
||||
hint: document.getElementById("hint"),
|
||||
@@ -233,6 +237,9 @@ function repaint() {
|
||||
}
|
||||
if (model.revision === painted) return;
|
||||
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;
|
||||
// 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. */
|
||||
function paintPages(model) {
|
||||
const entries = pageEntries(model);
|
||||
pageAxes = entries;
|
||||
el.pages.hidden = entries.length === 0;
|
||||
el.pages.replaceChildren(
|
||||
...entries.map((entry) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "page-axis";
|
||||
// Only a Page axis is navigable; a Filter one constrains without being
|
||||
// something `[`/`]` can move (gotcha 27).
|
||||
span.classList.toggle("navigable", entry.navigable);
|
||||
span.textContent = entry.position
|
||||
? `${entry.text} (${entry.position})`
|
||||
: entry.text;
|
||||
return span;
|
||||
el.pages.replaceChildren(...entries.map(pageChip));
|
||||
}
|
||||
|
||||
/** One page chip. A navigable axis is a <button> carrying the arrow, because
|
||||
* it opens a menu; a Filter axis is a <span> with neither (improvise-rw6l). */
|
||||
function pageChip(entry) {
|
||||
const label = entry.position
|
||||
? `${entry.text} (${entry.position})`
|
||||
: entry.text;
|
||||
if (!entry.navigable) {
|
||||
// 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
|
||||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||||
* 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.views, "li[data-index]", (i) => client.click_view(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
|
||||
// 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) => {
|
||||
// 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.
|
||||
@@ -624,6 +747,12 @@ window.addEventListener("keydown", (e) => {
|
||||
// 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.
|
||||
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
|
||||
// it has focus. Claiming them here would call `preventDefault` on the
|
||||
// browser's own disclosure toggle, so a keyboard visitor could focus it and
|
||||
|
||||
@@ -108,6 +108,13 @@
|
||||
-->
|
||||
<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>
|
||||
<aside id="categories" aria-label="Categories"></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
|
||||
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
|
||||
export function pageEntries(model) {
|
||||
return model.page_coords.map((axis) => ({
|
||||
return model.page_coords.map((axis, index) => ({
|
||||
index,
|
||||
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
|
||||
// exactly as `indicator`'s is.
|
||||
text: `${axis.cat} = ${axis.selected}`,
|
||||
@@ -96,9 +97,61 @@ export function pageEntries(model) {
|
||||
// navigable: there is no position to show and no key that would move it.
|
||||
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
|
||||
navigable: Boolean(axis.cycle),
|
||||
// The items a menu offers, RELAYED from `cycle` — the one home of the
|
||||
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
|
||||
items: axis.cycle
|
||||
? axis.cycle.items.map((label, item) => ({
|
||||
item,
|
||||
label,
|
||||
selected: item === axis.cycle.at,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** The down-arrow. Present exactly where there is a menu to open, which is
|
||||
* `navigable` and never "is this on the page bar" (gotcha 27). */
|
||||
export const MENU_ARROW = "▾";
|
||||
|
||||
/** What a delegated click on the page bar or its menu MEANS. `null` where the
|
||||
* node names neither, which is every gap between the chips. */
|
||||
export function pageItemAction(dataset) {
|
||||
const axis = whole(dataset.axis);
|
||||
if (axis === null) return null;
|
||||
const item = whole(dataset.item);
|
||||
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
|
||||
// both: it picks. One attribute apart, so nothing infers from structure.
|
||||
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
|
||||
}
|
||||
|
||||
/** What a key does while the page menu is open. The menu owns the keyboard
|
||||
* exactly as the inline editor does — `context/code/clients.md`. */
|
||||
export function menuKey(key, { count, active }) {
|
||||
if (count === 0) return { kind: "close" };
|
||||
switch (key) {
|
||||
case "Escape":
|
||||
case "Tab":
|
||||
return { kind: "close" };
|
||||
case "Enter":
|
||||
case " ":
|
||||
return { kind: "pick", item: active };
|
||||
case "ArrowDown":
|
||||
case "j":
|
||||
return { kind: "move", item: (active + 1) % count };
|
||||
case "ArrowUp":
|
||||
case "k":
|
||||
return { kind: "move", item: (active - 1 + count) % count };
|
||||
case "Home":
|
||||
return { kind: "move", item: 0 };
|
||||
case "End":
|
||||
return { kind: "move", item: count - 1 };
|
||||
// Everything else is SWALLOWED rather than passed on: a menu that let
|
||||
// `hjkl` reach the grid would scroll the thing it is anchored to.
|
||||
default:
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
}
|
||||
|
||||
/** The grid, as a description: every decision made, nothing created. The page
|
||||
* decides nothing here and neither does this — which labels repeat, how many
|
||||
* header levels there are and which columns share a group all arrive
|
||||
|
||||
+68
-2
@@ -269,12 +269,78 @@ header h1 {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without
|
||||
being navigable (gotcha 27), which is why only one of them lights up. */
|
||||
/* A Page axis can be moved with `[`/`]` and picked from a menu; a Filter one
|
||||
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 {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -75,6 +75,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
"click_category",
|
||||
"click_cell",
|
||||
"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_view",
|
||||
"on_frame",
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
import { test } from "node:test";
|
||||
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 = {}) => ({
|
||||
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.
|
||||
test("the page bar names the category and the item it is fixed to", () => {
|
||||
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.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", () => {
|
||||
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