Merge pull request #180 from fiddlerwoaroof/fix/help-keys-checks
Help and advertised-key checks that can see what they claim (wk18, 3t5x, 3ij0, jhdf)
This commit is contained in:
+2
-1
@@ -98,7 +98,8 @@ The message states the **shape** rather than the arity, because
|
|||||||
## 6 — the help's ex-commands were the unguarded surface
|
## 6 — the help's ex-commands were the unguarded surface
|
||||||
|
|
||||||
`help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose
|
`help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose
|
||||||
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` and
|
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` (an
|
||||||
|
ex-command only, since improvise-wk18: built by `ex!`) and
|
||||||
`HelpLine::Accent` rows are hand-written prose and had nothing checking them —
|
`HelpLine::Accent` rows are hand-written prose and had nothing checking them —
|
||||||
which is how all three documented spellings of `:formula` came to be ones the
|
which is how all three documented spellings of `:formula` came to be ones the
|
||||||
parser refuses.
|
parser refuses.
|
||||||
|
|||||||
@@ -2064,14 +2064,38 @@ nothing in the table to look up, and its hint is a hand-written tail. That is a
|
|||||||
real hole and it is where it is visible, not papered over: a keymap cannot
|
real hole and it is where it is visible, not papered over: a keymap cannot
|
||||||
describe keys a command resolves privately.
|
describe keys a command resolves privately.
|
||||||
|
|
||||||
**`gg`, `yy`, `ZZ` and the ex-commands.** A prefix sequence is two addresses and
|
**The ex-commands.** `:q`/`:w` are parsed text rather than keys, so they are
|
||||||
not one binding (gotcha 13), and `:q`/`:w` are parsed text rather than keys.
|
`HelpLine::Literal` — built only by the `ex!` macro, whose leading colon is its
|
||||||
Both are `HelpLine::Literal`, which says out loud that the row is spelled rather
|
own — and the parser, not the keymap, is what checks them
|
||||||
than derived.
|
(`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`).
|
||||||
|
|
||||||
**The captions.** `i:edit` where `i` entered records mode would still pass every
|
This paragraph used to say the same of `gg`, `yy` and `ZZ`: "a prefix sequence
|
||||||
test here. A caption is English beside a command name and only a reader judges
|
is two addresses and not one binding". Two addresses is true and was no reason
|
||||||
it — the same limit gotcha 14 recorded for the old hint test.
|
to spell them by hand — `Keymap::prefixed_keys_running` looks the pair up the
|
||||||
|
way `keys_running` looks up one key, and `keys()` asks both (improvise-wk18). A
|
||||||
|
key string with no command name was also the one row no guard could reach.
|
||||||
|
|
||||||
|
**Every row the pages ask for is visible to a guard.** The page builders take
|
||||||
|
the `keys` lookup as a parameter; `pages()` passes the real one, and
|
||||||
|
`help_bindings()` (tests only) passes one that records `(mode, command, args,
|
||||||
|
description)`. So a check over the help overlay reads the *same builders* the
|
||||||
|
overlay is drawn from, never a list kept beside them. That premise is a type,
|
||||||
|
not a convention (improvise-v29m): `HelpLine::Keys` carries a `KeyRow` whose
|
||||||
|
only constructor does the lookup, so a builder cannot type a key row the
|
||||||
|
recorder would never see; and `ex!` refuses at compile time any usage with a
|
||||||
|
bare word after the command name, so an ex row cannot smuggle a key either.
|
||||||
|
`resolve::tests::every_advertised_command_answers_in_its_own_mode` walks it
|
||||||
|
beside `advertised`, which is how the browser's black holes on the help page
|
||||||
|
became visible (improvise-uln5).
|
||||||
|
|
||||||
|
**The captions.** `i:edit` where `i` entered records mode would still pass most
|
||||||
|
tests here: a caption is English beside a command name and only a reader judges
|
||||||
|
it — the same limit gotcha 14 recorded for the old hint test. Two checks now
|
||||||
|
bite at its edges. The browser guard requires an advertised key to *act*, so a
|
||||||
|
key whose caption promises an action and which only refuses must be named
|
||||||
|
(improvise-3t5x); and `only_the_edit_key_is_captioned_as_editing_the_cell` asks
|
||||||
|
by command that no other Normal row claims to edit (improvise-jhdf). A false
|
||||||
|
caption in words neither looks for still passes.
|
||||||
|
|
||||||
### The browser half
|
### The browser half
|
||||||
|
|
||||||
|
|||||||
@@ -1458,6 +1458,17 @@ is prose, and nothing checks it against the command it documents. That is a
|
|||||||
neighbour of improvise-wk18, not the same gap — wk18 is about `literal()` rows
|
neighbour of improvise-wk18, not the same gap — wk18 is about `literal()` rows
|
||||||
whose *keys* go unchecked; here the keys were right and the words were wrong.
|
whose *keys* go unchecked; here the keys were right and the words were wrong.
|
||||||
|
|
||||||
|
**improvise-jhdf closed the paraphrase, and the paragraph above is what it
|
||||||
|
found.** Rewording Enter's caption to "Edit the cell (same as i)" — the same lie
|
||||||
|
in new words — passed both help tests. The test now asks by **command**, which
|
||||||
|
wk18's `help::help_bindings()` made possible: every Normal-mode help row whose
|
||||||
|
caption says "edit" in any case must run `edit-or-drill`. It fails on that
|
||||||
|
rival naming `(Normal, "enter-advance", None, "Edit the cell (same as i)")`.
|
||||||
|
Two controls stop it passing by looking away: `edit-or-drill` and
|
||||||
|
`enter-advance` must each still have a Normal row, or the filter has nothing to
|
||||||
|
catch. What it still cannot see is a false caption that avoids the word — "Opens
|
||||||
|
the cell" — which is the honest limit of any check over prose.
|
||||||
|
|
||||||
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
|
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
|
||||||
|
|
||||||
improvise-iioi, and it is the **same trap** as the category click one level
|
improvise-iioi, and it is the **same trap** as the category click one level
|
||||||
@@ -2071,6 +2082,50 @@ 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
|
hint; the other two are improvise-gcvb's records-cell commit, which needs
|
||||||
`record_col`.
|
`record_col`.
|
||||||
|
|
||||||
|
**improvise-3t5x and improvise-wk18 widened it again, in two directions, and the
|
||||||
|
list above is superseded by the one in the test.**
|
||||||
|
|
||||||
|
*What "answers" means.* It counted any `Local` as answered, so a lone
|
||||||
|
`SetStatus` refusal passed — which is why improvise-s8h7's false "Edit current
|
||||||
|
cell" stayed green. Injected to show it: make `edit_or_drill`'s `Record` arm
|
||||||
|
return only `SetStatus("RIVAL")`, so `i` refuses on every cell — the old guard
|
||||||
|
**passed**. The line drawn now: a caption is a promise, so an advertised key
|
||||||
|
must *act*. A refusal is legitimate — a feature a browser honestly lacks should
|
||||||
|
say so rather than go silent — but it is not what the caption says, so it has to
|
||||||
|
be **named** in the list with its reason, like a black hole. The test therefore
|
||||||
|
sorts every key into three outcomes, `Acts`, `Declines` (said something, did
|
||||||
|
nothing) and `Silent`, and lists the last two. The same injection now fails
|
||||||
|
naming `NORMAL: a/i edit-or-drill Declines`.
|
||||||
|
|
||||||
|
*Judged by what moved, not by the variant.* The first version of the new
|
||||||
|
classifier called anything but an all-status `Local` an action, and a rival that
|
||||||
|
unhooked `jump-first-row` from `composed` **passed** — because a chord's second
|
||||||
|
key always returns `Local([SetTransientPrefix(None), ..])`, the disarm, even
|
||||||
|
when nothing else happens. That is the laundering this entry already warns
|
||||||
|
about, one level down: `KeyOutcome::of` launders an empty vec into `Ignored`,
|
||||||
|
and `chord` launders a no-op into a `Local`. The disarm is now ignored when
|
||||||
|
deciding whether a key did anything, and the same rival fails naming `NORMAL:
|
||||||
|
gg jump-first-row Silent`.
|
||||||
|
|
||||||
|
*Which keys.* It read `help::advertised`, the status-bar table, and the help
|
||||||
|
overlay is a second body of advertising the browser also paints
|
||||||
|
(improvise-uln5). It now also reads `help::help_bindings()`, which records every
|
||||||
|
keys row the page builders ask for — the same builders `pages()` renders, so the
|
||||||
|
two cannot drift — including prefix sequences (`gg`, `yy`, `ZZ`), which it
|
||||||
|
presses by arming the prefix first. That is what added the five host-only
|
||||||
|
black holes (`/`, `n`, `H`, `z`, Normal's `Enter`) to the list: each was already
|
||||||
|
in `the_command_names_that_still_need_the_host_are_these`, and the help page
|
||||||
|
advertised it anyway.
|
||||||
|
|
||||||
|
*Which cells.* `furnished` stood on a record cell only, so `i` on an aggregate
|
||||||
|
(s8h7's whole subject) was outside its reach. Normal mode is now pressed on a
|
||||||
|
record, an aggregate and a derived cell, and a non-record cell is reported only
|
||||||
|
where it changes the answer, so the list does not triple. `furnished_on` also
|
||||||
|
carries a history in both directions and a yanked value, so `<`, `,` and `p`
|
||||||
|
are not refused by an empty stack — state refusals are correct behaviour and
|
||||||
|
not this test's subject, the same argument that gave `furnished` three of
|
||||||
|
everything.
|
||||||
|
|
||||||
### a_locally_committed_creating_column_agrees_with_the_host
|
### a_locally_committed_creating_column_agrees_with_the_host
|
||||||
|
|
||||||
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
|
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
|
||||||
@@ -3341,9 +3396,22 @@ over `Ctrl-R` is in `context/code/tui.md`), the test is renamed, and the second
|
|||||||
half now asserts the binding **exists** — with `view-back` kept as the control,
|
half now asserts the binding **exists** — with `view-back` kept as the control,
|
||||||
which now guards the opposite failure: a table that binds everything, or a
|
which now guards the opposite failure: a table that binds everything, or a
|
||||||
sweep that reports names it never read. A third assertion pins the key itself,
|
sweep that reports names it never read. A third assertion pins the key itself,
|
||||||
`composed(&state, ev(Char(',')), "view-forward", &[]) == [Command::ViewForward]`,
|
`resolve(&state, ev(Char(','))) == Local([Command::ViewForward])`, so the
|
||||||
so the binding cannot drift onto some other key while the presence check stays
|
binding cannot drift onto some other key while the presence check stays happy.
|
||||||
happy.
|
|
||||||
|
**That third assertion was vacuous as first written, and the paragraph above
|
||||||
|
said it was not** (improvise-3ij0). It called `composed(&state, ev(Char(',')),
|
||||||
|
"view-forward", &[])`, and `composed` dispatches on the command **name**: the
|
||||||
|
`view-forward` arm never reads the event, so the `,` was decoration and the
|
||||||
|
assertion restated the first half of the test. Rebinding `view-forward` from `,`
|
||||||
|
to `~` in `keymap.rs` left it green; the key was pinned only by
|
||||||
|
`ui::app::tests::the_view_history_walks_both_ways_from_the_keyboard`, in another
|
||||||
|
crate. It now goes through `resolve`, which looks the key up in the shared
|
||||||
|
keymap before anything reads a name — so the rival fails **here**, at this
|
||||||
|
assertion (resolve.rs, "and the key is `,`"), and the pre-fix code under the
|
||||||
|
same rival was run and passes. The general shape: a helper that takes a key
|
||||||
|
*and* a name, and dispatches on the name, cannot pin the key; only the lookup
|
||||||
|
can.
|
||||||
|
|
||||||
**The rival: bind `,` to `view-back` instead** — the misspelling the original
|
**The rival: bind `,` to `view-back` instead** — the misspelling the original
|
||||||
control was written against, and the one a copy-paste actually produces. It
|
control was written against, and the one a copy-paste actually produces. It
|
||||||
@@ -4540,3 +4608,35 @@ normal edges only, because a dev edge would unify the feature back in
|
|||||||
which fails naming that crate while the two tests above pass. Then removed from
|
which fails naming that crate while the two tests above pass. Then removed from
|
||||||
`improvise-ws-server` alone, which fails naming it. The native path runs
|
`improvise-ws-server` alone, which fails naming it. The native path runs
|
||||||
without `--target`, so the two arms are different invocations.
|
without `--target`, so the two arms are different invocations.
|
||||||
|
|
||||||
|
### resolve::tests::every_grid_move_lands_where_its_binding_says
|
||||||
|
|
||||||
|
improvise-6562, found reviewing PR #180 at 949810c, and it is the same
|
||||||
|
laundering as the disarm, one level over. `heard()` said it judged by what moved
|
||||||
|
and in fact judged by command **type**: any `Local` command besides a status or a
|
||||||
|
disarm counted as `Acts`. The fixture sat at every edge — a 2x1 grid with the
|
||||||
|
cursor at (0,0), one tile, help page 0 — so about twenty movement keys that
|
||||||
|
clamp to nothing were counted as acting. Rebinding `jump-last-col` to
|
||||||
|
`GridMove::ToStart(false)`, so `$` and `End` go to the *first* column, left the
|
||||||
|
whole workspace green.
|
||||||
|
|
||||||
|
Two changes, because either alone is not enough. `heard()` now **folds** the
|
||||||
|
outcome and compares the view slice before and after (status and disarm aside),
|
||||||
|
counting a `Command::Model` as sent and a `Request` as an act; and `furnished_on`
|
||||||
|
stands on a 3x3 grid with the cursor at (1,1), three tiles with the cursor on
|
||||||
|
the middle one, a page axis on its middle item and help page 1, so no move is
|
||||||
|
clamped by an edge. Injecting `jump-last-col` as a `SetSelected` to where the
|
||||||
|
cursor already is now fails `every_advertised_command_answers_in_its_own_mode`
|
||||||
|
naming `NORMAL: $/End jump-last-col Silent`.
|
||||||
|
|
||||||
|
**The reviewer's own rival still passes that guard, and should.** From (1,1),
|
||||||
|
`ToStart(false)` moves to (1,0) — the view moved, so the key "acts". No
|
||||||
|
did-anything-happen check can see *direction*; only an expected destination can.
|
||||||
|
This test supplies one without a second table: a `move-selection` binding's
|
||||||
|
expected landing is read off its own args (the deltas), and the four jumps are
|
||||||
|
named by what they are called. Every key that runs one is pressed from the
|
||||||
|
centre — fourteen of them, with a floor so a blind walk fails. Under the rival
|
||||||
|
it fails naming `End` and `$`. The general point: the fixture that made the
|
||||||
|
laundering invisible (the edge) is also the one that makes a wrong-direction
|
||||||
|
jump visible, so moving off the edge fixed one blind spot and opened the other,
|
||||||
|
and each needs its own question.
|
||||||
|
|||||||
@@ -1100,3 +1100,62 @@ and the text between the inner quotes leaked. It failed as
|
|||||||
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
|
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
|
||||||
this test used a C string with no inner quotes and passed against the bug,
|
this test used a C string with no inner quotes and passed against the bug,
|
||||||
because the ordinary lexing happened to blank it too.
|
because the ordinary lexing happened to blank it too.
|
||||||
|
|
||||||
|
### help::tests::no_help_row_spells_a_key_by_hand
|
||||||
|
|
||||||
|
improvise-wk18. The help pages had one kind of row whose keys the keymap never
|
||||||
|
saw: `literal("gg", …)`, `literal("yy", …)`, `literal("ZZ", …)`, and a dim tip
|
||||||
|
reading "press Tab or l/n". Every other key row comes off `keys_for`, so a
|
||||||
|
rebinding reaches it; these would have gone on saying `gg` after `g g` stopped
|
||||||
|
meaning anything, and no guard could see them because a string carries no
|
||||||
|
command name. At HEAD the test failed naming all four.
|
||||||
|
|
||||||
|
The fix is structural rather than a second scanner. The three prefix rows are
|
||||||
|
ordinary `keys(..)` rows now — `keys` also asks `Keymap::prefixed_keys_running`
|
||||||
|
for two-key sequences. The tip is **gone** rather than derived: made a `keys` row
|
||||||
|
in `ModeKey::Help`, it gave the Welcome page its one row with a description,
|
||||||
|
and `harness/tests/help.spec.js` ("? opens help, and its rows carry the keys and
|
||||||
|
the words") failed in CI on `welcome.every(row => row.description === null)` —
|
||||||
|
Welcome is prose by contract. The Help mode's own status hint already derives
|
||||||
|
`next-page` from the keymap, so the literal was a second table (improvise-rr8n's
|
||||||
|
rule: keep the one derived from the thing it describes).
|
||||||
|
`literal()` and `dim()` are gone; the only way left to build a
|
||||||
|
`HelpLine::Literal` is the `ex!` macro, which writes the leading colon itself, so
|
||||||
|
what remains hand-written is ex-command text — checked against the parser by
|
||||||
|
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`, which reads
|
||||||
|
exactly the text a user would type. This test is what stops a direct
|
||||||
|
`HelpLine::Literal { keys: "gg", .. }` coming back past the macro.
|
||||||
|
|
||||||
|
### help::tests::every_keys_row_the_pages_ask_for_finds_its_keys
|
||||||
|
|
||||||
|
A `keys` row whose lookup finds nothing is **dropped**, by design — a page never
|
||||||
|
advertises a key that does not exist. The cost of that design is that a broken
|
||||||
|
lookup looks like a shorter page: `every_help_page_key_row_names_keys_that_exist`
|
||||||
|
only checks the rows that survived, plus a floor of twenty. So this asks the
|
||||||
|
question from the other end, over `help_bindings()` — every row the builders
|
||||||
|
*asked for* — and requires each to find a key.
|
||||||
|
|
||||||
|
The injection: comment out the `prefixed_labels` extension in `keys`. The three
|
||||||
|
prefix rows vanish from the page, every other help test stays green, and this
|
||||||
|
fails naming `Normal jump-first-row`, `Normal yank` and `Normal wq`.
|
||||||
|
|
||||||
|
### help::tests::an_ex_row_can_name_placeholders_and_nothing_else
|
||||||
|
|
||||||
|
improvise-v29m, found reviewing PR #180 at 949810c. `no_help_row_spells_a_key_by_hand`
|
||||||
|
read only `HelpLine::Literal`, and its premise — that `keys()` is the only way to
|
||||||
|
make a key row — was a convention: `HelpLine::Keys { keys: Vec<String>, .. }`
|
||||||
|
was a public variant with public fields, and `help_bindings()` records only what
|
||||||
|
the builders *ask* for, so `HelpLine::Keys { keys: vec!["Q"], description: "Quit
|
||||||
|
instantly" }` typed straight into `panels()`, with `Q` bound to nothing, passed
|
||||||
|
the whole workspace. So did `ex!("q or press Q", ..)`: the parser test splits on
|
||||||
|
colons and `parse_line` accepts trailing words.
|
||||||
|
|
||||||
|
Both are now **compile errors**, which is why this test is not where the guard
|
||||||
|
lives. `HelpLine::Keys` carries a `KeyRow` whose fields are private to a module
|
||||||
|
whose one constructor, `KeyRow::looked_up`, does the keymap lookup; the injected
|
||||||
|
row fails with E0559/E0451. `ex!` evaluates `ex_usage_is_placeholders` in a
|
||||||
|
`const` item, and the injected usage fails with `evaluation panicked: assertion
|
||||||
|
failed: ex_usage_is_placeholders("q or press Q")`. The rule cost one row:
|
||||||
|
`:add-items <cat> a b c ...` had three bare words and is now
|
||||||
|
`:add-items <cat> <item> ...`. This test pins the rule the macro asks, in both
|
||||||
|
directions, including that shipped usage and the rival.
|
||||||
|
|||||||
@@ -304,14 +304,11 @@ pub enum HelpLine {
|
|||||||
Heading(&'static str),
|
Heading(&'static str),
|
||||||
Text(&'static str),
|
Text(&'static str),
|
||||||
Accent(&'static str),
|
Accent(&'static str),
|
||||||
/// A keys-and-description row whose keys came off the keymap, so it
|
/// A keys-and-description row whose keys came off the keymap. A [`KeyRow`]
|
||||||
/// cannot go stale against the bindings it documents.
|
/// has no public constructor, so no builder can type one (improvise-v29m).
|
||||||
Keys {
|
Keys(KeyRow),
|
||||||
keys: Vec<String>,
|
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
|
||||||
description: &'static str,
|
/// colon is what stops a key being typed here (improvise-wk18).
|
||||||
},
|
|
||||||
/// A row naming something the keymap does not own: an ex-command, or a
|
|
||||||
/// prefix sequence, which is two addresses and not one binding.
|
|
||||||
Literal {
|
Literal {
|
||||||
keys: &'static str,
|
keys: &'static str,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
@@ -326,38 +323,125 @@ pub struct HelpPage {
|
|||||||
pub lines: Vec<HelpLine>,
|
pub lines: Vec<HelpLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A keys row, looked up in `mode`. An unbound command yields no keys and the
|
pub use key_row::KeyRow;
|
||||||
/// row is dropped, so the page never advertises a key that does not exist.
|
|
||||||
|
/// The one door to a keys row: its fields are private to this module, and its
|
||||||
|
/// only constructor looks the keys up.
|
||||||
|
mod key_row {
|
||||||
|
use super::{ModeKey, key_label, keys_for, prefixed_labels};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct KeyRow {
|
||||||
|
keys: Vec<String>,
|
||||||
|
description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyRow {
|
||||||
|
pub fn keys(&self) -> &[String] {
|
||||||
|
&self.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unbound command yields no keys and no row, so the page never
|
||||||
|
/// advertises a key that does not exist.
|
||||||
|
pub(super) fn looked_up(
|
||||||
|
mode: &ModeKey,
|
||||||
|
command: &str,
|
||||||
|
args: Option<&[String]>,
|
||||||
|
description: &'static str,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let mut keys: Vec<String> = keys_for(mode, command, args)
|
||||||
|
.iter()
|
||||||
|
.map(|(c, m)| key_label(c, *m))
|
||||||
|
.collect();
|
||||||
|
keys.extend(prefixed_labels(mode, command, args));
|
||||||
|
(!keys.is_empty()).then_some(Self { keys, description })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keys row, looked up in `mode`.
|
||||||
fn keys(
|
fn keys(
|
||||||
mode: &ModeKey,
|
mode: &ModeKey,
|
||||||
command: &str,
|
command: &'static str,
|
||||||
args: Option<&[&str]>,
|
args: Option<&[&str]>,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
) -> Option<HelpLine> {
|
) -> Option<HelpLine> {
|
||||||
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||||
let found = keys_for(mode, command, args.as_deref());
|
KeyRow::looked_up(mode, command, args.as_deref(), description).map(HelpLine::Keys)
|
||||||
match found.is_empty() {
|
|
||||||
true => None,
|
|
||||||
false => Some(HelpLine::Keys {
|
|
||||||
keys: found.iter().map(|(c, m)| key_label(c, *m)).collect(),
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn literal(keys: &'static str, description: &'static str) -> HelpLine {
|
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
|
||||||
HelpLine::Literal {
|
fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Vec<String> {
|
||||||
keys,
|
let mut labels: Vec<String> = match Keymaps::shared().get(mode) {
|
||||||
description,
|
Some(keymap) => keymap.prefixed_keys_running(command, args),
|
||||||
dim: false,
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
|
.iter()
|
||||||
|
.map(|[(c1, m1), (c2, m2)]| format!("{}{}", key_label(c1, *m1), key_label(c2, *m2)))
|
||||||
|
.collect();
|
||||||
|
labels.sort();
|
||||||
|
labels.dedup();
|
||||||
|
labels
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dim(keys: &'static str, description: &'static str) -> HelpLine {
|
/// How a page asks for a keys row. `pages` answers with [`keys`]; a test
|
||||||
HelpLine::Literal {
|
/// answers by recording the question, so a guard reads commands, not text.
|
||||||
keys,
|
type KeysRow<'a> =
|
||||||
description,
|
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
|
||||||
dim: true,
|
|
||||||
|
/// An ex-command row. The colon is the macro's, and every word after the
|
||||||
|
/// command's name must be a placeholder, checked when the page compiles.
|
||||||
|
macro_rules! ex {
|
||||||
|
($text:literal, $description:expr $(,)?) => {{
|
||||||
|
const _: () = assert!(ex_usage_is_placeholders($text));
|
||||||
|
HelpLine::Literal {
|
||||||
|
keys: concat!(":", $text),
|
||||||
|
description: $description,
|
||||||
|
dim: false,
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
(dim $text:literal, $description:expr $(,)?) => {{
|
||||||
|
const _: () = assert!(ex_usage_is_placeholders($text));
|
||||||
|
HelpLine::Literal {
|
||||||
|
keys: concat!(" :", $text),
|
||||||
|
description: $description,
|
||||||
|
dim: true,
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the name, every word opens with `<`, `[` or `"`, or is `...`: so an
|
||||||
|
/// ex row cannot also advertise a key ("q or press Q"). Quotes span spaces.
|
||||||
|
const fn ex_usage_is_placeholders(text: &str) -> bool {
|
||||||
|
let b = text.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < b.len() && b[i] != b' ' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
while i < b.len() && b[i] == b' ' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i == b.len() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let start = i;
|
||||||
|
let mut quoted = false;
|
||||||
|
while i < b.len() && (quoted || b[i] != b' ') {
|
||||||
|
if b[i] == b'"' {
|
||||||
|
quoted = !quoted;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let ellipsis =
|
||||||
|
i - start == 3 && b[start] == b'.' && b[start + 1] == b'.' && b[start + 2] == b'.';
|
||||||
|
if !(ellipsis || b[start] == b'<' || b[start] == b'[' || b[start] == b'"') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,30 +451,48 @@ pub fn pages() -> Vec<HelpPage> {
|
|||||||
let pages = vec![
|
let pages = vec![
|
||||||
HelpPage {
|
HelpPage {
|
||||||
title: "Welcome",
|
title: "Welcome",
|
||||||
lines: welcome(),
|
lines: welcome(&keys),
|
||||||
},
|
},
|
||||||
HelpPage {
|
HelpPage {
|
||||||
title: "Navigation",
|
title: "Navigation",
|
||||||
lines: navigation(),
|
lines: navigation(&keys),
|
||||||
},
|
},
|
||||||
HelpPage {
|
HelpPage {
|
||||||
title: "Editing",
|
title: "Editing",
|
||||||
lines: editing(),
|
lines: editing(&keys),
|
||||||
},
|
},
|
||||||
HelpPage {
|
HelpPage {
|
||||||
title: "Panels & Views",
|
title: "Panels & Views",
|
||||||
lines: panels(),
|
lines: panels(&keys),
|
||||||
},
|
},
|
||||||
HelpPage {
|
HelpPage {
|
||||||
title: "Commands",
|
title: "Commands",
|
||||||
lines: commands(),
|
lines: commands(&keys),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
|
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
|
||||||
pages
|
pages
|
||||||
}
|
}
|
||||||
|
|
||||||
fn welcome() -> Vec<HelpLine> {
|
/// Every keys row the pages ask for, as `(mode, command, args, description)`
|
||||||
|
/// — the same builders, so a guard over it cannot drift from what is drawn.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn help_bindings() -> Vec<(ModeKey, &'static str, Option<Vec<String>>, &'static str)> {
|
||||||
|
let asked = std::cell::RefCell::new(Vec::new());
|
||||||
|
let record: KeysRow = &|mode, command, args, description| {
|
||||||
|
let args = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||||
|
asked
|
||||||
|
.borrow_mut()
|
||||||
|
.push((mode.clone(), command, args, description));
|
||||||
|
None
|
||||||
|
};
|
||||||
|
for build in [welcome, navigation, editing, panels, commands] {
|
||||||
|
build(record);
|
||||||
|
}
|
||||||
|
asked.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn welcome(_: KeysRow) -> Vec<HelpLine> {
|
||||||
vec![
|
vec![
|
||||||
HelpLine::Blank,
|
HelpLine::Blank,
|
||||||
HelpLine::Banner(" improvise"),
|
HelpLine::Banner(" improvise"),
|
||||||
@@ -427,12 +529,10 @@ fn welcome() -> Vec<HelpLine> {
|
|||||||
),
|
),
|
||||||
HelpLine::Text(" Tile The row/column/page assignment of a category."),
|
HelpLine::Text(" Tile The row/column/page assignment of a category."),
|
||||||
HelpLine::Text(" Formula A computed item: derives its value from other items."),
|
HelpLine::Text(" Formula A computed item: derives its value from other items."),
|
||||||
HelpLine::Blank,
|
|
||||||
dim("Tip: press Tab or l/n to go to the next page.", ""),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn navigation() -> Vec<HelpLine> {
|
fn navigation(keys: KeysRow) -> Vec<HelpLine> {
|
||||||
let n = &ModeKey::Normal;
|
let n = &ModeKey::Normal;
|
||||||
[
|
[
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
@@ -445,7 +545,7 @@ fn navigation() -> Vec<HelpLine> {
|
|||||||
None,
|
None,
|
||||||
"Move down, wrapping to the next column",
|
"Move down, wrapping to the next column",
|
||||||
),
|
),
|
||||||
Some(literal("gg", "Jump to first row")),
|
keys(n, "jump-first-row", None, "Jump to first row"),
|
||||||
keys(n, "jump-last-row", None, "Jump to last row"),
|
keys(n, "jump-last-row", None, "Jump to last row"),
|
||||||
keys(n, "jump-first-col", None, "Jump to first column"),
|
keys(n, "jump-first-col", None, "Jump to first column"),
|
||||||
keys(n, "jump-last-col", None, "Jump to last column"),
|
keys(n, "jump-last-col", None, "Jump to last column"),
|
||||||
@@ -512,7 +612,7 @@ fn navigation() -> Vec<HelpLine> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn editing() -> Vec<HelpLine> {
|
fn editing(keys: KeysRow) -> Vec<HelpLine> {
|
||||||
let n = &ModeKey::Normal;
|
let n = &ModeKey::Normal;
|
||||||
let e = &ModeKey::Editing;
|
let e = &ModeKey::Editing;
|
||||||
[
|
[
|
||||||
@@ -555,7 +655,7 @@ fn editing() -> Vec<HelpLine> {
|
|||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Copy and paste")),
|
Some(HelpLine::Heading("Copy and paste")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal("yy", "Yank (copy) the current cell value")),
|
keys(n, "yank", None, "Yank (copy) the current cell value"),
|
||||||
keys(
|
keys(
|
||||||
n,
|
n,
|
||||||
"paste",
|
"paste",
|
||||||
@@ -593,7 +693,7 @@ fn editing() -> Vec<HelpLine> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panels() -> Vec<HelpLine> {
|
fn panels(keys: KeysRow) -> Vec<HelpLine> {
|
||||||
let n = &ModeKey::Normal;
|
let n = &ModeKey::Normal;
|
||||||
[
|
[
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
@@ -741,14 +841,14 @@ fn panels() -> Vec<HelpLine> {
|
|||||||
"Toggle collapse of nearest group above cursor",
|
"Toggle collapse of nearest group above cursor",
|
||||||
),
|
),
|
||||||
keys(n, "hide-selected-row-item", None, "Hide current row item"),
|
keys(n, "hide-selected-row-item", None, "Hide current row item"),
|
||||||
Some(dim(" :show-item <cat> <item>", "Restore a hidden item")),
|
Some(ex!(dim "show-item <cat> <item>", "Restore a hidden item")),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commands() -> Vec<HelpLine> {
|
fn commands(keys: KeysRow) -> Vec<HelpLine> {
|
||||||
let n = &ModeKey::Normal;
|
let n = &ModeKey::Normal;
|
||||||
[
|
[
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
@@ -763,55 +863,40 @@ fn commands() -> Vec<HelpLine> {
|
|||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("File operations")),
|
Some(HelpLine::Heading("File operations")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal(
|
Some(ex!("w [path]", "Save (path optional after first save)",)),
|
||||||
":w [path]",
|
Some(ex!("wq", "Save and quit")),
|
||||||
"Save (path optional after first save)",
|
Some(ex!("q", "Quit (warns if unsaved changes)")),
|
||||||
)),
|
Some(ex!("q!", "Force quit without saving")),
|
||||||
Some(literal(":wq", "Save and quit")),
|
keys(n, "wq", None, "Save and quit (same as :wq)"),
|
||||||
Some(literal(":q", "Quit (warns if unsaved changes)")),
|
|
||||||
Some(literal(":q!", "Force quit without saving")),
|
|
||||||
Some(literal("ZZ", "Save and quit (same as :wq)")),
|
|
||||||
keys(n, "save", None, "Save (same as :w)"),
|
keys(n, "save", None, "Save (same as :w)"),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Import and export")),
|
Some(HelpLine::Heading("Import and export")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal(":import <path>", "Open the JSON/CSV import wizard")),
|
Some(ex!("import <path>", "Open the JSON/CSV import wizard")),
|
||||||
Some(literal(
|
Some(ex!("export [path.csv]", "Export the active view to CSV",)),
|
||||||
":export [path.csv]",
|
|
||||||
"Export the active view to CSV",
|
|
||||||
)),
|
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Model building")),
|
Some(HelpLine::Heading("Model building")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal(":add-cat <name>", "Add a new category")),
|
Some(ex!("add-cat <name>", "Add a new category")),
|
||||||
Some(literal(
|
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
|
||||||
":add-item <cat> <item>",
|
Some(ex!(
|
||||||
"Add one item to a category",
|
"add-items <cat> <item> ...",
|
||||||
)),
|
|
||||||
Some(literal(
|
|
||||||
":add-items <cat> a b c ...",
|
|
||||||
"Add multiple items at once",
|
"Add multiple items at once",
|
||||||
)),
|
)),
|
||||||
Some(literal(
|
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
|
||||||
":formula <cat> \"<Name> = <expr>\"",
|
|
||||||
"Add a formula",
|
|
||||||
)),
|
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Views")),
|
Some(HelpLine::Heading("Views")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal(":add-view [name]", "Create a new view")),
|
Some(ex!("add-view [name]", "Create a new view")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Display")),
|
Some(HelpLine::Heading("Display")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(literal(
|
Some(ex!("set-format <fmt>", "Set number format (e.g. ',.2')",)),
|
||||||
":set-format <fmt>",
|
|
||||||
"Set number format (e.g. ',.2')",
|
|
||||||
)),
|
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
Some(HelpLine::Heading("Other keys")),
|
Some(HelpLine::Heading("Other keys")),
|
||||||
Some(HelpLine::Blank),
|
Some(HelpLine::Blank),
|
||||||
keys(n, "enter-mode", Some(&["help"]), "Open this help screen"),
|
keys(n, "enter-mode", Some(&["help"]), "Open this help screen"),
|
||||||
Some(literal(":help", "Open this help screen")),
|
Some(ex!("help", "Open this help screen")),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -884,8 +969,12 @@ mod tests {
|
|||||||
for page in &pages {
|
for page in &pages {
|
||||||
assert!(!page.title.is_empty());
|
assert!(!page.title.is_empty());
|
||||||
for line in &page.lines {
|
for line in &page.lines {
|
||||||
if let HelpLine::Keys { keys, description } = line {
|
if let HelpLine::Keys(row) = line {
|
||||||
assert!(!keys.is_empty(), "{description} resolved to no keys");
|
assert!(
|
||||||
|
!row.keys().is_empty(),
|
||||||
|
"{} resolved to no keys",
|
||||||
|
row.description()
|
||||||
|
);
|
||||||
rows += 1;
|
rows += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -896,6 +985,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// improvise-wk18: a key the pages name is looked up, never typed. The only
|
||||||
|
/// hand-written row left is an ex-command. See `context/test-evidence/protocol.md`.
|
||||||
|
#[test]
|
||||||
|
fn no_help_row_spells_a_key_by_hand() {
|
||||||
|
let spelled: Vec<&str> = pages()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|page| page.lines.iter())
|
||||||
|
.filter_map(|line| match line {
|
||||||
|
HelpLine::Literal { keys, .. } => Some(*keys),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|keys| !keys.trim_start().starts_with(':'))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
spelled.is_empty(),
|
||||||
|
"these rows name keys the keymap never saw: {spelled:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// improvise-v29m: `ex!` refuses a usage with a bare word in it at compile
|
||||||
|
/// time; this pins the rule it asks. See `context/test-evidence/protocol.md`.
|
||||||
|
#[test]
|
||||||
|
fn an_ex_row_can_name_placeholders_and_nothing_else() {
|
||||||
|
for usage in [
|
||||||
|
"w [path]",
|
||||||
|
"q!",
|
||||||
|
"formula <cat> \"<Name> = <expr>\"",
|
||||||
|
"add-items <cat> <item> ...",
|
||||||
|
] {
|
||||||
|
assert!(ex_usage_is_placeholders(usage), "{usage} is refused");
|
||||||
|
}
|
||||||
|
for usage in ["q or press Q", "add-items <cat> a b c", "help ?"] {
|
||||||
|
assert!(!ex_usage_is_placeholders(usage), "{usage} is accepted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row whose lookup finds no key is dropped silently, so a broken lookup
|
||||||
|
/// looks like a shorter page. See `context/test-evidence/protocol.md`.
|
||||||
|
#[test]
|
||||||
|
fn every_keys_row_the_pages_ask_for_finds_its_keys() {
|
||||||
|
let asked = help_bindings();
|
||||||
|
assert!(asked.len() > 20, "only {} rows asked", asked.len());
|
||||||
|
let dropped: Vec<String> = asked
|
||||||
|
.iter()
|
||||||
|
.filter(|(mode, command, args, description)| {
|
||||||
|
let args: Option<Vec<&str>> = args
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.iter().map(String::as_str).collect());
|
||||||
|
keys(mode, command, args.as_deref(), description).is_none()
|
||||||
|
})
|
||||||
|
.map(|(mode, command, _, _)| format!("{mode:?} {command}"))
|
||||||
|
.collect();
|
||||||
|
assert!(dropped.is_empty(), "rows with no key: {dropped:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// `compact`'s three shapes, including the one no shipped mode reaches:
|
/// `compact`'s three shapes, including the one no shipped mode reaches:
|
||||||
/// keys that are all chords leave nothing to draw.
|
/// keys that are all chords leave nothing to draw.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -966,7 +1110,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|page| page.lines)
|
.flat_map(|page| page.lines)
|
||||||
.filter_map(|line| match line {
|
.filter_map(|line| match line {
|
||||||
HelpLine::Keys { keys, description } => Some((keys, description)),
|
HelpLine::Keys(row) => Some((row.keys().to_vec(), row.description())),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -998,16 +1142,26 @@ mod tests {
|
|||||||
/// See `context/test-evidence/clients.md`.
|
/// See `context/test-evidence/clients.md`.
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_edit_key_is_captioned_as_editing_the_cell() {
|
fn only_the_edit_key_is_captioned_as_editing_the_cell() {
|
||||||
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
|
let normal: Vec<_> = help_bindings()
|
||||||
let claims: Vec<_> = rows()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, d)| d.contains("Edit current cell"))
|
.filter(|(mode, ..)| *mode == ModeKey::Normal)
|
||||||
.collect();
|
.collect();
|
||||||
assert!(!claims.is_empty(), "the check needs a row to check");
|
// By COMMAND, not phrase (improvise-jhdf): any wording of "edit" on a
|
||||||
for (keys, description) in claims {
|
// Normal row that is not edit-or-drill's is the lie s8h7 removed.
|
||||||
|
let liars: Vec<_> = normal
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, command, _, d)| {
|
||||||
|
d.to_lowercase().contains("edit") && *command != "edit-or-drill"
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
liars.is_empty(),
|
||||||
|
"captioned as editing but do not: {liars:?}"
|
||||||
|
);
|
||||||
|
for command in ["edit-or-drill", "enter-advance"] {
|
||||||
assert!(
|
assert!(
|
||||||
keys.iter().all(|k| edit.contains(k)),
|
normal.iter().any(|(_, c, ..)| *c == command),
|
||||||
"{keys:?} is captioned {description:?} but only {edit:?} edit"
|
"{command} has no Normal help row, so the check above cannot see it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,8 +1178,8 @@ mod tests {
|
|||||||
println!("\n=== {} ===", page.title);
|
println!("\n=== {} ===", page.title);
|
||||||
for line in &page.lines {
|
for line in &page.lines {
|
||||||
match line {
|
match line {
|
||||||
HelpLine::Keys { keys, description } => {
|
HelpLine::Keys(row) => {
|
||||||
println!(" {:<28}{description}", keys.join(" "))
|
println!(" {:<28}{}", row.keys().join(" "), row.description())
|
||||||
}
|
}
|
||||||
HelpLine::Literal {
|
HelpLine::Literal {
|
||||||
keys, description, ..
|
keys, description, ..
|
||||||
|
|||||||
@@ -235,6 +235,32 @@ impl Keymap {
|
|||||||
keys
|
keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every two-key sequence here or in a parent that runs `command`: a
|
||||||
|
/// prefix key, then a key of the sub-keymap it opens (flat, gotcha 13).
|
||||||
|
pub fn prefixed_keys_running(
|
||||||
|
&self,
|
||||||
|
command: &str,
|
||||||
|
args: Option<&[String]>,
|
||||||
|
) -> Vec<[(KeyCode, KeyModifiers); 2]> {
|
||||||
|
let mut keys: Vec<[(KeyCode, KeyModifiers); 2]> = self
|
||||||
|
.bindings
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(pattern, binding)| match (pattern, binding) {
|
||||||
|
(KeyPattern::Key(code, mods), Binding::Prefix(sub)) => Some((*code, *mods, sub)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.flat_map(|(code, mods, sub)| {
|
||||||
|
sub.keys_running(command, args)
|
||||||
|
.into_iter()
|
||||||
|
.map(move |second| [(code, mods), second])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(parent) = &self.parent {
|
||||||
|
keys.extend(parent.prefixed_keys_running(command, args));
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up the binding for a key in this keymap's own bindings.
|
/// Look up the binding for a key in this keymap's own bindings.
|
||||||
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
||||||
self.bindings
|
self.bindings
|
||||||
|
|||||||
@@ -2044,18 +2044,60 @@ mod tests {
|
|||||||
|
|
||||||
// ── what is bound and what is not ───────────────────────────────
|
// ── what is bound and what is not ───────────────────────────────
|
||||||
|
|
||||||
|
/// A three-by-three grid with `id` at (1,1) and the cursor on it: no move
|
||||||
|
/// from there is clamped by an edge (improvise-6562).
|
||||||
|
fn centred_on(id: CellId) -> ClientState {
|
||||||
|
let mut centre = CellDisplay::number("1200");
|
||||||
|
centre.id = Some(id);
|
||||||
|
let mut cells: Vec<_> = (0..3)
|
||||||
|
.flat_map(|r| (0..3).map(move |c| ((r, c), CellDisplay::number("7"))))
|
||||||
|
.collect();
|
||||||
|
cells[4].1 = centre;
|
||||||
|
let mut client = ClientState::default();
|
||||||
|
client.bootstrap(Snapshot {
|
||||||
|
view: ViewState::default(),
|
||||||
|
seed: vec![
|
||||||
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
||||||
|
"North", "South", "East",
|
||||||
|
]))),
|
||||||
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
||||||
|
"Revenue", "Cost", "Profit",
|
||||||
|
]))),
|
||||||
|
Projection::Grid(GridUpdate::Cells(cells)),
|
||||||
|
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 3 })),
|
||||||
|
Projection::Basis(published()),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
client.window = (3, 3);
|
||||||
|
client.view.cursor_mut().selected = (1, 1);
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
/// A client whose every widget has something in it, so an `Upstream` names
|
/// 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).
|
/// an **unhandled command** and never a state that said no (improvise-rmvx).
|
||||||
fn furnished(mode: AppMode) -> ClientState {
|
fn furnished(mode: AppMode) -> ClientState {
|
||||||
let mut state = client_on(record_cell());
|
furnished_on(mode, record_cell())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `furnished`, with `cell` under the cursor. A history in both directions
|
||||||
|
/// and a yanked value, so no `Cmd` is refused by an empty stack (3t5x).
|
||||||
|
fn furnished_on(mode: AppMode, cell: CellId) -> ClientState {
|
||||||
|
let mut state = centred_on(cell);
|
||||||
|
let frame = |name: &str| crate::ViewFrame {
|
||||||
|
view_name: name.into(),
|
||||||
|
mode: AppMode::Normal,
|
||||||
|
};
|
||||||
|
state.view.view_back_stack = vec![frame("Second")];
|
||||||
|
state.view.view_forward_stack = vec![frame("Third")];
|
||||||
|
state.view.yanked = Some(CellValue::Number(5.0));
|
||||||
// A records MODE over a non-records GRID is a state no host can
|
// A records MODE over a non-records GRID is a state no host can
|
||||||
// produce, so the fixture projects both (improvise-e5pf).
|
// produce, so the fixture projects both (improvise-e5pf).
|
||||||
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
||||||
state.view.mode = mode;
|
state.view.mode = mode;
|
||||||
state.grid.page_coords = vec![crate::PageAxis {
|
state.grid.page_coords = vec![crate::PageAxis {
|
||||||
cat: "Quarter".into(),
|
cat: "Quarter".into(),
|
||||||
selected: "Q1".into(),
|
selected: "Q2".into(),
|
||||||
items: Some(vec!["Q1".into(), "Q2".into()]),
|
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
|
||||||
}];
|
}];
|
||||||
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
|
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
|
||||||
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
|
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
|
||||||
@@ -2087,10 +2129,15 @@ mod tests {
|
|||||||
axes: Vec::new(),
|
axes: Vec::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
state.tiles.tiles = vec![crate::TileEntry {
|
state.tiles.tiles = ["Region", "Product", "Quarter"]
|
||||||
category: "Region".into(),
|
.into_iter()
|
||||||
axis: Axis::Row,
|
.map(|category| crate::TileEntry {
|
||||||
}];
|
category: category.into(),
|
||||||
|
axis: Axis::Row,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state.view.tile_cat_idx = 1;
|
||||||
|
state.view.help_page = 1;
|
||||||
state.view.cat_panel_cursor = 1;
|
state.view.cat_panel_cursor = 1;
|
||||||
state.view.formula_cursor = 1;
|
state.view.formula_cursor = 1;
|
||||||
state.view.view_panel_cursor = 1;
|
state.view.view_panel_cursor = 1;
|
||||||
@@ -2107,59 +2154,255 @@ mod tests {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// improvise-rmvx. An advertised key that resolves upstream is a black hole
|
/// What a user sees an advertised key do. A status alone is `Declines`:
|
||||||
/// (gotcha 47). See `context/test-evidence/clients.md`.
|
/// honest, but not what the caption promised (improvise-3t5x).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum Heard {
|
||||||
|
Acts,
|
||||||
|
Declines,
|
||||||
|
Silent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Judged by what the fold MOVED, never by a command's type: a clamped move
|
||||||
|
/// or a lone disarm changes nothing (improvise-6562). `Model` is sent.
|
||||||
|
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
|
||||||
|
let commands = match outcome {
|
||||||
|
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
|
||||||
|
KeyOutcome::Request(_) => return Heard::Acts,
|
||||||
|
KeyOutcome::Local(commands) => commands,
|
||||||
|
};
|
||||||
|
let quiet = |view: &ViewState| ViewState {
|
||||||
|
status_msg: String::new(),
|
||||||
|
transient_prefix: None,
|
||||||
|
..view.clone()
|
||||||
|
};
|
||||||
|
let before = quiet(&state.view);
|
||||||
|
commands.iter().for_each(|c| state.fold(c));
|
||||||
|
let sent = commands.iter().any(|c| matches!(c, Command::Model { .. }));
|
||||||
|
let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_)));
|
||||||
|
match (sent || quiet(&state.view) != before, said) {
|
||||||
|
(true, _) => Heard::Acts,
|
||||||
|
(false, true) => Heard::Declines,
|
||||||
|
(false, false) => Heard::Silent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `(mode, search, command, args)` either table advertises: the
|
||||||
|
/// status-bar hint and the help pages (improvise-wk18).
|
||||||
|
fn advertised_everywhere() -> Vec<(AppMode, bool, &'static str, Option<Vec<String>>)> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for mode in AppMode::all() {
|
||||||
|
for (command, args, _) in crate::help::advertised(&mode) {
|
||||||
|
rows.push((mode.clone(), false, command, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, command, args, _) in crate::help::help_bindings() {
|
||||||
|
let (mode, search) = match key {
|
||||||
|
ModeKey::SearchMode => (AppMode::Normal, true),
|
||||||
|
key => match AppMode::all()
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(&key))
|
||||||
|
{
|
||||||
|
Some(mode) => (mode, false),
|
||||||
|
None => panic!("a help row names {key:?}, which no mode reaches"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rows.push((mode, search, command, args));
|
||||||
|
}
|
||||||
|
rows.sort_by_key(|(m, s, c, a)| (crate::mode_label(m), *s, *c, a.clone()));
|
||||||
|
rows.dedup();
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every key sequence that runs `command` in `keymap`: one key, or a
|
||||||
|
/// prefix and the key after it.
|
||||||
|
fn sequences_running(
|
||||||
|
keymap: &Keymap,
|
||||||
|
command: &str,
|
||||||
|
args: Option<&[String]>,
|
||||||
|
) -> Vec<Vec<KeyEvent>> {
|
||||||
|
let one = keymap
|
||||||
|
.keys_running(command, args)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(code, mods)| vec![KeyEvent::new(code, mods)]);
|
||||||
|
let two = keymap
|
||||||
|
.prefixed_keys_running(command, args)
|
||||||
|
.into_iter()
|
||||||
|
.map(|pair| pair.map(|(code, mods)| KeyEvent::new(code, mods)).to_vec());
|
||||||
|
one.chain(two).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What pressing `sequence` in `state` does, arming any prefix first.
|
||||||
|
fn press(mut state: ClientState, sequence: &[KeyEvent]) -> Heard {
|
||||||
|
let (last, prefix) = sequence.split_last().expect("a sequence has a key");
|
||||||
|
for key in prefix {
|
||||||
|
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
|
||||||
|
commands.iter().for_each(|c| state.fold(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outcome = resolve(&state, *last);
|
||||||
|
heard(&mut state, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or
|
||||||
|
/// is named below saying why not. See `context/test-evidence/clients.md`.
|
||||||
#[test]
|
#[test]
|
||||||
fn every_advertised_command_answers_in_its_own_mode() {
|
fn every_advertised_command_answers_in_its_own_mode() {
|
||||||
let mut unanswered = Vec::new();
|
let aggregate = CellId::Aggregate(
|
||||||
for mode in AppMode::all() {
|
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
||||||
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
|
);
|
||||||
continue;
|
let derived = CellId::Derived {
|
||||||
|
target: "Profit".into(),
|
||||||
|
};
|
||||||
|
// (mode, cell, command, heard) -> the keys that heard it.
|
||||||
|
let mut short: std::collections::BTreeMap<(String, &str, Heard), Vec<String>> =
|
||||||
|
Default::default();
|
||||||
|
for (mode, search, command, args) in advertised_everywhere() {
|
||||||
|
let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode");
|
||||||
|
let keymap = Keymaps::shared().get(&key).expect("a keymap");
|
||||||
|
let at = |cell: &CellId| {
|
||||||
|
let mut state = furnished_on(mode.clone(), cell.clone());
|
||||||
|
state.view.search_mode = search;
|
||||||
|
state
|
||||||
};
|
};
|
||||||
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
|
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
|
||||||
continue;
|
for sequence in sequences_running(keymap, command, args.as_deref()) {
|
||||||
};
|
let keys: String = sequence
|
||||||
let state = furnished(mode.clone());
|
.iter()
|
||||||
for (command, args, caption) in crate::help::advertised(&mode) {
|
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
|
||||||
for (code, mods) in keymap.keys_running(command, args.as_deref()) {
|
.collect();
|
||||||
let event = KeyEvent::new(code, mods);
|
let on_record = press(at(&record_cell()), &sequence);
|
||||||
// BOTH silent variants. To a user `Ignored` is the same
|
let mut report = vec![(label.clone(), on_record)];
|
||||||
// black hole as `Upstream` — nothing is on the screen.
|
// Only a grid mode stands on another kind of cell, and a cell
|
||||||
let answered = !matches!(
|
// matters only where it changes the answer.
|
||||||
resolve(&state, event),
|
if mode == AppMode::Normal {
|
||||||
KeyOutcome::Upstream(_) | KeyOutcome::Ignored
|
for (on, cell) in [
|
||||||
);
|
(" on an aggregate", &aggregate),
|
||||||
if !answered {
|
(" on a derived cell", &derived),
|
||||||
unanswered.push(format!(
|
] {
|
||||||
"{}: {:?} → `{command} {args:?}` ({caption})",
|
let got = press(at(cell), &sequence);
|
||||||
crate::mode_label(&mode),
|
if got != on_record {
|
||||||
code
|
report.push((label.clone() + on, got));
|
||||||
));
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) {
|
||||||
|
short
|
||||||
|
.entry((where_, command, got))
|
||||||
|
.or_default()
|
||||||
|
.push(keys.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unanswered.sort();
|
let short: Vec<String> = short
|
||||||
unanswered.dedup();
|
.into_iter()
|
||||||
// The five that remain, each with the reason it is not a black hole.
|
.map(|((at, command, got), mut keys)| {
|
||||||
// Closing one has to come here and say so; opening a sixth fails.
|
keys.sort();
|
||||||
let expected = [
|
keys.dedup();
|
||||||
|
format!("{at}: {} {command} {got:?}", keys.join("/"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Closing one has to come here and say so; adding one fails. A caption
|
||||||
|
// is a promise, and each line here is a promise a browser does not keep.
|
||||||
|
let expected: &[&str] = &[
|
||||||
// Reached only from a mode `declined` refuses at the door, so a
|
// Reached only from a mode `declined` refuses at the door, so a
|
||||||
// browser is never standing in front of this hint.
|
// browser is never standing in front of these.
|
||||||
"COMMAND: Enter → `execute-command None` (run)",
|
"COMMAND: Enter execute-command Silent",
|
||||||
"EXPORT: Enter → `commit-export None` (export)",
|
"EXPORT: Enter commit-export Silent",
|
||||||
"SAVE AS: Enter → `commit-save None` (save)",
|
"SAVE AS: Enter commit-save Silent",
|
||||||
|
"NORMAL SEARCH: Enter/Esc exit-search-mode Silent",
|
||||||
|
// Commands the browser has no arm for: upstream, so a black hole
|
||||||
|
// (improvise-uln5). Each is also in the host-only list below.
|
||||||
|
"NORMAL: Enter enter-advance Silent",
|
||||||
|
"NORMAL: H hide-selected-row-item Silent",
|
||||||
|
"NORMAL: / search Silent",
|
||||||
|
"NORMAL: n search-navigate Silent",
|
||||||
|
"NORMAL: z toggle-group-under-cursor Silent",
|
||||||
|
// Honestly unavailable, and saying so in `declined`'s words.
|
||||||
|
"FORMULAS: Del/d delete-formula-at-cursor Declines",
|
||||||
|
"NORMAL: > drill-into-cell Declines",
|
||||||
|
"NORMAL: : enter-mode Declines",
|
||||||
|
"NORMAL: P toggle-prune-empty Declines",
|
||||||
|
"NORMAL: R toggle-records-mode Declines",
|
||||||
|
"NORMAL: ZZ wq Declines",
|
||||||
|
"RECORDS: o add-record-row Declines",
|
||||||
|
"RECORDS: a/i edit-or-drill Declines",
|
||||||
|
"RECORDS: : enter-mode Declines",
|
||||||
|
"RECORDS: P toggle-prune-empty Declines",
|
||||||
|
"RECORDS: R toggle-records-mode Declines",
|
||||||
|
// `i` on an aggregate IS drill, which the browser lacks (s8h7); on a
|
||||||
|
// derived cell the browser names the formula instead (ir7l).
|
||||||
|
"NORMAL on an aggregate: a/i edit-or-drill Declines",
|
||||||
|
"NORMAL on a derived cell: a/i edit-or-drill Declines",
|
||||||
|
// No record under the cursor to write. The TUI writes onto the
|
||||||
|
// aggregate's partial key, which is its own bug (improvise-l4mp).
|
||||||
|
"NORMAL on an aggregate: p paste Silent",
|
||||||
|
"NORMAL on an aggregate: x clear-cell Silent",
|
||||||
|
"NORMAL on a derived cell: p paste Silent",
|
||||||
|
"NORMAL on a derived cell: x clear-cell Silent",
|
||||||
// A records cell commit needs `record_col` (gotcha 1); only the two
|
// A records cell commit needs `record_col` (gotcha 1); only the two
|
||||||
// creating columns resolve, and this fixture is on a record cell.
|
// 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 Silent",
|
||||||
"RECORDS INSERT: Tab → `commit-and-advance-right None` (commit+right)",
|
"RECORDS INSERT: Enter commit-cell-edit Silent",
|
||||||
];
|
];
|
||||||
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||||
expected.sort();
|
expected.sort();
|
||||||
|
let mut short = short;
|
||||||
|
short.sort();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unanswered, expected,
|
short,
|
||||||
"advertised and unanswerable: anything here that is not in the \
|
expected,
|
||||||
named list above reads as broken rather than as unavailable"
|
"advertised keys that do not do what they say, in full:\n{}",
|
||||||
|
short.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// improvise-6562: "it moved" cannot see a move the WRONG way, so every grid
|
||||||
|
/// key is pressed from the centre and must land where it says. Evidence file.
|
||||||
|
#[test]
|
||||||
|
fn every_grid_move_lands_where_its_binding_says() {
|
||||||
|
let keymap = Keymaps::shared().get(&ModeKey::Normal).expect("Normal");
|
||||||
|
let mut moves = Vec::new();
|
||||||
|
bound_commands(keymap, &mut moves);
|
||||||
|
// A move's destination is its own args; a jump's is its name.
|
||||||
|
let mut expected: Vec<(String, Vec<String>, (usize, usize))> = moves
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(name, _)| name == "move-selection")
|
||||||
|
.map(|(name, args)| {
|
||||||
|
let d = |i: usize| args[i].parse::<isize>().expect("a delta");
|
||||||
|
let to = ((1 + d(0)) as usize, (1 + d(1)) as usize);
|
||||||
|
(name, args, to)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (name, to) in [
|
||||||
|
("jump-first-row", (0, 1)),
|
||||||
|
("jump-last-row", (2, 1)),
|
||||||
|
("jump-first-col", (1, 0)),
|
||||||
|
("jump-last-col", (1, 2)),
|
||||||
|
] {
|
||||||
|
expected.push((name.to_string(), Vec::new(), to));
|
||||||
|
}
|
||||||
|
let mut pressed = 0;
|
||||||
|
for (name, args, to) in expected {
|
||||||
|
let wanted = (!args.is_empty()).then_some(args.as_slice());
|
||||||
|
for sequence in sequences_running(keymap, &name, wanted) {
|
||||||
|
let mut state = furnished(AppMode::Normal);
|
||||||
|
for key in &sequence {
|
||||||
|
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
|
||||||
|
commands.iter().for_each(|c| state.fold(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
state.view.cursor().selected,
|
||||||
|
to,
|
||||||
|
"{sequence:?} runs `{name} {args:?}` from (1,1)"
|
||||||
|
);
|
||||||
|
pressed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
pressed >= 14,
|
||||||
|
"only {pressed} keys pressed; the walk went blind"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2431,8 +2674,8 @@ mod tests {
|
|||||||
not vacuous over a table that binds everything or nothing"
|
not vacuous over a table that binds everything or nothing"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
composed(&state, ev(KeyCode::Char(',')), "view-forward", &[]),
|
resolve(&state, ev(KeyCode::Char(','))),
|
||||||
Some(vec![Command::ViewForward]),
|
KeyOutcome::Local(vec![Command::ViewForward]),
|
||||||
"and the key is `,` — the unshifted twin of `<`, so both halves of \
|
"and the key is `,` — the unshifted twin of `<`, so both halves of \
|
||||||
the history sit on one physical key"
|
the history sit on one physical key"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -427,9 +427,9 @@ impl RenderHelpRow {
|
|||||||
L::Heading(t) => prose("heading", t),
|
L::Heading(t) => prose("heading", t),
|
||||||
L::Text(t) => prose("text", t),
|
L::Text(t) => prose("text", t),
|
||||||
L::Accent(t) => prose("accent", t),
|
L::Accent(t) => prose("accent", t),
|
||||||
L::Keys { keys, description } => Self {
|
L::Keys(row) => Self {
|
||||||
keys: keys.join(" "),
|
keys: row.keys().join(" "),
|
||||||
description: description.to_string(),
|
description: row.description().to_string(),
|
||||||
kind: "keys",
|
kind: "keys",
|
||||||
},
|
},
|
||||||
L::Literal {
|
L::Literal {
|
||||||
|
|||||||
+3
-3
@@ -69,9 +69,9 @@ fn page_content(page: usize) -> Vec<Row> {
|
|||||||
HelpLine::Heading(text) => plain(text, s.heading),
|
HelpLine::Heading(text) => plain(text, s.heading),
|
||||||
HelpLine::Text(text) => plain(text, s.normal),
|
HelpLine::Text(text) => plain(text, s.normal),
|
||||||
HelpLine::Accent(text) => plain(text, s.accent),
|
HelpLine::Accent(text) => plain(text, s.accent),
|
||||||
HelpLine::Keys { keys, description } => Row {
|
HelpLine::Keys(row) => Row {
|
||||||
key: format!(" {}", keys.join(" ")),
|
key: format!(" {}", row.keys().join(" ")),
|
||||||
desc: description,
|
desc: row.description(),
|
||||||
style: s.key,
|
style: s.key,
|
||||||
},
|
},
|
||||||
HelpLine::Literal {
|
HelpLine::Literal {
|
||||||
|
|||||||
Reference in New Issue
Block a user