Files
improvise/crates/improvise-protocol/src/help.rs
T
Edward Langley 334e218c7b s8h7: i on an aggregate names drill, in help and in the browser
In the TUI, EditOrDrill hands an aggregated cell to DrillIntoCell, so `i`
on an aggregate is drill by another door. The help page called it only
"Edit current cell", and the browser refused it in words unrelated to `>`'s,
so a user could not tell the two were one missing feature.

- help.rs: the edit-or-drill row says it drills into an aggregate.
- help.rs: Normal-mode Enter was captioned "Edit current cell (same as i)"
  but is bound to enter-advance alone, which only moves down; the row moves
  to Cursor movement and says so.
- resolve.rs: the Aggregate arm now reads "an aggregate is edited by
  drilling in — " + not_yet("drill"), the same sentence `>` uses.
- context/code/clients.md and the evidence entry reverse the recorded
  "different sentences on purpose" rationale, which was false about the TUI.
- harness/tests/drill.spec.js follows the new wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:53:18 -07:00

1045 lines
36 KiB
Rust

//! What a binding is called and what it does, in words: the status-bar hint,
//! the which-key popup and the help pages. See `context/code/protocol.md` §8.
use crate::key::{KeyCode, KeyModifiers};
use crate::keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey, key_label};
use crate::{AppMode, HELP_PAGE_COUNT};
/// One advertised binding. `command` is the semantic value — a localized or
/// non-terminal front end keys off it and ignores `caption`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hint {
/// The keys that reach it, as the keymap holds them. A renderer spells them
/// with [`Hint::labels`]; nothing reads a modifier back out of a label.
pub keys: Vec<(KeyCode, KeyModifiers)>,
pub command: &'static str,
pub caption: &'static str,
}
impl Hint {
/// This hint's keys, spelled. The one spelling is `keymap::key_label`.
pub fn labels(&self) -> Vec<String> {
self.keys.iter().map(|(c, m)| key_label(c, *m)).collect()
}
}
/// A key held with a modifier the status bar will not spell. Asked of the
/// modifiers, never of the label — `context/code/protocol.md` §8.
fn is_chord(mods: KeyModifiers) -> bool {
mods != KeyModifiers::NONE && mods != KeyModifiers::SHIFT
}
/// Short uppercase name for the title bar. Moved off [`AppMode`] so the enum
/// that crosses the wire carries no English (improvise-k099).
pub fn mode_label(mode: &AppMode) -> &'static str {
match mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::SavePrompt { .. } => "SAVE AS",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
AppMode::RecordsNormal => "RECORDS",
AppMode::RecordsEditing { .. } => "RECORDS INSERT",
}
}
/// The tail some hints end in: prose rather than a binding, so it is outside
/// the derivation. The wizard's is keys — see `context/code/protocol.md` §8.
fn hint_tail(mode: &AppMode) -> &'static str {
match mode {
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
AppMode::FormulaEdit { .. } => "type: Name = expression",
AppMode::CategoryAdd { .. } => "type a category name",
AppMode::ItemAdd { .. } => "type an item name",
AppMode::ExportPrompt { .. } | AppMode::SavePrompt { .. } => "type a file path",
_ => "",
}
}
/// 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.
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![
("move-selection", None, "nav"),
("edit-or-drill", None, "edit"),
("toggle-records-mode", None, "records"),
("toggle-prune-empty", None, "prune"),
("toggle-panel-and-focus", arg("formula"), "formulas"),
("toggle-panel-and-focus", arg("category"), "categories"),
("toggle-panel-and-focus", arg("view"), "views"),
("enter-tile-select", None, "tiles"),
("page-prev", None, "prev-page"),
("page-next", None, "next-page"),
("drill-into-cell", None, "drill"),
("enter-mode", arg("command"), "cmd"),
],
AppMode::RecordsNormal => vec![
("move-selection", None, "nav"),
("edit-or-drill", None, "edit"),
("add-record-row", None, "add-row"),
("toggle-records-mode", None, "pivot"),
("toggle-prune-empty", None, "prune"),
("view-back", None, "back"),
("enter-mode", arg("command"), "cmd"),
],
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => vec![
("commit-cell-edit", None, "commit"),
("commit-and-advance-right", None, "commit+right"),
("clear-buffer", arg("edit"), "cancel"),
],
AppMode::FormulaPanel => vec![
("enter-mode", arg("formula-edit"), "new"),
("delete-formula-at-cursor", None, "delete"),
("move-panel-cursor", None, "nav"),
("enter-mode", arg("normal"), "back"),
],
AppMode::FormulaEdit { .. } => vec![
("commit-buffer", arg("formula"), "save"),
("clear-buffer", arg("formula"), "cancel"),
],
AppMode::CategoryPanel => vec![
("move-panel-cursor", None, "nav"),
("cycle-axis-at-cursor", None, "cycle-axis"),
("enter-mode", arg("category-add"), "new-cat"),
("open-item-add-at-cursor", None, "add-items"),
("delete-category-at-cursor", None, "delete"),
("enter-mode", arg("normal"), "back"),
],
AppMode::CategoryAdd { .. } => vec![
("commit-buffer", arg("category"), "add & continue"),
("clear-buffer", arg("category"), "done"),
],
AppMode::ItemAdd { .. } => vec![
("commit-buffer", arg("item"), "add & continue"),
("clear-buffer", arg("item"), "done"),
],
AppMode::ViewPanel => vec![
("move-panel-cursor", None, "nav"),
("switch-view-at-cursor", None, "switch"),
("create-and-switch-view", None, "new"),
("delete-view-at-cursor", None, "delete"),
("enter-mode", arg("normal"), "back"),
],
AppMode::TileSelect => vec![
("move-tile-cursor", None, "select"),
("cycle-axis-for-tile", None, "cycle"),
("set-axis-for-tile", arg("row"), "row"),
("set-axis-for-tile", arg("column"), "col"),
("set-axis-for-tile", arg("page"), "page"),
("enter-mode", arg("normal"), "back"),
],
// Its whole keymap is one `Any` binding into `HandleWizardKey`, which
// decides per step, so there is nothing here to look up (see §8).
AppMode::ImportWizard => vec![],
AppMode::Help => vec![
("help-page-next", None, "next-page"),
("help-page-prev", None, "prev-page"),
("enter-mode", arg("normal"), "close"),
],
AppMode::ExportPrompt { .. } => vec![
("commit-export", None, "export"),
("clear-buffer", arg("export"), "cancel"),
],
AppMode::SavePrompt { .. } => vec![
("commit-save", None, "save"),
("clear-buffer", arg("save"), "cancel"),
],
AppMode::CommandMode { .. } => vec![
("execute-command", None, "run"),
("clear-buffer", arg("command"), "cancel"),
],
AppMode::Quit => vec![],
}
}
/// What this mode offers, keys and all. `Quit` alone offers nothing, because
/// it is never rendered.
pub fn hints(mode: &AppMode) -> Vec<Hint> {
let Some(key) = ModeKey::from_app_mode(mode, false) else {
return Vec::new();
};
advertised(mode)
.into_iter()
.map(|(command, args, caption)| Hint {
keys: keys_for(&key, command, args.as_deref()),
command,
caption,
})
.filter(|hint| !hint.keys.is_empty())
.collect()
}
/// The compact status-bar rendering, in the protocol so two front ends do not
/// each invent one. A client wanting its own shape reads [`hints`].
pub fn hint_line(mode: &AppMode) -> String {
let body = hints(mode)
.iter()
.map(|hint| (compact(&hint.keys), hint.caption))
// A command bound only to chords compacts to nothing, and a bare
// ":caption" advertises a key the reader cannot find.
.filter(|(keys, _)| !keys.is_empty())
.map(|(keys, caption)| format!("{keys}:{caption}"))
.collect::<Vec<_>>()
.join(" ");
match (body.as_str(), hint_tail(mode)) {
("", tail) => tail.to_string(),
(_, "") => body,
(_, tail) => format!("{body} — {tail}"),
}
}
/// The status bar's rendering of one hint's keys: chords dropped, the rest
/// run together when every one is a single character. §8 says why.
fn compact(keys: &[(KeyCode, KeyModifiers)]) -> String {
let plain: Vec<String> = keys
.iter()
.filter(|(_, mods)| !is_chord(*mods))
.map(|(code, mods)| key_label(code, *mods))
.collect();
match plain.iter().all(|k| k.chars().count() == 1) {
true => plain.concat(),
false => plain.join("/"),
}
}
/// Every key that reaches `command` with `args` in `mode`, parents included,
/// sorted by label so the answer does not move between processes (gotcha 18).
pub fn keys_for(
mode: &ModeKey,
command: &str,
args: Option<&[String]>,
) -> Vec<(KeyCode, KeyModifiers)> {
let mut keys = match Keymaps::shared().get(mode) {
Some(keymap) => keymap.keys_running(command, args),
None => Vec::new(),
};
keys.sort_by_key(|(code, mods)| key_label(code, *mods));
// By label, not by pattern: two patterns can spell the same key, and the
// status bar drew it twice when they did.
keys.dedup_by_key(|(code, mods)| key_label(code, *mods));
keys
}
/// The which-key popup's rows for an armed prefix: the keys it binds, each
/// with the caption for what it runs (improvise-x7sw).
pub fn which_key(keymap: &Keymap) -> Vec<Hint> {
let mut hints: Vec<Hint> = keymap
.bindings()
.iter()
.filter_map(|(pattern, binding)| {
let KeyPattern::Key(code, mods) = pattern else {
return None;
};
let command = match binding {
Binding::Cmd { name, .. } => *name,
Binding::Sequence(steps) => steps.first().map(|(name, _)| *name)?,
Binding::Prefix(_) => return None,
};
Some(Hint {
keys: vec![(*code, *mods)],
command,
caption: describe(command),
})
})
.collect();
hints.sort_by_key(Hint::labels);
hints
}
/// The English for a command name. One table, keyed by the name the registry
/// and the keymap already share — never by a key.
pub fn describe(command: &'static str) -> &'static str {
match command {
"add-record-row" => "add a record row",
"clear-cell" => "clear the cell",
"commit-save" => "save",
"create-and-switch-view" => "new view",
"cycle-axis-at-cursor" => "cycle this category's axis",
"delete-category-at-cursor" => "delete",
"delete-formula-at-cursor" => "delete the formula",
"delete-view-at-cursor" => "delete the view",
"drill-into-cell" => "drill into the cell",
"enter-mode" => "change mode",
"force-quit" => "quit without saving",
"hide-selected-row-item" => "hide this row's item",
"jump-first-col" => "first column",
"jump-first-row" => "first row",
"jump-last-col" => "last column",
"jump-last-row" => "last row",
"page-next" => "next page item",
"page-prev" => "previous page item",
"paste" => "paste",
"save" => "save",
"switch-view-at-cursor" => "switch to this view",
"toggle-col-group-under-cursor" => "collapse this column group",
"toggle-group-under-cursor" => "collapse this group",
"toggle-prune-empty" => "hide empty rows and columns",
"toggle-records-mode" => "records mode",
"transpose" => "swap rows and columns",
"view-back" => "back",
"view-forward" => "forward",
"yank" => "yank the cell",
other => other,
}
}
/// A styled line on a help page. The **styles** are the front end's
/// (`src/ui/help.rs`, `web/`); the content is nobody's but this module's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelpLine {
Blank,
Banner(&'static str),
Heading(&'static str),
Text(&'static str),
Accent(&'static str),
/// A keys-and-description row whose keys came off the keymap, so it
/// cannot go stale against the bindings it documents.
Keys {
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.
Literal {
keys: &'static str,
description: &'static str,
dim: bool,
},
}
/// One page of the help overlay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelpPage {
pub title: &'static str,
pub lines: Vec<HelpLine>,
}
/// A keys row, looked up in `mode`. An unbound command yields no keys and the
/// row is dropped, so the page never advertises a key that does not exist.
fn keys(
mode: &ModeKey,
command: &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() {
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 {
HelpLine::Literal {
keys,
description,
dim: false,
}
}
fn dim(keys: &'static str, description: &'static str) -> HelpLine {
HelpLine::Literal {
keys,
description,
dim: true,
}
}
/// The five pages, in order. `HELP_PAGE_COUNT` is their count and the bound
/// `ViewState::help_page` is clamped to.
pub fn pages() -> Vec<HelpPage> {
let pages = vec![
HelpPage {
title: "Welcome",
lines: welcome(),
},
HelpPage {
title: "Navigation",
lines: navigation(),
},
HelpPage {
title: "Editing",
lines: editing(),
},
HelpPage {
title: "Panels & Views",
lines: panels(),
},
HelpPage {
title: "Commands",
lines: commands(),
},
];
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
pages
}
fn welcome() -> Vec<HelpLine> {
vec![
HelpLine::Blank,
HelpLine::Banner(" improvise"),
HelpLine::Text(" A multi-dimensional data modeling tool in your terminal."),
HelpLine::Blank,
HelpLine::Text(" improvise lets you build spreadsheet-like models organized"),
HelpLine::Text(" by categories (dimensions). Each category has items, and"),
HelpLine::Text(" every combination of items across categories forms a cell."),
HelpLine::Text(" Think of it like a pivot table you can build from scratch."),
HelpLine::Blank,
HelpLine::Heading("Quick start"),
HelpLine::Blank,
HelpLine::Text(" 1. Create categories (dimensions) for your model:"),
HelpLine::Accent(" :add-cat Region :add-cat Product"),
HelpLine::Blank,
HelpLine::Text(" 2. Add items to each category:"),
HelpLine::Accent(" :add-items Region North South East West"),
HelpLine::Accent(" :add-items Product Widget Gadget"),
HelpLine::Blank,
HelpLine::Text(" 3. Navigate with hjkl or arrow keys and press i to edit cells."),
HelpLine::Blank,
HelpLine::Text(" 4. Add formulas to compute values automatically:"),
HelpLine::Accent(" :formula Product \"Total = Widget + Gadget\""),
HelpLine::Blank,
HelpLine::Text(" 5. Save your work:"),
HelpLine::Accent(" :w mymodel.improv"),
HelpLine::Blank,
HelpLine::Heading("Core concepts"),
HelpLine::Blank,
HelpLine::Text(" Category A dimension of your data (e.g. Region, Time, Product)."),
HelpLine::Text(" Item A member of a category (e.g. North, Q1, Widget)."),
HelpLine::Text(
" View A saved layout: which categories go on rows, columns, or pages.",
),
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.", ""),
]
}
fn navigation() -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
Some(HelpLine::Heading("Cursor movement")),
Some(HelpLine::Blank),
keys(n, "move-selection", None, "Move one cell"),
keys(
n,
"enter-advance",
None,
"Move down, wrapping to the next column",
),
Some(literal("gg", "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"),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Scrolling")),
Some(HelpLine::Blank),
keys(n, "scroll-rows", Some(&["5"]), "Scroll half-page down"),
keys(n, "scroll-rows", Some(&["-5"]), "Scroll half-page up"),
keys(
n,
"page-scroll",
Some(&["1"]),
"Scroll three-quarters page down",
),
keys(
n,
"page-scroll",
Some(&["-1"]),
"Scroll three-quarters page up",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Page-axis cycling")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" When a category is on the Page axis, only one item is",
)),
Some(HelpLine::Text(
" visible at a time. Use [ and ] to cycle through them.",
)),
Some(HelpLine::Blank),
keys(n, "page-prev", None, "Previous page-axis item"),
keys(n, "page-next", None, "Next page-axis item"),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Search")),
Some(HelpLine::Blank),
keys(
n,
"search",
None,
"Start search — type a pattern, matching cells highlight",
),
keys(
n,
"search-navigate",
Some(&["forward"]),
"Jump to next match",
),
keys(n, "search-or-category-add", None, "Jump to previous match"),
keys(
&ModeKey::SearchMode,
"exit-search-mode",
None,
"Exit search mode",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("View history")),
Some(HelpLine::Blank),
keys(n, "drill-into-cell", None, "Drill into selected cell"),
keys(n, "view-back", None, "Go back to previous view"),
keys(n, "view-forward", None, "Go forward again after going back"),
]
.into_iter()
.flatten()
.collect()
}
fn editing() -> Vec<HelpLine> {
let n = &ModeKey::Normal;
let e = &ModeKey::Editing;
[
Some(HelpLine::Blank),
Some(HelpLine::Heading("Entering edit mode")),
Some(HelpLine::Blank),
keys(
n,
"edit-or-drill",
Some(&["editing"]),
"Edit current cell (drill into an aggregate)",
),
keys(
e,
"clear-buffer",
Some(&["edit"]),
"Cancel edit, return to Normal",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("While editing")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Type normally to enter a value. Values can be:",
)),
Some(HelpLine::Accent(" Numbers: 42 3.14 -100")),
Some(HelpLine::Accent(" Text: hello world")),
Some(HelpLine::Blank),
keys(
e,
"commit-cell-edit",
Some(&["editing"]),
"Commit and move down",
),
keys(
e,
"commit-and-advance-right",
Some(&["editing"]),
"Commit and move right (stay in edit mode)",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Copy and paste")),
Some(HelpLine::Blank),
Some(literal("yy", "Yank (copy) the current cell value")),
keys(
n,
"paste",
None,
"Paste the yanked value into the current cell",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Cell operations")),
Some(HelpLine::Blank),
keys(n, "clear-cell", None, "Clear the current cell"),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Formulas")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Formulas are computed items. A formula belongs to a category",
)),
Some(HelpLine::Text(
" and derives its value from other items in that category.",
)),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Example: in a Product category with items Widget and Gadget:",
)),
Some(HelpLine::Accent(
" :formula Product \"Total = Widget + Gadget\"",
)),
Some(HelpLine::Blank),
Some(HelpLine::Text(" Supported operators: + - * /")),
Some(HelpLine::Text(
" Formula cells update automatically when source values change.",
)),
]
.into_iter()
.flatten()
.collect()
}
fn panels() -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
Some(HelpLine::Heading("Side panels")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Panels open on the right side of the screen and give you",
)),
Some(HelpLine::Text(
" quick access to formulas, categories, and views.",
)),
Some(HelpLine::Blank),
keys(
n,
"toggle-panel-and-focus",
Some(&["formula"]),
"Toggle Formula panel",
),
keys(
&ModeKey::FormulaPanel,
"enter-mode",
Some(&["formula-edit"]),
"New formula",
),
keys(
&ModeKey::FormulaPanel,
"delete-formula-at-cursor",
None,
"Delete selected formula",
),
Some(HelpLine::Blank),
keys(
n,
"toggle-panel-and-focus",
Some(&["category"]),
"Toggle Category panel",
),
keys(
&ModeKey::CategoryPanel,
"enter-mode",
Some(&["category-add"]),
"New category",
),
keys(
&ModeKey::CategoryPanel,
"open-item-add-at-cursor",
None,
"Add items to selected category",
),
keys(
&ModeKey::CategoryPanel,
"delete-category-at-cursor",
None,
"Delete selected category/item",
),
Some(HelpLine::Blank),
keys(
n,
"search-or-category-add",
None,
"Quick-add a new category (from anywhere)",
),
Some(HelpLine::Blank),
keys(
n,
"toggle-panel-and-focus",
Some(&["view"]),
"Toggle View panel",
),
keys(
&ModeKey::ViewPanel,
"create-and-switch-view",
None,
"New view",
),
keys(
&ModeKey::ViewPanel,
"delete-view-at-cursor",
None,
"Delete selected view",
),
keys(
&ModeKey::ViewPanel,
"switch-view-at-cursor",
None,
"Switch to selected view",
),
Some(HelpLine::Blank),
keys(
n,
"cycle-panel-focus",
None,
"Cycle focus between open panels",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Tile select mode")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Tiles control which axis each category is placed on.",
)),
keys(
n,
"enter-tile-select",
None,
"Enter tile-select mode, then:",
),
Some(HelpLine::Blank),
keys(
&ModeKey::TileSelect,
"move-tile-cursor",
Some(&["1"]),
"Select next category tile",
),
keys(
&ModeKey::TileSelect,
"cycle-axis-for-tile",
None,
"Cycle axis: Row → Col → Page",
),
keys(
&ModeKey::TileSelect,
"set-axis-for-tile",
Some(&["row"]),
"Set axis to Row",
),
keys(
&ModeKey::TileSelect,
"set-axis-for-tile",
Some(&["column"]),
"Set axis to Col",
),
keys(
&ModeKey::TileSelect,
"set-axis-for-tile",
Some(&["page"]),
"Set axis to Page",
),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Groups and visibility")),
Some(HelpLine::Blank),
keys(
n,
"toggle-group-under-cursor",
None,
"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")),
]
.into_iter()
.flatten()
.collect()
}
fn commands() -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
Some(HelpLine::Heading("Command line ( : )")),
Some(HelpLine::Blank),
Some(HelpLine::Text(
" Press : to open the command line. Commands are entered",
)),
Some(HelpLine::Text(
" vim-style and executed with Enter. Esc cancels.",
)),
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)")),
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(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 ...",
"Add multiple items at once",
)),
Some(literal(
":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(HelpLine::Blank),
Some(HelpLine::Heading("Display")),
Some(HelpLine::Blank),
Some(literal(
":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")),
]
.into_iter()
.flatten()
.collect()
}
#[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]
fn every_advertised_hint_names_a_binding_that_exists() {
for mode in AppMode::all() {
let Some(key) = ModeKey::from_app_mode(&mode, false) else {
continue;
};
for (command, args, caption) in advertised(&mode) {
assert!(
!keys_for(&key, command, args.as_deref()).is_empty(),
"{}: `{command} {args:?}` ({caption}) is advertised and bound to nothing",
mode_label(&mode)
);
}
}
}
/// `Quit` alone shows nothing, because it is never rendered — the same
/// property `only_quit_has_no_hint` asserted about the old string.
#[test]
fn only_quit_advertises_nothing() {
let silent: Vec<&str> = AppMode::all()
.iter()
.filter(|mode| hint_line(mode).is_empty())
.map(mode_label)
.collect();
assert_eq!(silent, vec!["QUIT"]);
}
/// The keys are looked up, not spelled: a hint reports what the keymap
/// says, which is what stops it going stale.
#[test]
fn a_hints_keys_come_off_the_keymap() {
let normal = hints(&AppMode::Normal);
let nav = normal
.iter()
.find(|h| h.command == "move-selection")
.expect("Normal advertises the cursor");
for key in ["h", "j", "k", "l"] {
assert!(
nav.labels().iter().any(|k| k == key),
"{key} is bound to move-selection and must appear: {:?}",
nav.keys
);
}
assert!(
hint_line(&AppMode::Normal).contains(":nav"),
"and the compact line carries the caption"
);
}
/// Every help page's key rows resolve too, and every page has a title.
/// A row that resolved to nothing is dropped rather than drawn empty.
#[test]
fn every_help_page_key_row_names_keys_that_exist() {
let pages = pages();
assert_eq!(pages.len(), HELP_PAGE_COUNT);
let mut rows = 0;
for page in &pages {
assert!(!page.title.is_empty());
for line in &page.lines {
if let HelpLine::Keys { keys, description } = line {
assert!(!keys.is_empty(), "{description} resolved to no keys");
rows += 1;
}
}
}
assert!(
rows > 20,
"only {rows} derived rows; the pages have stopped being derived"
);
}
/// `compact`'s three shapes, including the one no shipped mode reaches:
/// keys that are all chords leave nothing to draw.
#[test]
fn the_compact_rendering_drops_chords_and_says_nothing_when_all_are() {
const NONE: KeyModifiers = KeyModifiers::NONE;
const CTRL: KeyModifiers = KeyModifiers::CONTROL;
const ALT: KeyModifiers = KeyModifiers::ALT;
let ch = |c: char, m| (KeyCode::Char(c), m);
assert_eq!(compact(&['h', 'j', 'k', 'l'].map(|c| ch(c, NONE))), "hjkl");
assert_eq!(
compact(&[(KeyCode::Esc, NONE), (KeyCode::Tab, NONE)]),
"Esc/Tab"
);
assert_eq!(
compact(&[ch('T', NONE), (KeyCode::Left, CTRL), ch('x', ALT)]),
"T",
"a chord is not worth the width it costs in a status bar"
);
assert_eq!(
compact(&[ch('d', CTRL), ch('u', CTRL)]),
"",
"and all-chords is empty rather than a bare colon"
);
assert_eq!(
compact(&[(KeyCode::BackTab, KeyModifiers::SHIFT)]),
"S-Tab",
"shift alone is not a chord — it is how a key is named, not held"
);
}
/// Pins the rule `is_chord` states; it cannot fail under the OLD rule, which
/// dropped these too. See `context/test-evidence/protocol.md`.
#[test]
fn a_chord_is_recognised_by_its_modifiers_and_not_by_its_label() {
let exotic = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
assert!(
is_chord(exotic),
"any modifier beyond shift is a chord, whatever it is called"
);
assert_eq!(
compact(&[
(KeyCode::Char('T'), KeyModifiers::NONE),
(KeyCode::F(5), exotic)
]),
"T",
"the label is built from the modifiers, so nothing may read them \
back out of it"
);
}
/// A caption is looked up by **command name**, so a localized front end
/// can ignore `caption` and key off `command` instead (improvise-k099).
#[test]
fn a_hint_carries_the_command_name_beside_the_english() {
for hint in hints(&AppMode::Normal) {
assert!(!hint.command.is_empty());
}
assert_eq!(describe("paste"), "paste");
assert_eq!(
describe("no-such-command"),
"no-such-command",
"an undescribed name falls back to itself rather than to silence"
);
}
fn rows() -> Vec<(Vec<String>, &'static str)> {
pages()
.into_iter()
.flat_map(|page| page.lines)
.filter_map(|line| match line {
HelpLine::Keys { keys, description } => Some((keys, description)),
_ => None,
})
.collect()
}
fn labels_of(command: &str, args: Option<&[String]>) -> Vec<String> {
keys_for(&ModeKey::Normal, command, args)
.iter()
.map(|(c, m)| key_label(c, *m))
.collect()
}
/// improvise-s8h7: `i` on an aggregate drills in the TUI, so the row
/// that documents it must say so. See `context/test-evidence/clients.md`.
#[test]
fn the_edit_key_row_says_it_drills_into_an_aggregate() {
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
let found: Vec<_> = rows().into_iter().filter(|(k, _)| *k == edit).collect();
let [(_, description)] = &found[..] else {
panic!("expected one help row keyed {edit:?}, got {found:?}");
};
assert!(
description.contains("drill") && description.contains("aggregate"),
"{edit:?} drills into an aggregate and the row must say so: {description}"
);
}
/// improvise-s8h7: Normal's Enter is `enter-advance`, which only moves.
/// See `context/test-evidence/clients.md`.
#[test]
fn only_the_edit_key_is_captioned_as_editing_the_cell() {
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
let claims: Vec<_> = rows()
.into_iter()
.filter(|(_, d)| d.contains("Edit current cell"))
.collect();
assert!(!claims.is_empty(), "the check needs a row to check");
for (keys, description) in claims {
assert!(
keys.iter().all(|k| edit.contains(k)),
"{keys:?} is captioned {description:?} but only {edit:?} edit"
);
}
}
/// Not an assertion: a printout for a human to read once. `cargo test -p
/// improvise-protocol eyeball -- --nocapture --ignored`.
#[test]
#[ignore]
fn eyeball_the_rendered_hints_and_pages() {
for mode in AppMode::all() {
println!("{:>14} {}", mode_label(&mode), hint_line(&mode));
}
for page in pages() {
println!("\n=== {} ===", page.title);
for line in &page.lines {
match line {
HelpLine::Keys { keys, description } => {
println!(" {:<28}{description}", keys.join(" "))
}
HelpLine::Literal {
keys, description, ..
} => {
println!(" {keys:<28}{description}")
}
HelpLine::Blank => println!(),
HelpLine::Banner(t)
| HelpLine::Heading(t)
| HelpLine::Text(t)
| HelpLine::Accent(t) => println!("{t}"),
}
}
}
}
}