334e218c7b
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
1045 lines
36 KiB
Rust
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}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|