Help rows name no key by hand, and the guard hears what advertised keys do

wk18: gg/yy/ZZ and the page tip are keymap lookups (prefixed sequences
included); ex-command rows can only be built by ex!, whose colon is its
own. help_bindings() records every row the pages ask for, from the same
builders.

3t5x: every_advertised_command_answers_in_its_own_mode walks the hint
line AND the help pages, judges each key by what moved (a lone status is
Declines, a disarm alone is Silent), and names every key that does not do
what it says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 21:13:40 -07:00
parent 97d17b60f2
commit 740511e0f9
2 changed files with 310 additions and 101 deletions
+115 -61
View File
@@ -310,8 +310,8 @@ pub enum HelpLine {
keys: Vec<String>,
description: &'static str,
},
/// A row naming something the keymap does not own: an ex-command, or a
/// prefix sequence, which is two addresses and not one binding.
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
/// colon is what stops a key being typed here (improvise-wk18).
Literal {
keys: &'static str,
description: &'static str,
@@ -330,35 +330,60 @@ pub struct HelpPage {
/// row is dropped, so the page never advertises a key that does not exist.
fn keys(
mode: &ModeKey,
command: &str,
command: &'static str,
args: Option<&[&str]>,
description: &'static str,
) -> Option<HelpLine> {
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());
match found.is_empty() {
let mut labels: Vec<String> = keys_for(mode, command, args.as_deref())
.iter()
.map(|(c, m)| key_label(c, *m))
.collect();
labels.extend(prefixed_labels(mode, command, args.as_deref()));
match labels.is_empty() {
true => None,
false => Some(HelpLine::Keys {
keys: found.iter().map(|(c, m)| key_label(c, *m)).collect(),
keys: labels,
description,
}),
}
}
fn literal(keys: &'static str, description: &'static str) -> HelpLine {
HelpLine::Literal {
keys,
description,
dim: false,
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Vec<String> {
let mut labels: Vec<String> = match Keymaps::shared().get(mode) {
Some(keymap) => keymap.prefixed_keys_running(command, args),
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 {
HelpLine::Literal {
keys,
description,
dim: true,
}
/// How a page asks for a keys row. `pages` answers with [`keys`]; a test
/// answers by recording the question, so a guard reads commands, not text.
type KeysRow<'a> =
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
/// An ex-command row. The leading colon is the macro's, not the caller's.
macro_rules! ex {
($text:literal, $description:expr $(,)?) => {
HelpLine::Literal {
keys: concat!(":", $text),
description: $description,
dim: false,
}
};
(dim $text:literal, $description:expr $(,)?) => {
HelpLine::Literal {
keys: concat!(" :", $text),
description: $description,
dim: true,
}
};
}
/// The five pages, in order. `HELP_PAGE_COUNT` is their count and the bound
@@ -367,30 +392,48 @@ pub fn pages() -> Vec<HelpPage> {
let pages = vec![
HelpPage {
title: "Welcome",
lines: welcome(),
lines: welcome(&keys),
},
HelpPage {
title: "Navigation",
lines: navigation(),
lines: navigation(&keys),
},
HelpPage {
title: "Editing",
lines: editing(),
lines: editing(&keys),
},
HelpPage {
title: "Panels & Views",
lines: panels(),
lines: panels(&keys),
},
HelpPage {
title: "Commands",
lines: commands(),
lines: commands(&keys),
},
];
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
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(keys: KeysRow) -> Vec<HelpLine> {
vec![
HelpLine::Blank,
HelpLine::Banner(" improvise"),
@@ -428,11 +471,18 @@ fn welcome() -> Vec<HelpLine> {
HelpLine::Text(" Tile The row/column/page assignment of a category."),
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.", ""),
]
.into_iter()
.chain(keys(
&ModeKey::Help,
"help-page-next",
None,
"Next page of this help",
))
.collect()
}
fn navigation() -> Vec<HelpLine> {
fn navigation(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -445,7 +495,7 @@ fn navigation() -> Vec<HelpLine> {
None,
"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-first-col", None, "Jump to first column"),
keys(n, "jump-last-col", None, "Jump to last column"),
@@ -512,7 +562,7 @@ fn navigation() -> Vec<HelpLine> {
.collect()
}
fn editing() -> Vec<HelpLine> {
fn editing(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
let e = &ModeKey::Editing;
[
@@ -555,7 +605,7 @@ fn editing() -> Vec<HelpLine> {
Some(HelpLine::Blank),
Some(HelpLine::Heading("Copy and paste")),
Some(HelpLine::Blank),
Some(literal("yy", "Yank (copy) the current cell value")),
keys(n, "yank", None, "Yank (copy) the current cell value"),
keys(
n,
"paste",
@@ -593,7 +643,7 @@ fn editing() -> Vec<HelpLine> {
.collect()
}
fn panels() -> Vec<HelpLine> {
fn panels(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -741,14 +791,14 @@ fn panels() -> Vec<HelpLine> {
"Toggle collapse of nearest group above cursor",
),
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()
.flatten()
.collect()
}
fn commands() -> Vec<HelpLine> {
fn commands(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -763,55 +813,40 @@ fn commands() -> Vec<HelpLine> {
Some(HelpLine::Blank),
Some(HelpLine::Heading("File operations")),
Some(HelpLine::Blank),
Some(literal(
":w [path]",
"Save (path optional after first save)",
)),
Some(literal(":wq", "Save and quit")),
Some(literal(":q", "Quit (warns if unsaved changes)")),
Some(literal(":q!", "Force quit without saving")),
Some(literal("ZZ", "Save and quit (same as :wq)")),
Some(ex!("w [path]", "Save (path optional after first save)",)),
Some(ex!("wq", "Save and quit")),
Some(ex!("q", "Quit (warns if unsaved changes)")),
Some(ex!("q!", "Force quit without saving")),
keys(n, "wq", None, "Save and quit (same as :wq)"),
keys(n, "save", None, "Save (same as :w)"),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Import and export")),
Some(HelpLine::Blank),
Some(literal(":import <path>", "Open the JSON/CSV import wizard")),
Some(literal(
":export [path.csv]",
"Export the active view to CSV",
)),
Some(ex!("import <path>", "Open the JSON/CSV import wizard")),
Some(ex!("export [path.csv]", "Export the active view to CSV",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Model building")),
Some(HelpLine::Blank),
Some(literal(":add-cat <name>", "Add a new category")),
Some(literal(
":add-item <cat> <item>",
"Add one item to a category",
)),
Some(literal(
":add-items <cat> a b c ...",
Some(ex!("add-cat <name>", "Add a new category")),
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
Some(ex!(
"add-items <cat> a b c ...",
"Add multiple items at once",
)),
Some(literal(
":formula <cat> \"<Name> = <expr>\"",
"Add a formula",
)),
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Views")),
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::Heading("Display")),
Some(HelpLine::Blank),
Some(literal(
":set-format <fmt>",
"Set number format (e.g. ',.2')",
)),
Some(ex!("set-format <fmt>", "Set number format (e.g. ',.2')",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Other keys")),
Some(HelpLine::Blank),
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()
.flatten()
@@ -915,6 +950,25 @@ mod tests {
);
}
/// 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:
/// keys that are all chords leave nothing to draw.
#[test]
+195 -40
View File
@@ -2045,7 +2045,20 @@ mod tests {
/// 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());
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 = client_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
// produce, so the fixture projects both (improvise-e5pf).
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
@@ -2105,59 +2118,201 @@ mod tests {
state
}
/// improvise-rmvx. An advertised key that resolves upstream is a black hole
/// (gotcha 47). See `context/test-evidence/clients.md`.
/// What a user sees an advertised key do. A status alone is `Declines`:
/// 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 moved, never by the variant: a chord's second key is
/// `Local` even when all it does is disarm the prefix.
fn heard(outcome: KeyOutcome) -> Heard {
let commands = match outcome {
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
KeyOutcome::Request(_) => return Heard::Acts,
KeyOutcome::Local(commands) => commands,
};
let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_)));
let did = commands
.iter()
.any(|c| !matches!(c, Command::SetStatus(_) | Command::SetTransientPrefix(None)));
match (did, 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));
}
}
heard(resolve(&state, *last))
}
/// 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]
fn every_advertised_command_answers_in_its_own_mode() {
let mut unanswered = Vec::new();
for mode in AppMode::all() {
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
continue;
let aggregate = CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
);
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 {
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);
// BOTH silent variants. To a user `Ignored` is the same
// black hole as `Upstream` — nothing is on the screen.
let answered = !matches!(
resolve(&state, event),
KeyOutcome::Upstream(_) | KeyOutcome::Ignored
);
if !answered {
unanswered.push(format!(
"{}: {:?} → `{command} {args:?}` ({caption})",
crate::mode_label(&mode),
code
));
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
for sequence in sequences_running(keymap, command, args.as_deref()) {
let keys: String = sequence
.iter()
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
.collect();
let on_record = press(at(&record_cell()), &sequence);
let mut report = vec![(label.clone(), on_record)];
// Only a grid mode stands on another kind of cell, and a cell
// matters only where it changes the answer.
if mode == AppMode::Normal {
for (on, cell) in [
(" on an aggregate", &aggregate),
(" on a derived cell", &derived),
] {
let got = press(at(cell), &sequence);
if got != on_record {
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();
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 = [
let short: Vec<String> = short
.into_iter()
.map(|((at, command, got), mut keys)| {
keys.sort();
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
// 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)",
// browser is never standing in front of these.
"COMMAND: Enter execute-command Silent",
"EXPORT: Enter commit-export Silent",
"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 in the TUI (improvise-s8h7), which
// the browser lacks; on a derived cell the TUI edits a shadowed
// cell and the browser names the formula instead (improvise-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
// 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)",
"RECORDS INSERT: Tab commit-and-advance-right Silent",
"RECORDS INSERT: Enter commit-cell-edit Silent",
];
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
expected.sort();
let mut short = short;
short.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"
short,
expected,
"advertised keys that do not do what they say, in full:\n{}",
short.join("\n")
);
}