fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.
rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.
Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.
fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.
gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.
Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.
1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+75
-9
@@ -456,7 +456,36 @@ immutable `CmdContext`; the commands are then folded in order.
|
||||
`the_command_names_that_still_need_the_host_are_these` names them, and
|
||||
`Host::handle` answers a `Key` frame with an empty vec, so each one is a binding
|
||||
that **does nothing at all** in a browser
|
||||
(`doctrine(an_upstream_key_is_a_black_hole)`). The classes:
|
||||
(`doctrine(an_upstream_key_is_a_black_hole)`).
|
||||
|
||||
**An upstream key that the app also ADVERTISES is a different severity, and it
|
||||
had its own class of defect** (improvise-rmvx). A key nobody offers doing
|
||||
nothing is a gap; a key named in the mode's own hint line doing nothing reads as
|
||||
*broken*. The two are crossed by `every_advertised_command_answers_in_its_own
|
||||
_mode`, which asks `help::advertised` — a table of command **names**, so the
|
||||
cross-reference is exact and nothing tokenises the rendered string — against
|
||||
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
|
||||
only mean an unhandled name. The old guard asked only about `Normal`, which is
|
||||
why `add-record-row` and `delete-formula-at-cursor` were outside its reach
|
||||
entirely; `view-back` in RecordsNormal was a seventh instance nobody had
|
||||
noticed.
|
||||
|
||||
`declined` is the answer, and it is one list rather than an arm apiece: six
|
||||
command names, each with **its own true subject**. Wording matters here more
|
||||
than it looks — the `>` fix was nearly routed through a message about
|
||||
aggregates, which would have been true-sounding about the wrong thing and worse
|
||||
than silence, because a reader who is told something plausible stops looking.
|
||||
`step` checks `declined` first, so `composed` has no arm for any of them and the
|
||||
two lists cannot overlap.
|
||||
|
||||
A declining step takes its **whole sequence** with it: `o` in RecordsNormal is
|
||||
`[add-record-row, enter-edit-at-cursor]`, and the second step's premise is false
|
||||
once the first has declined. Returning only the decline, rather than the decline
|
||||
plus whatever composed before it, is what keeps `Binding::Sequence`'s "every
|
||||
step or none of them" true — an abort is not a rollback (gotcha 21), and this
|
||||
client has no way to undo a `Command` it has already put on the wire.
|
||||
|
||||
The classes that remain:
|
||||
|
||||
- **Prefix keys.** `Command::SetTransientPrefix` exists and this client could
|
||||
arm one; what it cannot do is what the *second* key then runs, which is
|
||||
@@ -474,13 +503,23 @@ that **does nothing at all** in a browser
|
||||
in the formula panel, not in the grid.
|
||||
- **Anything touching storage or an arbitrary command line.** The host's by
|
||||
definition.
|
||||
- **The records-mode commit.** A wide-records row is one coordinate tuple and
|
||||
editing a category column moves every measure cell in it;
|
||||
`commit_plain_records_edit` needs `GridLayout::record_col` to know which.
|
||||
- **The records-mode commit** — *except the two creating columns*. A wide-records
|
||||
row is one coordinate tuple and editing a category column moves every measure
|
||||
cell in it; `commit_plain_records_edit` needs `GridLayout::record_col` to know
|
||||
which. A `CellId::Create` does not: it commits the typed NAME as one existing
|
||||
`ModelCommand` (`AddItem` into `_Measure`, or `AddCategory`), reads no
|
||||
`record_col` and writes no cell, so improvise-gcvb narrowed the early return
|
||||
rather than removing it. The `+cat` half **must not advance**: an inserted
|
||||
column renumbers every index to its right and the advance was computed from
|
||||
the pre-commit layout (gotcha 23), so `CommitAndAdvance` returns straight
|
||||
after it and a client that advanced anyway would land one column off,
|
||||
silently. `AddCategory` can also be refused by the 12-category limit and sets
|
||||
`abort_effects`; the mode change therefore goes **first**, where an abort
|
||||
cannot reach it (gotcha 21).
|
||||
- **Drill.** Session-local residue, and the one case that does **not** go
|
||||
upstream silently — see below.
|
||||
|
||||
### `strands_the_user`: a prompt this client can enter but cannot commit
|
||||
### `stranding`: a prompt this client can enter but cannot commit
|
||||
|
||||
`enter-mode` is trivially resolvable here, and that is the trap: the user
|
||||
reaches the prompt, types a path, presses Enter — and the commit resolves to
|
||||
@@ -497,10 +536,12 @@ meaningless: the worker persists on its own behalf whenever the document is
|
||||
dirty, so the document *is* the OPFS entry and there is no Ctrl-S semantics to
|
||||
offer.
|
||||
|
||||
The predicate is named as a property rather than matched at the call site
|
||||
(`doctrine(two_questions_one_enum)`): "can this client finish what entering the
|
||||
mode starts" is not the same question as "is this a text-entry mode", and a
|
||||
third such prompt belongs here. Found by Copilot on PR #27 against the newly
|
||||
It returns the **subject** rather than a bool (improvise-rmvx): the refusal is
|
||||
right and the silence was not, so `:` now says the command line is unavailable
|
||||
instead of doing nothing at all. The predicate is named as a property rather
|
||||
than matched at the call site (`doctrine(two_questions_one_enum)`): "can this
|
||||
client finish what entering the mode starts" is not the same question as "is
|
||||
this a text-entry mode", and a third such prompt belongs here. Found by Copilot on PR #27 against the newly
|
||||
added `SavePrompt`; `ExportPrompt` had the identical trap already and was fixed
|
||||
with it — fixing one arm of a rule with two is the shape
|
||||
`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)` names.
|
||||
@@ -946,6 +987,19 @@ Four smaller decisions that would be re-derived wrongly:
|
||||
broken grid. `editing` is `AppMode::is_editing()` plus the `edit` buffer:
|
||||
without it, a browser that resolved `i` locally would change its mode indicator
|
||||
and then swallow every keystroke invisibly.
|
||||
- **`minibuffer` is the same argument, for the other six text-entry modes**
|
||||
(improvise-fdt6). `editing` drives the *inline cell editor*, so it is scoped to
|
||||
the two cell-editing modes and reads the `"edit"` buffer by name — which left
|
||||
FormulaEdit, CategoryAdd, ItemAdd, SavePrompt, ExportPrompt and CommandMode
|
||||
opening a prompt and swallowing every character typed into it. `minibuffer` is
|
||||
`AppMode::minibuffer()` plus **the buffer that config names**, read with
|
||||
`improvise_protocol::read_buffer` so the `"search"` special case keeps its one
|
||||
home (gotcha 11); `None` is "no text-entry mode is open", so the page asks
|
||||
nothing about the mode. The colour stays semantic — `MinibufColor` crosses and
|
||||
the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg`
|
||||
is the TUI's (gotcha 26). It is drawn as a line of its own rather than *in
|
||||
place of* `#status` as the TUI's bottom bar does, because `#status` also
|
||||
carries the transport's failures and a repaint must not erase one (gotcha 48).
|
||||
|
||||
`RenderCell::edit_text` is the stored value, not the formatted one: seeding an
|
||||
editor from `text` and committing turns `1200` into the string `"1,200"`. The
|
||||
@@ -1303,6 +1357,7 @@ it about", and the browser half has four:
|
||||
| `RenderModel` → DOM | every field the render model projects reaches an element | `web/tests/render_surface.test.js` (statically) **and** `harness/tests/contract.spec.js` (on the screen) |
|
||||
| JS → wasm | the calls the page makes exist, with the arity the bundle declares, and their answers are painted | `web/tests/bundle/wasm_surface.test.js` (the surface) **and** `contract.spec.js`'s pointer test (the wiring) |
|
||||
| page → peer | the handshake goes out first, a frame is answered whole, a failure is named, **and the page routes each failure through the call that disarms the watchdog** | `harness/tests/shell.spec.js`, `harness/tests/drill.spec.js` |
|
||||
| key → screen | a key the mode's hint line ADVERTISES produces something the user can see, and a text-entry mode paints what is typed into it | `harness/tests/keys.spec.js` |
|
||||
| worker → OPFS | `storage_contract`, run against a real `OpfsFs` | `harness/tests/opfs.spec.js` |
|
||||
|
||||
**A unit test of a rule does not test the wiring that reaches it**, and the
|
||||
@@ -1383,6 +1438,17 @@ enough to make twice, from different sides:
|
||||
widget and asserts the element is *on the screen*. Node cannot see that, and
|
||||
the moment a reduction would have to weaken it, it stays here.
|
||||
|
||||
**And the static half is weaker than it reads, which is worth knowing before
|
||||
leaning on it.** `render_surface.test.js` asks whether the string
|
||||
`model.<field>` occurs anywhere in the page's code — so a field *mentioned* by a
|
||||
function nothing ever calls passes. That happened on improvise-fdt6: the new
|
||||
`minibuffer` field had a `paintMinibuffer` reading it, and the call site and the
|
||||
`getElementById` were both missing, so the node suite went green over a page
|
||||
that still painted nothing. `keys.spec.js` failed on its first run, in Chromium,
|
||||
naming the hidden element. The static check is a **completeness** check — it
|
||||
tells you a field was not forgotten — and never an **execution** check; only
|
||||
`harness/` is that (gotcha 48).
|
||||
|
||||
What is deliberately **not** in Playwright any more is what each key or click
|
||||
*means*: `improvise_protocol::resolve` decides that and its tests are where the
|
||||
meaning is pinned. One instance of "a resolved change repaints" is kept, because
|
||||
|
||||
@@ -1703,3 +1703,104 @@ not the text, so a cell drawn blank passed. The assertion was strengthened until
|
||||
the injection failed, which is the only reason it is worth having — and it is
|
||||
the same lesson as the eleven tests above: a class is a flag, and a flag is not
|
||||
a pixel.
|
||||
|
||||
### every_advertised_command_answers_in_its_own_mode
|
||||
|
||||
improvise-rmvx. Gotcha 47 says an upstream key is a black hole, and
|
||||
`the_command_names_that_still_need_the_host_are_these` is the guard that was
|
||||
supposed to keep the list of them honest. It was built with
|
||||
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
|
||||
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
|
||||
were outside its reach entirely and could never have failed it. **A named list
|
||||
that silently scopes itself to one mode is a guard that looks broader than it
|
||||
is.**
|
||||
|
||||
This asks it of every mode, and it asks it the only way that is not
|
||||
over-reporting. The naive version resolves every advertised name against an
|
||||
EMPTY `ClientState` and flags `edit-or-drill`, `commit-cell-edit`,
|
||||
`create-and-switch-view` and `page-prev`/`page-next` — but those are *handled*
|
||||
names refused by STATE, which is correct behaviour and not this bug. So the
|
||||
fixture is `furnished`: every widget has something in it, a basis is published,
|
||||
and a window is announced, so an `Upstream` can only mean the name is
|
||||
unhandled.
|
||||
|
||||
Two things it does NOT do, each because the first attempt did and was wrong.
|
||||
It does not call `step` with the advertised args: `advertised` carries `None`
|
||||
for "any binding of this command", and `step`'s numeric and mode arguments are
|
||||
mandatory, so that reports `move-selection`, `move-panel-cursor` and
|
||||
`move-tile-cursor` as gaps in every mode. And it does not tokenise the rendered
|
||||
hint string: `help::advertised` is a table of command NAMES, so the
|
||||
cross-reference is exact and anything reading the rendered text is solving a
|
||||
harder problem than exists. The keys come off `Keymap::keys_running`, which
|
||||
`labels_for` now shares so "which keys" and "how are they spelled" cannot give
|
||||
two answers.
|
||||
|
||||
**The injection**: delete the `"toggle-prune-empty"` arm from `declined`. The
|
||||
test fails naming `NORMAL: Char('P')` and `RECORDS: Char('P')` — both modes,
|
||||
which is the widening the ticket was about, and a build that ran rather than a
|
||||
compile error.
|
||||
|
||||
What it found that the ticket did not predict: `view-back` is advertised in
|
||||
RecordsNormal (`<`, captioned "back"), so it was a seventh instance of the same
|
||||
class. The ticket had it filed under the Normal-mode host list instead, where
|
||||
being upstream is not a defect.
|
||||
|
||||
The five that remain are listed by name with a reason each. Three of them —
|
||||
`commit-export`, `commit-save`, `execute-command` — are reachable only from a
|
||||
mode `declined` refuses at the door, so a browser never stands in front of the
|
||||
hint; the other two are improvise-gcvb's records-cell commit, which needs
|
||||
`record_col`.
|
||||
|
||||
### a_locally_committed_creating_column_agrees_with_the_host
|
||||
|
||||
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
|
||||
mode, and the reason given was sound for the coordinate and measure columns —
|
||||
a category column renames every measure cell in the row, which needs
|
||||
`record_col` (gotcha 1). The guard was broader than its reason: a cell at the
|
||||
cursor now projects `CellId::Create`, and committing one is a single existing
|
||||
`ModelCommand` over the typed NAME that reads no `record_col` and writes no
|
||||
cell.
|
||||
|
||||
Both halves are proved by injection, and the second is the one that matters.
|
||||
Putting the broad `is_records()` early return back makes the test fail at "the
|
||||
client must answer this key locally". Deleting the `CreateKind::Category` early
|
||||
return — so `+cat` advances like any other column — fails with the client in
|
||||
`RecordsEditing` where the host is in `RecordsNormal`: the TUI returns straight
|
||||
after a `+cat` commit with a `RebuildLayout` and no cursor move, because an
|
||||
inserted column renumbers every index to its right and the advance was computed
|
||||
from the pre-commit layout (gotcha 23). A client that advanced anyway would land
|
||||
one column off, silently.
|
||||
|
||||
`both_ends` compares the document as `.improv` text, the mode, the status and
|
||||
every cursor, so neither half can pass by agreeing on the wrong thing. The
|
||||
fixture drives *both* hosts into records mode with the `R` key rather than
|
||||
hand-building a `_Records` view, because `twin_of` copies only the session
|
||||
slice and a twin without the view would refuse the commit for the wrong reason.
|
||||
|
||||
### every_minibuffer_mode_paints_what_is_being_typed
|
||||
|
||||
improvise-fdt6. `commit-buffer formula` already resolved on the client — that
|
||||
half of the ticket was closed by an earlier wave — so the browser could enter
|
||||
FormulaEdit, type a formula and commit it. What did not exist was any way to
|
||||
SEE it: `RenderModel::editing` is populated only when `mode.is_editing()`, which
|
||||
is the two *cell*-editing modes, and it reads the `"edit"` buffer by name. Every
|
||||
character typed into FormulaEdit, CategoryAdd, ItemAdd, SavePrompt,
|
||||
ExportPrompt or CommandMode went into a buffer nothing painted. The mode opened,
|
||||
the user typed, and the screen did not move — which is exactly the dead-room
|
||||
shape the ticket names, arrived at from the rendering side rather than the
|
||||
resolution side.
|
||||
|
||||
The value is read with `improvise_protocol::read_buffer` rather than
|
||||
`buffers.get`, so the `"search"` special case (gotcha 11) keeps its one home.
|
||||
|
||||
**The injection**: `.filter(|_| state.view.mode.is_editing())` on the new
|
||||
field, which is the pre-fix behaviour exactly. It fails naming `"formula"`.
|
||||
|
||||
**And the static check was not enough, which is the part worth keeping.**
|
||||
`web/tests/render_surface.test.js` asserts that every `pub` field of
|
||||
`RenderModel` appears as `model.<field>` somewhere in `app.js`. It passed while
|
||||
the page was still broken: the string `model.minibuffer` was inside
|
||||
`paintMinibuffer`, and `paintMinibuffer` was never called and `el.minibuffer`
|
||||
was never looked up. A field can be *mentioned* by a function nothing invokes.
|
||||
`harness/tests/keys.spec.js` caught it on the first run, in the browser, which
|
||||
is the whole argument for that directory existing (gotcha 48).
|
||||
|
||||
@@ -53,7 +53,7 @@ fn hint_tail(mode: &AppMode) -> &'static str {
|
||||
|
||||
/// What each mode advertises: command names and captions, never keys. The
|
||||
/// keys come off the keymap, so a rebinding reaches the status bar for free.
|
||||
fn advertised(mode: &AppMode) -> Vec<(&'static str, Option<Vec<String>>, &'static str)> {
|
||||
pub(crate) fn advertised(mode: &AppMode) -> Vec<(&'static str, Option<Vec<String>>, &'static str)> {
|
||||
let arg = |a: &str| Some(vec![a.to_string()]);
|
||||
match mode {
|
||||
AppMode::Normal => vec![
|
||||
@@ -782,11 +782,10 @@ fn commands() -> Vec<HelpLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every `AppMode`, as one list. Shared with `resolve`'s tests so a
|
||||
/// cross-reference cannot silently scope itself to one mode (improvise-rmvx).
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all_modes() -> Vec<AppMode> {
|
||||
pub(crate) fn all_modes() -> Vec<AppMode> {
|
||||
vec![
|
||||
AppMode::Normal,
|
||||
AppMode::editing(),
|
||||
@@ -808,6 +807,10 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The property the old hand-written `hint_text` needed a 60-line
|
||||
/// tokenizer to approximate. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
|
||||
@@ -208,22 +208,35 @@ impl Keymap {
|
||||
&self.bindings
|
||||
}
|
||||
|
||||
/// Every key label here or in a parent that runs `command`. `args` of
|
||||
/// `None` accepts any; §8 says why that is the useful granularity.
|
||||
pub fn labels_for(&self, command: &str, args: Option<&[String]>) -> Vec<String> {
|
||||
let mut labels: Vec<String> = self
|
||||
/// Every key here or in a parent that runs `command`. `args` of `None`
|
||||
/// accepts any; §8 says why that is the useful granularity.
|
||||
pub fn keys_running(
|
||||
&self,
|
||||
command: &str,
|
||||
args: Option<&[String]>,
|
||||
) -> Vec<(KeyCode, KeyModifiers)> {
|
||||
let mut keys: Vec<(KeyCode, KeyModifiers)> = self
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|(_, binding)| runs(binding, command, args))
|
||||
.filter_map(|(pattern, _)| match pattern {
|
||||
KeyPattern::Key(code, mods) => Some(key_label(code, *mods)),
|
||||
KeyPattern::Key(code, mods) => Some((*code, *mods)),
|
||||
KeyPattern::AnyChar | KeyPattern::Any => None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(parent) = &self.parent {
|
||||
labels.extend(parent.labels_for(command, args));
|
||||
keys.extend(parent.keys_running(command, args));
|
||||
}
|
||||
labels
|
||||
keys
|
||||
}
|
||||
|
||||
/// The same keys, spelled. One walk, so a caller asking "which keys" and
|
||||
/// a caller asking "how are they written" cannot get different answers.
|
||||
pub fn labels_for(&self, command: &str, args: Option<&[String]>) -> Vec<String> {
|
||||
self.keys_running(command, args)
|
||||
.iter()
|
||||
.map(|(code, mods)| key_label(code, *mods))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up the binding for a key in this keymap's own bindings.
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use render::{
|
||||
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
|
||||
WizardProposal, WizardStepKind, header_level_widths,
|
||||
};
|
||||
pub use resolve::{Click, KeyOutcome, click, resolve, typed_cell_command};
|
||||
pub use resolve::{Click, KeyOutcome, click, read_buffer, resolve, typed_cell_command};
|
||||
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
|
||||
// `improvise-core`'s, because the index that resolves one lives in
|
||||
// `DataStore`. Re-exported so a client depends on the protocol alone.
|
||||
|
||||
@@ -7,10 +7,14 @@ use crate::client::ClientState;
|
||||
use crate::keymap::{Binding, Keymaps, ModeKey};
|
||||
use crate::viewmodel::{CatTreeEntry, flatten_cat_tree};
|
||||
use crate::{
|
||||
AppMode, CellId, CellValue, Command, GridExtent, HelpPage, KeyCode, KeyEvent, ModelCommand,
|
||||
Panel, RecordCell, ViewState, records_named,
|
||||
AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent,
|
||||
ModelCommand, Panel, RecordCell, ViewState, records_named,
|
||||
};
|
||||
|
||||
/// The virtual category a new measure is filed under, spelled as
|
||||
/// `commit.rs::add_measure` spells it.
|
||||
const MEASURE_CATEGORY: &str = "_Measure";
|
||||
|
||||
/// What this client decided to do with a keypress.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum KeyOutcome {
|
||||
@@ -54,8 +58,8 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
|
||||
// the second key then runs is not. See `context/code/clients.md`.
|
||||
Binding::Prefix(_) => KeyOutcome::Upstream(event),
|
||||
Binding::Cmd { name, args } => match step(state, event, name, args) {
|
||||
Some(commands) => KeyOutcome::of(commands),
|
||||
None => KeyOutcome::Upstream(event),
|
||||
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
|
||||
Step::Upstream => KeyOutcome::Upstream(event),
|
||||
},
|
||||
// All or nothing, each step resolved against the *unchanged* state.
|
||||
// See `context/code/clients.md`.
|
||||
@@ -185,7 +189,8 @@ fn focus(state: &ClientState, panel: Panel) -> Vec<Command> {
|
||||
commands
|
||||
}
|
||||
|
||||
/// Every step of a `Binding::Sequence`, or none of them.
|
||||
/// Every step of a `Binding::Sequence`, or none of them. A declining step takes
|
||||
/// the whole sequence with it — `context/code/clients.md` (gotcha 21).
|
||||
fn sequence(
|
||||
state: &ClientState,
|
||||
event: KeyEvent,
|
||||
@@ -193,14 +198,61 @@ fn sequence(
|
||||
) -> Option<Vec<Command>> {
|
||||
let mut commands = Vec::new();
|
||||
for (name, args) in steps {
|
||||
commands.extend(step(state, event, name, args)?);
|
||||
match step(state, event, name, args) {
|
||||
Step::Local(more) => commands.extend(more),
|
||||
Step::Declined(said) => return Some(said),
|
||||
Step::Upstream => return None,
|
||||
}
|
||||
}
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// What one command name resolved to. `Declined` is composed here like
|
||||
/// `Local`, and differs only in what it does to a sequence.
|
||||
enum Step {
|
||||
Local(Vec<Command>),
|
||||
Declined(Vec<Command>),
|
||||
Upstream,
|
||||
}
|
||||
|
||||
/// One name, answered. A decline is checked first, so [`composed`] has no arm
|
||||
/// for any of them and the two lists cannot overlap.
|
||||
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
|
||||
match declined(name, args) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
None => Step::Upstream,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands this client cannot compose and answers **out loud**; the one
|
||||
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
|
||||
fn declined(name: &str, args: &[String]) -> Option<String> {
|
||||
let what = match name {
|
||||
"drill-into-cell" => "drill",
|
||||
"toggle-prune-empty" => "hiding empty rows and columns",
|
||||
"toggle-records-mode" => "records mode",
|
||||
"add-record-row" => "adding a record row",
|
||||
"delete-formula-at-cursor" => "deleting a formula",
|
||||
"view-back" => "stepping back to the previous view",
|
||||
// A door this client will not open still has to say so: entering is
|
||||
// resolvable and the commit behind it is not.
|
||||
"enter-mode" => stranding(&AppMode::from_name(args.first()?)?)?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{what} is not available in the browser yet"))
|
||||
}
|
||||
|
||||
/// The subset of command names this client can turn into `Command`s; `None`
|
||||
/// means "the host's". Each arm mirrors its `Cmd` line for line — clients.md.
|
||||
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Option<Vec<Command>> {
|
||||
fn composed(
|
||||
state: &ClientState,
|
||||
event: KeyEvent,
|
||||
name: &str,
|
||||
args: &[String],
|
||||
) -> Option<Vec<Command>> {
|
||||
match name {
|
||||
// ── the grid cursor ──────────────────────────────────────────
|
||||
"move-selection" => grid_move(state, GridMove::Relative(num(args, 0)?, num(args, 1)?)),
|
||||
@@ -232,13 +284,7 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Op
|
||||
"clear-buffer" => Some(vec![write_buffer(args.first()?, String::new())]),
|
||||
|
||||
// ── mode entry ───────────────────────────────────────────────
|
||||
"enter-mode" => {
|
||||
let mode = AppMode::from_name(args.first()?)?;
|
||||
match strands_the_user(&mode) {
|
||||
true => None,
|
||||
false => Some(entered(mode)),
|
||||
}
|
||||
}
|
||||
"enter-mode" => Some(entered(AppMode::from_name(args.first()?)?)),
|
||||
|
||||
// ── panels ───────────────────────────────────────────────────
|
||||
"cycle-panel-focus" => Some(cycle_panel_focus(&state.view)),
|
||||
@@ -280,12 +326,6 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Op
|
||||
cmd: ModelCommand::TransposeAxes,
|
||||
}]),
|
||||
|
||||
// The hint line advertises `>`, and an upstream key is a black hole
|
||||
// (gotcha 47), so it said nothing at all. Interim: improvise-gd28.
|
||||
"drill-into-cell" => Some(vec![Command::SetStatus(
|
||||
"drill is not available in the browser yet".to_string(),
|
||||
)]),
|
||||
|
||||
// ── editing ──────────────────────────────────────────────────
|
||||
"edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?),
|
||||
"commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down),
|
||||
@@ -380,7 +420,7 @@ fn num(args: &[String], i: usize) -> Option<i32> {
|
||||
// The named buffers. A runtime-named buffer may not be an entry in `buffers`
|
||||
// at all: "search" is `ViewState::search_query` (improvise-gdl).
|
||||
|
||||
fn read_buffer(view: &ViewState, name: &str) -> String {
|
||||
pub fn read_buffer(view: &ViewState, name: &str) -> String {
|
||||
match name {
|
||||
"search" => view.search_query.clone(),
|
||||
other => view.buffers.get(other).cloned().unwrap_or_default(),
|
||||
@@ -397,13 +437,15 @@ fn write_buffer(name: &str, value: String) -> Command {
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt this client can *enter* but cannot *commit*: a dead room is worse
|
||||
/// than a locked door. All three arms, and who may delete each: clients.md.
|
||||
fn strands_the_user(mode: &AppMode) -> bool {
|
||||
matches!(
|
||||
mode,
|
||||
AppMode::SavePrompt { .. } | AppMode::ExportPrompt { .. } | AppMode::CommandMode { .. }
|
||||
)
|
||||
/// A prompt this client can *enter* but cannot *commit*; `Some` is the subject
|
||||
/// to decline by name. A dead room is worse than a locked door: clients.md.
|
||||
fn stranding(mode: &AppMode) -> Option<&'static str> {
|
||||
match mode {
|
||||
AppMode::SavePrompt { .. } => Some("saving to a path"),
|
||||
AppMode::ExportPrompt { .. } => Some("exporting"),
|
||||
AppMode::CommandMode { .. } => Some("the command line"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnterMode::execute`: clear the incoming mode's minibuffer, then change.
|
||||
@@ -630,27 +672,82 @@ enum Advance {
|
||||
/// `CommitAndAdvance::execute`, for the one case a thin client can compose: a
|
||||
/// plain grid cell. The three-way fork and the mirror order: clients.md.
|
||||
fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
|
||||
let cursor = state.view.cursor();
|
||||
let (window_rows, window_cols) = state.window;
|
||||
if window_rows == 0 || window_cols == 0 {
|
||||
return None;
|
||||
}
|
||||
// Records mode is the fork this client does not have: a category column
|
||||
// renames every measure cell in the row, which needs `record_col`.
|
||||
let text = read_buffer(&state.view, "edit");
|
||||
let id = cell_at_cursor(state)?.id.as_ref()?;
|
||||
// Records mode needs `record_col` (gotcha 1) — except the two CREATING
|
||||
// columns, which name nothing but themselves (improvise-gcvb).
|
||||
if let CellId::Create(kind) = id {
|
||||
return create_column(state, edit_mode, advance, kind, &text);
|
||||
}
|
||||
if state.view.mode.is_records() {
|
||||
return None;
|
||||
}
|
||||
let text = read_buffer(&state.view, "edit");
|
||||
let mut commands = vec![Command::Model {
|
||||
basis: state.basis.claim([record_at_cursor(state)?])?,
|
||||
cmd: typed_cell_command(cell_at_cursor(state)?.id.as_ref()?, &text)?,
|
||||
cmd: typed_cell_command(id, &text)?,
|
||||
}];
|
||||
commands.push(Command::ChangeMode(AppMode::Normal));
|
||||
commands.extend(advanced(state, edit_mode, advance)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// `CommitAndAdvance` over a creating column: a schema change over the typed
|
||||
/// NAME. Why +cat does not advance: clients.md (improvise-gcvb).
|
||||
fn create_column(
|
||||
state: &ClientState,
|
||||
edit_mode: AppMode,
|
||||
advance: Advance,
|
||||
kind: &CreateKind,
|
||||
text: &str,
|
||||
) -> Option<Vec<Command>> {
|
||||
// `exit_mode_for`: the tidy-up goes FIRST, where an aborting
|
||||
// `AddCategory` cannot reach it (gotcha 21).
|
||||
let mut commands = vec![Command::ChangeMode(match state.view.mode.is_records() {
|
||||
true => AppMode::RecordsNormal,
|
||||
false => AppMode::Normal,
|
||||
})];
|
||||
let name = text.trim();
|
||||
if !name.is_empty() {
|
||||
commands.push(model(
|
||||
state,
|
||||
match kind {
|
||||
CreateKind::Measure => ModelCommand::AddItem {
|
||||
category: MEASURE_CATEGORY.to_string(),
|
||||
item: name.to_string(),
|
||||
},
|
||||
CreateKind::Category => ModelCommand::AddCategory(name.to_string()),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
commands.push(Command::SetSelected { row, col });
|
||||
// An inserted column renumbers every index to its right, so staying put is
|
||||
// what lands on the new one (improvise-2zq, gotcha 23).
|
||||
if matches!(kind, CreateKind::Category) {
|
||||
return Some(commands);
|
||||
}
|
||||
commands.extend(advanced(state, edit_mode, advance)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The advance and the re-entry `CommitAndAdvance` ends in. `None` where the
|
||||
/// host would emit `AddRecordRow`, which this client cannot name (gotcha 1).
|
||||
fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
|
||||
let GridExtent { rows, cols } = state.grid.extent;
|
||||
let row_max = rows.saturating_sub(1);
|
||||
let col_max = cols.saturating_sub(1);
|
||||
let (row, col) = cursor.selected;
|
||||
let (row, col) = state.view.cursor().selected;
|
||||
if matches!(advance, Advance::Right)
|
||||
&& state.view.mode.is_records()
|
||||
&& row >= row_max
|
||||
&& col >= col_max
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let landed = match advance {
|
||||
Advance::Down if row < row_max => Some((row + 1, col)),
|
||||
Advance::Down if col < col_max => Some((0, col + 1)),
|
||||
@@ -662,15 +759,14 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<V
|
||||
Advance::Right => Some((row, (col + 1).min(col_max))),
|
||||
};
|
||||
let Some((nr, nc)) = landed else {
|
||||
return Some(commands);
|
||||
return Some(Vec::new());
|
||||
};
|
||||
commands.extend(scrolled_to(state, nr, nc));
|
||||
let mut commands = scrolled_to(state, nr, nc);
|
||||
// `EnterEditAtCursor`: seed from where we landed, then change mode. The
|
||||
// new cell is a different cell, so a stale cache is correct here.
|
||||
let seeded = seed_at(state, nr, nc);
|
||||
commands.push(Command::SetBuffer {
|
||||
name: "edit".to_string(),
|
||||
value: seeded,
|
||||
value: seed_at(state, nr, nc),
|
||||
});
|
||||
commands.push(Command::ChangeMode(edit_mode));
|
||||
Some(commands)
|
||||
@@ -1557,6 +1653,102 @@ mod tests {
|
||||
|
||||
// ── what is bound and what is not ───────────────────────────────
|
||||
|
||||
/// A client whose every widget has something in it, so an `Upstream` names
|
||||
/// an **unhandled command** and never a state that said no (improvise-rmvx).
|
||||
fn furnished(mode: AppMode) -> ClientState {
|
||||
let mut state = client_on(record_cell());
|
||||
state.view.mode = mode;
|
||||
state.grid.page_coords = vec![crate::PageAxis {
|
||||
cat: "Quarter".into(),
|
||||
selected: "Q1".into(),
|
||||
items: Some(vec!["Q1".into(), "Q2".into()]),
|
||||
}];
|
||||
crate::WidgetCache::apply(
|
||||
&mut state.categories,
|
||||
&crate::CategoryTreeUpdate::Categories(vec![crate::CategoryRow {
|
||||
name: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
);
|
||||
state.formulas.formulas = vec!["Profit = Revenue - Cost".into()];
|
||||
state.views.views = vec![
|
||||
crate::ViewSummary {
|
||||
name: "Default".into(),
|
||||
axes: Vec::new(),
|
||||
},
|
||||
crate::ViewSummary {
|
||||
name: "Second".into(),
|
||||
axes: Vec::new(),
|
||||
},
|
||||
];
|
||||
state.tiles.tiles = vec![crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
}];
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("formula".into(), "Profit = Revenue".into());
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("category".into(), "Product".into());
|
||||
state.view.buffers.insert("item".into(), "Shirts".into());
|
||||
state.view.buffers.insert("edit".into(), "42".into());
|
||||
state
|
||||
}
|
||||
|
||||
/// improvise-rmvx. An advertised key that resolves upstream is a black hole
|
||||
/// (gotcha 47). See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn every_advertised_command_answers_in_its_own_mode() {
|
||||
let mut unanswered = Vec::new();
|
||||
for mode in crate::help::all_modes() {
|
||||
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
|
||||
continue;
|
||||
};
|
||||
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
|
||||
continue;
|
||||
};
|
||||
let state = furnished(mode.clone());
|
||||
for (command, args, caption) in crate::help::advertised(&mode) {
|
||||
for (code, mods) in keymap.keys_running(command, args.as_deref()) {
|
||||
let event = KeyEvent::new(code, mods);
|
||||
if resolve(&state, event) == KeyOutcome::Upstream(event) {
|
||||
unanswered.push(format!(
|
||||
"{}: {:?} → `{command} {args:?}` ({caption})",
|
||||
crate::mode_label(&mode),
|
||||
code
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unanswered.sort();
|
||||
unanswered.dedup();
|
||||
// The five that remain, each with the reason it is not a black hole.
|
||||
// Closing one has to come here and say so; opening a sixth fails.
|
||||
let expected = [
|
||||
// Reached only from a mode `declined` refuses at the door, so a
|
||||
// browser is never standing in front of this hint.
|
||||
"COMMAND: Enter → `execute-command None` (run)",
|
||||
"EXPORT: Enter → `commit-export None` (export)",
|
||||
"SAVE AS: Enter → `commit-save None` (save)",
|
||||
// A records cell commit needs `record_col` (gotcha 1); only the two
|
||||
// creating columns resolve, and this fixture is on a record cell.
|
||||
"RECORDS INSERT: Enter → `commit-cell-edit None` (commit)",
|
||||
"RECORDS INSERT: Tab → `commit-and-advance-right None` (commit+right)",
|
||||
];
|
||||
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
unanswered, expected,
|
||||
"advertised and unanswerable: anything here that is not in the \
|
||||
named list above reads as broken rather than as unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
/// The command **names** this client cannot execute, one by one, so that
|
||||
/// closing one of these gaps has to come here and say so. Evidence file.
|
||||
#[test]
|
||||
@@ -1570,14 +1762,11 @@ mod tests {
|
||||
(ev(KeyCode::Char('Z')), "<the Z prefix>"),
|
||||
(ev(KeyCode::Char('H')), "hide-selected-row-item"),
|
||||
(ev(KeyCode::Char('z')), "toggle-group-under-cursor"),
|
||||
// `>` is NOT here any more: it answers with a status of its own,
|
||||
// because it is advertised and silence read as broken (gd28).
|
||||
(ev(KeyCode::Char('<')), "view-back"),
|
||||
// `>`, `<`, `R`, `P`, Ctrl-W and Ctrl-E are NOT here any more: each
|
||||
// answers with a status of its own (improvise-gd28, improvise-rmvx).
|
||||
(ev(KeyCode::Char('/')), "search"),
|
||||
(ev(KeyCode::Char('n')), "search-navigate"),
|
||||
(ev(KeyCode::Char('N')), "search-or-category-add"),
|
||||
(ev(KeyCode::Char('R')), "toggle-records-mode"),
|
||||
(ev(KeyCode::Char('P')), "toggle-prune-empty"),
|
||||
(ev(KeyCode::Enter), "enter-advance"),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
|
||||
@@ -1587,16 +1776,6 @@ mod tests {
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL),
|
||||
"force-quit",
|
||||
),
|
||||
// The two doors this client refuses rather than opens —
|
||||
// `enter-mode` is resolvable, the commit behind it is not.
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||
"enter-mode save-prompt (commit-save is the host's)",
|
||||
),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||
"enter-mode export-prompt (commit-export is the host's)",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve(&state, key),
|
||||
@@ -1638,19 +1817,31 @@ mod tests {
|
||||
#[test]
|
||||
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
||||
let state = in_mode(AppMode::Normal);
|
||||
for (key, mode) in [
|
||||
for (key, mode, subject) in [
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||
"save-prompt",
|
||||
"saving",
|
||||
),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||
"export-prompt",
|
||||
"exporting",
|
||||
),
|
||||
(ev(KeyCode::Char(':')), "command-mode", "command line"),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve(&state, key),
|
||||
KeyOutcome::Upstream(key),
|
||||
let KeyOutcome::Local(said) = resolve(&state, key) else {
|
||||
panic!("{mode} must be refused *out loud*, not in silence");
|
||||
};
|
||||
let [Command::SetStatus(msg)] = &said[..] else {
|
||||
panic!("{mode}: expected one status, got {said:?}");
|
||||
};
|
||||
assert!(
|
||||
msg.contains(subject),
|
||||
"{mode}: the refusal must name its own subject, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode} must not be entered by a client that cannot commit it"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,20 @@ pub struct RenderModel {
|
||||
/// What the user has typed so far while a keyboard edit is open on the
|
||||
/// selected cell. The decision is made here — `context/code/clients.md`.
|
||||
pub editing: Option<String>,
|
||||
/// The bottom-bar prompt while ANY text-entry mode is open, cell edits
|
||||
/// included. `None` elsewhere, so the page asks nothing about the mode.
|
||||
pub minibuffer: Option<RenderMinibuffer>,
|
||||
}
|
||||
|
||||
/// The prompt, what has been typed into it, and the colour. WHICH buffer is
|
||||
/// the mode's own answer (gotcha 11) — never a name this module spells.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RenderMinibuffer {
|
||||
pub prompt: String,
|
||||
pub value: String,
|
||||
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
|
||||
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
|
||||
pub color: improvise_protocol::MinibufColor,
|
||||
}
|
||||
|
||||
/// A grid row: either a group header spanning the row, or data. An enum so a
|
||||
@@ -251,6 +265,11 @@ pub fn render_model(state: &ClientState) -> RenderModel {
|
||||
.mode
|
||||
.is_editing()
|
||||
.then(|| state.view.buffers.get("edit").cloned().unwrap_or_default()),
|
||||
minibuffer: state.view.mode.minibuffer().map(|mb| RenderMinibuffer {
|
||||
prompt: mb.prompt.clone(),
|
||||
value: improvise_protocol::read_buffer(&state.view, &mb.buffer_key),
|
||||
color: mb.color,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +832,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-fdt6. A room the browser could enter, typing into a buffer
|
||||
/// nothing painted. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn every_minibuffer_mode_paints_what_is_being_typed() {
|
||||
for mode in [
|
||||
improvise_protocol::AppMode::formula_edit(),
|
||||
improvise_protocol::AppMode::category_add(),
|
||||
improvise_protocol::AppMode::item_add("Region".into()),
|
||||
improvise_protocol::AppMode::editing(),
|
||||
] {
|
||||
let config = mode.minibuffer().expect("a text-entry mode").clone();
|
||||
let mut client = client_with_grid(ViewState::default());
|
||||
assert_eq!(
|
||||
client.model().minibuffer,
|
||||
None,
|
||||
"Normal has no minibuffer to paint"
|
||||
);
|
||||
client.state_mut().fold(&Command::ChangeMode(mode.clone()));
|
||||
client.state_mut().fold(&Command::SetBuffer {
|
||||
name: config.buffer_key.clone(),
|
||||
value: "Profit = Revenue - Cost".into(),
|
||||
});
|
||||
let painted = client
|
||||
.model()
|
||||
.minibuffer
|
||||
.unwrap_or_else(|| panic!("{:?} must paint its minibuffer", config.buffer_key));
|
||||
assert_eq!(painted.prompt, config.prompt);
|
||||
assert_eq!(
|
||||
painted.value, "Profit = Revenue - Cost",
|
||||
"the page must draw what the user typed, not an empty line"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole model crosses as JSON, so the page needs no bindings.
|
||||
#[test]
|
||||
fn the_render_model_crosses_as_json() {
|
||||
|
||||
@@ -1070,3 +1070,69 @@ fn page_selections(app: &App) -> Vec<(String, Option<String>)> {
|
||||
.map(|cat| (cat.to_string(), view.page_selection(cat).map(String::from)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Both hosts driven into records mode the way a user gets there, so the
|
||||
/// `_Records` view and its `_Index`/`_Dim` axes are the real ones.
|
||||
fn records_pair() -> (App, App) {
|
||||
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
||||
let mut app = furnished_host();
|
||||
let mut twin = furnished_host();
|
||||
reduce_key(&mut app, r);
|
||||
reduce_key(&mut twin, r);
|
||||
(app, twin)
|
||||
}
|
||||
|
||||
/// Where the client's own projected grid puts a creating column. Asked of the
|
||||
/// projection, never re-derived from the axes — that is ADR 002's whole point.
|
||||
fn creating_cell(client: &ClientState, kind: improvise_protocol::CreateKind) -> (usize, usize) {
|
||||
for (r, row) in client.grid.cells.iter().enumerate() {
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
if cell.id.as_ref() == Some(&improvise_protocol::CellId::Create(kind)) {
|
||||
return (r, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("records mode must project a {kind:?} creating column");
|
||||
}
|
||||
|
||||
/// Put all three ends on one cell with one buffer, mid-edit.
|
||||
fn typing_at(
|
||||
app: &mut App,
|
||||
twin: &mut App,
|
||||
client: &mut ClientState,
|
||||
at: (usize, usize),
|
||||
text: &str,
|
||||
) {
|
||||
for host in [&mut *app, &mut *twin] {
|
||||
host.view_state.mode = AppMode::records_editing();
|
||||
host.view_state.cursor_mut().selected = at;
|
||||
host.view_state.buffers.insert("edit".into(), text.into());
|
||||
host.rebuild_layout();
|
||||
}
|
||||
client.view = app.view_state.clone();
|
||||
}
|
||||
|
||||
/// improvise-gcvb. The records-mode early return was broader than its reason.
|
||||
/// Why `+cat` must NOT advance: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_locally_committed_creating_column_agrees_with_the_host() {
|
||||
for (kind, typed) in [
|
||||
(improvise_protocol::CreateKind::Category, "Season"),
|
||||
(improvise_protocol::CreateKind::Measure, "Cost"),
|
||||
] {
|
||||
let (mut app, mut twin) = records_pair();
|
||||
let mut client = furnished_client(&app, (12, 12));
|
||||
let at = creating_cell(&client, kind);
|
||||
typing_at(&mut app, &mut twin, &mut client, at, typed);
|
||||
client = furnished_client(&app, (12, 12));
|
||||
client.view = app.view_state.clone();
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
|
||||
assert!(
|
||||
document_text(&app).contains(typed),
|
||||
"{kind:?}: the commit must reach the document: {}",
|
||||
document_text(&app)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Keys the page ADVERTISES, executed. Two properties: a key this client cannot
|
||||
// compose declines out loud (improvise-rmvx), and the one text-entry room the
|
||||
// browser could enter but never showed you typing in (improvise-fdt6).
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/// The shipped demo, focused, with a grid on screen. `#grid` must have focus
|
||||
/// or the page's keydown listener never sees a key and every assertion below
|
||||
/// would pass or fail for a reason that has nothing to do with resolution.
|
||||
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();
|
||||
await expect(page.locator("#grid td.editable").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("an advertised key is never silent", () => {
|
||||
// Each of these is named in the mode's own hint line, so silence read as
|
||||
// broken rather than as unavailable (gotcha 47). The subjects must differ:
|
||||
// one message reached by three keys would tell the user nothing.
|
||||
test("the three Normal-mode keys this client cannot compose each say their own subject", async ({
|
||||
page,
|
||||
}) => {
|
||||
await theDemo(page);
|
||||
|
||||
for (const [key, said] of [
|
||||
["P", "hiding empty rows and columns is not available in the browser yet"],
|
||||
["R", "records mode is not available in the browser yet"],
|
||||
[":", "the command line is not available in the browser yet"],
|
||||
]) {
|
||||
// A stale status would let the NEXT key pass on the previous key's
|
||||
// message, so each assertion is preceded by a state it is not.
|
||||
await expect(page.locator("#status")).not.toContainText(said);
|
||||
await page.keyboard.press(key);
|
||||
await expect(page.locator("#status")).toContainText(said);
|
||||
// A refusal that changed the mode would be a door opened anyway.
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a formula can be composed in the browser", () => {
|
||||
// improvise-fdt6. `commit-buffer formula` resolved here already; what did
|
||||
// not exist was any way to SEE the typing, so the room swallowed it.
|
||||
test("the formula minibuffer shows what is typed and the commit reaches the panel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await theDemo(page);
|
||||
|
||||
await expect(page.locator("#minibuffer")).toBeHidden();
|
||||
|
||||
await page.keyboard.press("F");
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await page.keyboard.press("n");
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULA");
|
||||
|
||||
// The property: every character is visible while it is being typed.
|
||||
await expect(page.locator("#minibuffer")).toBeVisible();
|
||||
await page.keyboard.type("Margin = Profit");
|
||||
await expect(page.locator("#minibuffer")).toContainText("Margin = Profit");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#minibuffer")).toBeHidden();
|
||||
// And it reached the DOCUMENT, not just the buffer: the panel is a
|
||||
// projection the host sends back.
|
||||
await expect(page.locator("#formulas")).toContainText("Margin = Profit");
|
||||
});
|
||||
});
|
||||
+12
@@ -30,6 +30,7 @@ const el = {
|
||||
views: document.getElementById("views"),
|
||||
tiles: document.getElementById("tiles"),
|
||||
mode: document.getElementById("mode"),
|
||||
minibuffer: document.getElementById("minibuffer"),
|
||||
hint: document.getElementById("hint"),
|
||||
help: document.getElementById("help"),
|
||||
status: document.getElementById("status"),
|
||||
@@ -158,6 +159,7 @@ function repaint() {
|
||||
// itself; see `RenderModel::hint`.
|
||||
el.hint.textContent = model.hint;
|
||||
paintHelp(model);
|
||||
paintMinibuffer(model);
|
||||
shell.setModel(model.status);
|
||||
el.viewName.textContent = model.view_name;
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
@@ -179,6 +181,16 @@ function repaint() {
|
||||
if (changed) sendViewport();
|
||||
}
|
||||
|
||||
/** The bottom-bar prompt. `null` is "no text-entry mode is open", decided in
|
||||
* `render.rs`, so the page asks nothing about the mode (improvise-fdt6). */
|
||||
function paintMinibuffer(model) {
|
||||
const mb = model.minibuffer;
|
||||
el.minibuffer.hidden = mb === null || mb === undefined;
|
||||
if (el.minibuffer.hidden) return;
|
||||
el.minibuffer.className = mb.color.toLowerCase();
|
||||
el.minibuffer.textContent = mb.prompt + mb.value;
|
||||
}
|
||||
|
||||
/** The help overlay. An empty `help` is what says it is closed -- the page
|
||||
* asks no question about the mode, and writes none of the content: the rows,
|
||||
* their keys and the page titles are `improvise_protocol::help`'s, the same
|
||||
|
||||
@@ -83,6 +83,13 @@
|
||||
<div id="grid" tabindex="0" aria-label="Grid"></div>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
The bottom bar. The TUI draws the minibuffer INSTEAD of the status line;
|
||||
here it is a line of its own, because #status also carries the transport's
|
||||
own failures and a repaint must not erase one (gotcha 48).
|
||||
-->
|
||||
<div id="minibuffer" role="status" hidden></div>
|
||||
|
||||
<div id="help" role="dialog" aria-label="Help" hidden></div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
|
||||
@@ -272,3 +272,21 @@ td input {
|
||||
.help-dim {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* The bottom-bar prompt while a text-entry mode is open. The colour is the
|
||||
mode's own answer (`MinibufColor`); this is the last translation, exactly
|
||||
as `draw.rs::minibuf_fg` is the TUI's. */
|
||||
#minibuffer {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: inherit;
|
||||
border-top: 1px solid var(--accent);
|
||||
}
|
||||
#minibuffer.green {
|
||||
color: #7ec699;
|
||||
}
|
||||
#minibuffer.cyan {
|
||||
color: #6fd0d8;
|
||||
}
|
||||
#minibuffer.yellow {
|
||||
color: #d8c96f;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user