feat(protocol): one home for UI strings (improvise-x7sw, ktv1, k099)

Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:

**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.

- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
  captions together. The client already holds the keymaps, which is the answer
  the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
  key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
  lines of content; `web/app.js` paints rows it does not author, and
  `harness/tests/shell.spec.js` asserts that `?` really draws them.

**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.

Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.

**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.

Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.

Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 11:18:17 -07:00
parent 555d9f1ca7
commit 414d02c4a6
20 changed files with 1442 additions and 618 deletions
+15
View File
@@ -818,6 +818,21 @@ the per-position accessors scan and asking them inside the row loop is quadratic
---
### `render.rs` and the help overlay (improvise-ktv1)
`RenderModel` carries `help`, `help_page` and `help_titles`, and the decision
the page must not make is *whether the overlay is open*: an **empty `help` is
what says it is closed**. That is the shape `col_groups` already uses for "this
grid has no column groups" — an absent list rather than a flag the page has to
interpret alongside a mode it would then have to understand.
The rows themselves are `improvise_protocol::help::pages()`, the same ones
`src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string
(`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a
CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can
style a heading without knowing what makes a line a heading. Where the content
comes from and what is derived from the keymap is `context/code/protocol.md` §8.
## `web/app.js` — the page
The browser half of improvise-cr3, kept as thin as it can be made, now talking
+78
View File
@@ -1102,3 +1102,81 @@ stale argument reads exactly like a live one:
Also stale and corrected, without being wrong enough to list above:
`key.rs`'s module doc called these "crate-local key types for the command
layer" — they are the protocol's, and both front ends use them.
---
## §8 `help.rs` — where UI strings live relative to a wire enum
Three tickets asked one question three times — improvise-x7sw (which-key hints
have no protocol home), improvise-ktv1 (the browser has no help overlay) and
improvise-k099 (`AppMode::hint_text`/`label` put terminal UI strings on a serde
enum). Answering it three times is the disease. The answer:
**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label` and `AppMode::hint_text` are
gone; `help::mode_label` and `help::hint_line` replace them. The enum a client
deserializes now carries no English at all.
**And wherever a string names a key, the key is looked up rather than spelled.**
`Hint { keys, command, caption }` is the unit: `keys` come off
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
non-terminal front end keys off, and `caption` is the default English — exactly
the `AxisColor`/`draw.rs::axis_fg` shape, with the decision shared and only the
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
### What that bought, and what it cost
It **deleted** `every_hint_key_resolves_in_its_mode` and its 60-line tokenizer.
That test existed to hold a hand-written string honest against the keymap; the
string is derived now, so the property is true by construction. A consolidation
that keeps its compatibility test is unfinished
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`),
and this is the deletion that measures this one. What replaced it,
`every_advertised_hint_names_a_binding_that_exists`, checks the *other*
direction — a mode advertising a command nothing binds — which was never
checkable before.
It also deleted `Keymap::binding_hints`: `help::which_key` is the one walker,
and the binding dump reads it too rather than a second one.
The cost is that the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists in the help pages lost their visual indent.
That is the price of the keys being true.
### The three rules the rendering follows
1. `hint_line` **drops chorded keys** (`Ctrl-`/`Alt-` prefixed). `T` advertises
tile select; its four Ctrl-arrow aliases would push the rest of the line off
the screen and teach nobody anything. A client wanting all of them reads
`hints()`, which keeps them.
2. It **runs single-character keys together** (`hjkl`) and slashes the rest
(`Esc/Tab`), which is the idiom the hand-written hints already used.
3. A help-page row whose command resolves to **no keys is dropped**, so a page
can never advertise a key that does not exist. `keys()` returning `Option`
is what makes that unrepresentable rather than validated.
### What is *not* derived, and why
**The wizard's.** `AppMode::ImportWizard`'s whole keymap is one `Any` binding
into `HandleWizardKey`, which decides per step inside the command — so there is
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
describe keys a command resolves privately.
**`gg`, `yy`, `ZZ` and the ex-commands.** A prefix sequence is two addresses and
not one binding (gotcha 13), and `:q`/`:w` are parsed text rather than keys.
Both are `HelpLine::Literal`, which says out loud that the row is spelled rather
than derived.
**The captions.** `i:edit` where `i` entered records mode would still pass every
test 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.
### The browser half
`RenderModel` carries `help`, `help_page` and `help_titles`, and an **empty
`help` is what says the overlay is closed** — the page asks no question about
the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
~310 lines of content that is now shared. Neither end restates what the keys
are.
+19
View File
@@ -728,3 +728,22 @@ command. It failed on the first press past the end — client 0, host 4 — and
wire was changed to match the effect. Whether both should wrap instead is
improvise-trd3.
#### "? opens help, and its rows are the shared ones"
improvise-ktv1's acceptance in one place: **a browser shows help.** `?` had
entered `AppMode::Help` since the keymap was shared, and nothing appeared —
the mode changed and the page had no overlay, which is worse than a key that
does nothing because the user thinks they missed it.
It asserts the shape, not merely that something is visible: the active tab is
the shared module's page title (`Welcome`, then `Navigation` after `l`, which
also exercises `Command::HelpPage` folding on the client), and the body has a
key row. Two faults were injected and each failed it. Dropping the
`paintHelp(model)` call fails `toBeVisible`; removing the `mode != Help` guard
in `help_rows` — so the overlay is never empty and therefore never hidden —
fails `toBeHidden`. The second is the one worth having: an overlay that is
always open passes any check that only ever opens it.
It runs against the **assembled** site like every other test here, so it
covers `render.rs`, the serialized model, `web/app.js` and the CSS together.
-33
View File
@@ -381,16 +381,6 @@ would assert a flag nothing in production sets.
## `ui/app.rs`
### only_quit_has_no_hint
improvise-2hi: the old `hint_text` had a `_ => ""` arm, so the modes without
hints were invisible. The match is exhaustive now; this pins which modes are
deliberately blank.
Bug (improvise-1c9): `ExportPrompt` was blank too, so the status bar went empty at
the one moment the user has to be told what to type. Only `Quit` may be blank — it
is never rendered.
### model_state_and_view_state_are_constructible
improvise-3vr: `ModelState` and `ViewState` are the named slices of `App` state
@@ -1771,29 +1761,6 @@ It builds the context in the mode whose keymap it is walking, because a few pars
read `ctx.mode` — `commit-buffer item` resolves its target category off
`AppMode::ItemAdd`.
### every_hint_key_resolves_in_its_mode
improvise-2hi: `AppMode::hint_text` is the status bar's promise about what the keys
do, and nothing tied it to the keymaps. `only_quit_has_no_hint` asserts a hint is
*present*, never that it is *accurate*, so rebinding or dropping a key leaves the
hint quietly lying to the user. This walks every `Key:verb` token in every hint and
requires the key to resolve in that mode's keymap.
The hint is the source of truth for *which* keys are worth mentioning; the keymap is
the source of truth for what they do. **Only the first half is checkable from here**
— a hint that says `i:edit` where `i` enters records mode still passes.
(Gotcha 14.)
#### Helper — `hint_keys`
Not a test — the tokenizer that test walks each hint with.
Every key a hint mentions. Anything it cannot classify is a **panic, not a skip**: a
hint token it does not understand is exactly the token that would go unchecked.
Everything after the em dash is prose ("— type a category name"). A colon-less word
continues the previous hint's verb ("Enter:add & continue", ":q quit"); one with
nothing to continue is a token the test does not understand.
### prefix_keymaps_are_flat
improvise-jc8: an armed prefix is stored as the key that opened it and resolved
+38
View File
@@ -398,3 +398,41 @@ An expanded name the cache does not carry must not shift the flattening:
`expanded_cats` is per-client and survives a document swap, so it **routinely**
names categories that are gone. A flattening that indexed by expanded-name
would go off the end here.
### only_quit_advertises_nothing
improvise-2hi, carried over from `only_quit_has_no_hint` when the hints moved
out of `AppMode` (improvise-k099). The old `hint_text` had a `_ => ""` arm, so
the modes without hints were invisible; `ExportPrompt` was blank too
(improvise-1c9), and the status bar went empty at the one moment the user has
to be told what to type. Only `Quit` may say nothing — it is never rendered.
What it asserts now is one step stronger, because `hint_line` is derived: a
mode goes quiet not by someone forgetting a string but by every command it
advertises becoming unbound, which
`every_advertised_hint_names_a_binding_that_exists` catches first.
### every_advertised_hint_names_a_binding_that_exists
The replacement for `every_hint_key_resolves_in_its_mode` (improvise-2hi),
which is **deleted**, and the deletion is the point
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
That test existed because `AppMode::hint_text` was a hand-written string
promising what the keys do, with nothing tying it to the keymaps. It walked
every `Key:verb` token in every hint through a 60-line tokenizer that had to
know the hint dialect — named keys, `Ctrl-<c>`, `F/C/V` alternatives, runs like
`hjkl`, the em-dash prose tail, CommandMode's ex-commands — and panicked on a
token it could not classify, because a token it skipped was exactly the one
that would go unchecked.
None of that is needed now. A hint names a **command**, and its keys are looked
up in `Keymaps::default_keymaps()`, so "the hint names a key that resolves" is
true by construction and cannot be otherwise. What remains checkable, and is
what this test checks, is the other direction: a mode advertising a command
that nothing binds. That was never checkable before.
Note what neither version reaches, and gotcha 14 stated for the old one: that
the *caption* is accurate. `i:edit` where `i` enters records mode still passes.
The caption is English beside a command name, and only a reader can judge it.
+7 -88
View File
@@ -463,90 +463,6 @@ mod tests {
assert!(checked > 100, "expected to walk the keymaps, saw {checked}");
}
/// improvise-2hi: every `Key:verb` token in every hint must resolve in that
/// mode's keymap. Which keys are named, never what they do — see the evidence.
#[test]
fn every_hint_key_resolves_in_its_mode() {
let ks = KeymapSet::default_keymaps();
let mut checked = 0usize;
for (mode, km) in ks.maps().iter() {
// SearchMode is Normal plus a flag and has no hint of its own;
// `app_mode_for` hands back Normal's, which describes Normal.
if *mode == ModeKey::SearchMode {
continue;
}
let hint = app_mode_for(mode).hint_text();
for (key, mods) in hint_keys(hint) {
assert!(
km.lookup(key, mods).is_some(),
"{mode:?} hints {hint:?}, but {key:?}+{mods:?} is bound to nothing there"
);
checked += 1;
}
}
assert!(checked > 50, "expected to walk the hints, saw {checked}");
}
/// Every key a hint mentions. An unclassifiable token is a **panic, not a skip**:
/// that is exactly the token that would go unchecked.
fn hint_keys(hint: &str) -> Vec<(KeyCode, KeyModifiers)> {
// Everything after the em dash is prose ("— type a category name").
let head = hint.split('—').next().unwrap_or_default();
let mut keys = Vec::new();
let mut seen_hint = false;
for token in head.split_whitespace() {
match token.rsplit_once(':') {
// ":q", ":w" — CommandMode lists ex-commands, not keys.
// Normal's "::cmd" splits as (":", "cmd") and is a key.
Some(("", _)) => seen_hint = true,
Some((spec, _)) => {
keys.extend(hint_key_spec(spec, hint));
seen_hint = true;
}
// A colon-less word continues the previous hint's verb; one with
// nothing to continue is a token this test does not understand.
None => assert!(
seen_hint,
"hint token {token:?} in {hint:?} is not a Key:verb hint"
),
}
}
keys
}
/// The key-spec dialect the hints use: `Enter`, `Ctrl-e`, `F/C/V` for
/// alternatives, and a bare run of characters (`hjkl`, `[:]`) otherwise.
fn hint_key_spec(spec: &str, hint: &str) -> Vec<(KeyCode, KeyModifiers)> {
let plain = |code| vec![(code, KeyModifiers::NONE)];
match spec {
"" => panic!("empty key spec in hint {hint:?}"),
_ if spec.contains('/') => spec
.split('/')
.flat_map(|s| hint_key_spec(s, hint))
.collect(),
"Enter" => plain(KeyCode::Enter),
"Esc" => plain(KeyCode::Esc),
"Tab" => plain(KeyCode::Tab),
"S-Tab" => plain(KeyCode::BackTab),
"Space" => plain(KeyCode::Char(' ')),
"BS" => plain(KeyCode::Backspace),
"Del" => plain(KeyCode::Delete),
_ => match spec
.strip_prefix("Ctrl-")
.map(|r| r.chars().collect::<Vec<_>>())
{
Some(rest) if rest.len() == 1 => {
vec![(KeyCode::Char(rest[0]), KeyModifiers::CONTROL)]
}
Some(_) => panic!("unreadable key spec {spec:?} in hint {hint:?}"),
None => spec
.chars()
.map(|c| (KeyCode::Char(c), KeyModifiers::NONE))
.collect(),
},
}
}
/// A representative `AppMode` for each keymap, so a test can put a
/// `CmdContext` in the mode whose bindings it is walking.
fn app_mode_for(mode: &ModeKey) -> AppMode {
@@ -793,7 +709,7 @@ mod tests {
&append_target(&ks, &mode),
prompted,
"{} prompts over \"{prompted}\" but types elsewhere",
mode.label()
improvise_protocol::mode_label(&mode)
);
}
}
@@ -932,9 +848,12 @@ mod binding_dump {
None => "-".to_string(),
Some(Binding::Cmd { name, args }) => format!("cmd {name} {args:?}"),
Some(Binding::Prefix(sub)) => {
let mut hints = sub.binding_hints();
hints.sort();
format!("prefix {hints:?}")
let rows: Vec<(Vec<String>, &str)> =
improvise_protocol::help::which_key(sub)
.into_iter()
.map(|hint| (hint.keys, hint.command))
.collect();
format!("prefix {rows:?}")
}
Some(Binding::Sequence(steps)) => format!("seq {steps:?}"),
};
+8 -16
View File
@@ -559,9 +559,10 @@ impl App {
self.view_state.mode = AppMode::ImportWizard;
}
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
self.view_state.mode.hint_text()
/// Hint text for the status bar (context-sensitive). Derived from the
/// keymap, so it cannot go stale against it — protocol.md §8.
pub fn hint_text(&self) -> String {
improvise_protocol::hint_line(&self.view_state.mode)
}
}
@@ -596,7 +597,10 @@ mod tests {
#[test]
fn every_mode_has_a_distinct_label() {
let labels: Vec<&str> = all_modes().iter().map(|m| m.label()).collect();
let labels: Vec<&str> = all_modes()
.iter()
.map(improvise_protocol::mode_label)
.collect();
assert!(labels.iter().all(|l| !l.is_empty()));
let mut sorted = labels.clone();
sorted.sort_unstable();
@@ -604,18 +608,6 @@ mod tests {
assert_eq!(sorted.len(), labels.len(), "labels collide: {labels:?}");
}
/// improvise-2hi/1c9: `hint_text` used to have a `_ => ""` arm. Only `Quit`
/// may be blank — it is never rendered.
#[test]
fn only_quit_has_no_hint() {
let blank: Vec<&str> = all_modes()
.iter()
.filter(|m| m.hint_text().is_empty())
.map(|m| m.label())
.collect();
assert_eq!(blank, vec!["QUIT"]);
}
#[test]
fn panel_sub_modes_report_their_parent_panel() {
assert_eq!(AppMode::FormulaPanel.panel(), Some(Panel::Formula));
+932
View File
@@ -0,0 +1,932 @@
//! 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 std::sync::OnceLock;
use crate::keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
use crate::{AppMode, HELP_PAGE_COUNT};
/// The one keymap set these are derived against, built once.
fn keymaps() -> &'static Keymaps {
static KEYMAPS: OnceLock<Keymaps> = OnceLock::new();
KEYMAPS.get_or_init(Keymaps::default_keymaps)
}
/// 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, derived from the keymap and never spelled.
pub keys: Vec<String>,
pub command: &'static str,
pub caption: &'static str,
}
/// 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.
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| format!("{}:{}", compact(&hint.keys), hint.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: &[String]) -> String {
let plain: Vec<&str> = keys
.iter()
.map(String::as_str)
.filter(|k| !k.starts_with("Ctrl-") && !k.starts_with("Alt-"))
.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<String> {
let mut keys = match keymaps().get(mode) {
Some(keymap) => keymap.labels_for(command, args),
None => Vec::new(),
};
keys.sort();
keys.dedup();
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![crate::keymap::key_label(code, *mods)],
command,
caption: describe(command),
})
})
.collect();
hints.sort_by(|a, b| a.keys.cmp(&b.keys));
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",
"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,
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"),
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"),
]
.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"),
keys(n, "enter-advance", None, "Edit current cell (same as i)"),
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::*;
fn all_modes() -> Vec<AppMode> {
vec![
AppMode::Normal,
AppMode::editing(),
AppMode::formula_edit(),
AppMode::FormulaPanel,
AppMode::CategoryPanel,
AppMode::category_add(),
AppMode::item_add("Region".into()),
AppMode::ViewPanel,
AppMode::TileSelect,
AppMode::ImportWizard,
AppMode::export_prompt(),
AppMode::save_prompt(),
AppMode::command_mode(),
AppMode::Help,
AppMode::Quit,
AppMode::RecordsNormal,
AppMode::records_editing(),
]
}
/// 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 all_modes() {
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> = all_modes()
.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.keys.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"
);
}
/// 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"
);
}
/// 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 all_modes() {
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}"),
}
}
}
}
}
+54 -31
View File
@@ -7,9 +7,25 @@ use std::sync::Arc;
use crate::key::{KeyCode, KeyModifiers};
use crate::{AppMode, Panel};
/// Format a KeyCode as a short human-readable label for which-key display.
/// How a key is written anywhere a person reads it, modifiers included. The
/// one spelling; `context/code/protocol.md` §8.
pub fn key_label(code: &KeyCode, mods: KeyModifiers) -> String {
let base = format_key_label(code);
match (
mods.contains(KeyModifiers::CONTROL),
mods.contains(KeyModifiers::ALT),
) {
(true, true) => format!("Ctrl-Alt-{base}"),
(true, false) => format!("Ctrl-{base}"),
(false, true) => format!("Alt-{base}"),
(false, false) => base,
}
}
fn format_key_label(code: &KeyCode) -> String {
match code {
KeyCode::Char(' ') => "Space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Esc => "Esc".to_string(),
@@ -186,41 +202,30 @@ impl Keymap {
.insert(KeyPattern::Any, Binding::Cmd { name, args: vec![] });
}
/// Human-readable hints for this keymap's concrete bindings, for the
/// which-key popup after a prefix key.
pub fn binding_hints(&self) -> Vec<(String, &'static str)> {
let mut hints: Vec<(String, &'static str)> = self
.bindings
.iter()
.filter_map(|(pattern, binding)| {
let label = match pattern {
KeyPattern::Key(code, _) => format_key_label(code),
KeyPattern::AnyChar | KeyPattern::Any => return None,
};
let name = match binding {
Binding::Cmd { name, .. } => *name,
Binding::Prefix(_) => return None,
Binding::Sequence(steps) => {
if let Some((name, _)) = steps.first() {
*name
} else {
return None;
}
}
};
Some((label, name))
})
.collect();
hints.sort_by(|a, b| a.0.cmp(&b.0));
hints
}
/// This keymap's own bindings, nothing inherited. For tests that must
/// walk the table rather than probe it.
pub fn bindings(&self) -> &HashMap<KeyPattern, Binding> {
&self.bindings
}
/// Every key label here or in a parent that runs `command`. `args` of
/// `None` accepts any; §8 says why that is the useful granularity.
pub fn labels_for(&self, command: &str, args: Option<&[String]>) -> Vec<String> {
let mut labels: Vec<String> = self
.bindings
.iter()
.filter(|(_, binding)| runs(binding, command, args))
.filter_map(|(pattern, _)| match pattern {
KeyPattern::Key(code, mods) => Some(key_label(code, *mods)),
KeyPattern::AnyChar | KeyPattern::Any => None,
})
.collect();
if let Some(parent) = &self.parent {
labels.extend(parent.labels_for(command, args));
}
labels
}
/// Look up the binding for a key in this keymap's own bindings.
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
@@ -250,6 +255,19 @@ impl Keymap {
}
}
/// Does this binding run `command`? A `Sequence` answers for its **first**
/// step, which is what the which-key popup has always reported.
fn runs(binding: &Binding, command: &str, args: Option<&[String]>) -> bool {
let named = match binding {
Binding::Cmd { name, args } => Some((*name, args)),
Binding::Sequence(steps) => steps.first().map(|(name, args)| (*name, args)),
Binding::Prefix(_) => None,
};
named.is_some_and(|(name, bound)| {
name == command && args.is_none_or(|wanted| bound.as_slice() == wanted)
})
}
/// Builder for a minibuffer mode's keymap: the append/pop/Esc-clears spine
/// every text-entry mode shares. See `context/code/protocol.md` §6.
pub struct TextEntry {
@@ -389,7 +407,12 @@ fn bind_panel_toggles(km: &mut Keymap) {
fn buffer_of(mode: &AppMode) -> &str {
&mode
.minibuffer()
.unwrap_or_else(|| panic!("{} has no minibuffer to type into", mode.label()))
.unwrap_or_else(|| {
panic!(
"{} has no minibuffer to type into",
crate::help::mode_label(mode)
)
})
.buffer_key
}
+2 -56
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
pub mod basis;
pub mod client;
pub mod frame;
pub mod help;
pub mod key;
pub mod keymap;
mod reduce;
@@ -17,6 +18,7 @@ pub mod viewmodel;
pub use basis::{Basis, BasisCache, Rejection};
pub use client::ClientState;
pub use frame::{ClientFrame, ServerFrame};
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
pub use key::{KeyCode, KeyEvent, KeyModifiers};
pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
pub use reduce::{Command, HelpPage, ModelCommand, reduce_view};
@@ -188,62 +190,6 @@ impl AppMode {
}
}
/// Short uppercase name shown in the title bar.
pub fn label(&self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::Editing { .. } => "INSERT",
Self::FormulaEdit { .. } => "FORMULA",
Self::FormulaPanel => "FORMULAS",
Self::CategoryPanel => "CATEGORIES",
Self::CategoryAdd { .. } => "NEW CATEGORY",
Self::ItemAdd { .. } => "ADD ITEMS",
Self::ViewPanel => "VIEWS",
Self::TileSelect => "TILES",
Self::ImportWizard => "IMPORT",
Self::ExportPrompt { .. } => "EXPORT",
Self::SavePrompt { .. } => "SAVE AS",
Self::CommandMode { .. } => "COMMAND",
Self::Help => "HELP",
Self::Quit => "QUIT",
Self::RecordsNormal => "RECORDS",
Self::RecordsEditing { .. } => "RECORDS INSERT",
}
}
/// Context-sensitive keybinding hint for the status bar. `Quit` alone
/// shows nothing, because it is never rendered.
pub fn hint_text(&self) -> &'static str {
match self {
Self::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
Self::Editing { .. } | Self::RecordsEditing { .. } => {
"Enter:commit Tab:commit+right Esc:cancel"
}
Self::RecordsNormal => "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd",
Self::FormulaPanel => "n:new d:delete jk:nav Esc:back",
Self::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
Self::CategoryPanel => {
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
}
Self::CategoryAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type a category name"
}
Self::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
Self::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
Self::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
Self::CommandMode { .. } => {
":q quit :w save :import :add-cat :formula :show-item :help"
}
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
Self::Help => "h/l:pages q/Esc:close",
Self::ExportPrompt { .. } => "Enter:export Esc:cancel — type a file path",
Self::SavePrompt { .. } => "Enter:save Esc:cancel — type a file path",
Self::Quit => "",
}
}
/// The side panel this mode is focused in. A panel's sub-modes count as
/// that panel, so its styling survives the user typing into it.
pub fn panel(&self) -> Option<Panel> {
+3 -3
View File
@@ -873,7 +873,7 @@ mod tests {
value: "Regi".into(),
}]),
"{} should type locally",
mode.label()
crate::help::mode_label(&mode)
);
}
}
@@ -917,7 +917,7 @@ mod tests {
value: "Reg".into(),
}]),
"{} should pop locally",
mode.label()
crate::help::mode_label(&mode)
);
}
}
@@ -1198,7 +1198,7 @@ mod tests {
resolve(&in_mode(mode.clone()), ev(code)),
KeyOutcome::Ignored,
"{} / {code:?}",
mode.label()
crate::help::mode_label(&mode)
);
}
}
+76 -5
View File
@@ -31,9 +31,14 @@ pub struct RenderModel {
pub categories: Vec<RenderCatRow>,
/// Whether the panel is open, so the page does not decide.
pub category_panel_open: bool,
/// The keys this mode offers, off `AppMode::hint_text()` — the same string
/// the TUI's footer draws, so the page spells no keys of its own.
/// The keys this mode offers, off `help::hint_line` — the same string the
/// TUI's footer draws, so the page spells no keys of its own.
pub hint: String,
/// The help overlay's rows, **empty unless it is open**. Shared content
/// (improvise-ktv1); the page does the painting and nothing else.
pub help: Vec<RenderHelpRow>,
pub help_page: usize,
pub help_titles: Vec<String>,
/// What the user has typed so far while a keyboard edit is open on the
/// selected cell. The decision is made here — `context/code/clients.md`.
pub editing: Option<String>,
@@ -142,7 +147,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
RenderModel {
revision: state.revision,
mode: state.view.mode.label().to_string(),
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
status: state.view.status_msg.clone(),
view_name: state.view.active_view.clone(),
col_levels: col_levels(cols),
@@ -189,7 +194,13 @@ pub fn render_model(state: &ClientState) -> RenderModel {
.map(|(i, entry)| RenderCatRow::of(entry, i == state.view.cat_panel_cursor))
.collect(),
category_panel_open: state.view.category_panel_open,
hint: state.view.mode.hint_text().to_string(),
hint: improvise_protocol::hint_line(&state.view.mode),
help: help_rows(&state.view),
help_page: state.view.help_page,
help_titles: improvise_protocol::help::pages()
.into_iter()
.map(|page| page.title.to_string())
.collect(),
editing: state
.view
.mode
@@ -198,6 +209,52 @@ pub fn render_model(state: &ClientState) -> RenderModel {
}
}
/// One help-overlay row, already resolved: the keys (empty for prose), the
/// description, and which of the shared line kinds it is.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RenderHelpRow {
pub keys: String,
pub description: String,
/// `blank`, `banner`, `heading`, `text`, `accent`, `keys` or `dim` — the
/// page styles it; it decides nothing.
pub kind: &'static str,
}
impl RenderHelpRow {
fn of(line: &improvise_protocol::HelpLine) -> Self {
use improvise_protocol::HelpLine as L;
let prose = |kind, text: &str| Self {
keys: text.to_string(),
description: String::new(),
kind,
};
match line {
L::Blank => prose("blank", ""),
L::Banner(t) => prose("banner", t),
L::Heading(t) => prose("heading", t),
L::Text(t) => prose("text", t),
L::Accent(t) => prose("accent", t),
L::Keys { keys, description } => Self {
keys: keys.join(" "),
description: description.to_string(),
kind: "keys",
},
L::Literal {
keys,
description,
dim,
} => Self {
keys: (*keys).to_string(),
description: description.to_string(),
kind: match dim {
true => "dim",
false => "keys",
},
},
}
}
}
impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry {
@@ -226,6 +283,17 @@ impl RenderCatRow {
}
}
/// The overlay's rows, and **only while it is open**: an empty list is what
/// tells the page not to draw one. Shared content, per-front-end painting.
fn help_rows(view: &improvise_protocol::ViewState) -> Vec<RenderHelpRow> {
if view.mode != improvise_protocol::AppMode::Help {
return Vec::new();
}
let pages = improvise_protocol::help::pages();
let page = pages.get(view.help_page).unwrap_or(&pages[0]);
page.lines.iter().map(RenderHelpRow::of).collect()
}
/// The column header lines: one per level, blanked where a label repeats.
/// Exactly `AxisFrame::shows_level`; the suppression rule is not restated.
fn col_levels(cols: &AxisFrame) -> Vec<Vec<String>> {
@@ -492,7 +560,10 @@ mod tests {
let mut client = init();
client.state_mut().fold(&Command::SetStatus("saved".into()));
let model = client.model();
assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label());
assert_eq!(
model.mode,
improvise_protocol::mode_label(&improvise_protocol::AppMode::Normal)
);
assert_eq!(model.status, "saved");
assert!(
model.hint.contains("i:edit"),
+12 -6
View File
@@ -53,7 +53,10 @@ fn a_locally_typed_character_lands_where_the_host_would_put_it() {
let event = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{} should type locally", mode.label());
panic!(
"{} should type locally",
improvise_protocol::mode_label(&mode)
);
};
for cmd in &cmds {
client.fold(cmd);
@@ -64,13 +67,13 @@ fn a_locally_typed_character_lands_where_the_host_would_put_it() {
client.view.buffers,
app.view_state.buffers,
"{}: client and host disagree about the buffers after typing",
mode.label()
improvise_protocol::mode_label(&mode)
);
assert_eq!(
client.view.buffers.get(&buffer).map(String::as_str),
Some("Regi"),
"{}: the character must be appended, not replace the buffer",
mode.label()
improvise_protocol::mode_label(&mode)
);
}
}
@@ -96,7 +99,10 @@ fn a_locally_popped_character_lands_where_the_host_would_put_it() {
let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{} should pop locally", mode.label());
panic!(
"{} should pop locally",
improvise_protocol::mode_label(&mode)
);
};
for cmd in &cmds {
client.fold(cmd);
@@ -107,7 +113,7 @@ fn a_locally_popped_character_lands_where_the_host_would_put_it() {
client.view.buffers,
app.view_state.buffers,
"{}: client and host disagree about the buffers after a pop",
mode.label()
improvise_protocol::mode_label(&mode)
);
}
}
@@ -836,7 +842,7 @@ fn a_locally_committed_minibuffer_agrees_with_the_host() {
assert!(
app.view_state.status_msg.starts_with("Added"),
"{:?}: the commit must have added something: {:?}",
mode.label(),
improvise_protocol::mode_label(&mode),
app.view_state.status_msg
);
}
+23
View File
@@ -128,3 +128,26 @@ test.describe("the standalone deployment", () => {
await expect(page.locator("#grid input")).toHaveCount(0);
});
});
test.describe("the help overlay", () => {
// improvise-ktv1. `?` has entered AppMode::Help in the browser since the
// keymap was shared; what was missing was anything to draw.
test("? opens help, and its rows are the shared ones", async ({ page }) => {
await page.goto("/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#help")).toBeHidden();
await page.locator("#grid").focus();
await page.keyboard.press("?");
await expect(page.locator("#help")).toBeVisible({ timeout: 10_000 });
// A title from improvise_protocol::help::pages(), and a keys row whose
// key came off the keymap rather than out of the page.
await expect(page.locator(".help-tab.active")).toHaveText("Welcome");
await page.keyboard.press("l");
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
await expect(page.locator(".help-body dt").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#help")).toBeHidden();
});
});
+8 -6
View File
@@ -248,7 +248,7 @@ fn draw(f: &mut Frame, app: &App) {
// Which-key popup. The armed prefix is stored as the key that opened it;
// the keymap set resolves it (improvise-jc8).
if let Some(km) = app.armed_prefix_keymap() {
let hints = km.binding_hints();
let hints = crate::protocol::help::which_key(&km);
f.render_widget(WhichKeyWidget::new(&hints), size);
}
}
@@ -414,10 +414,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
String::new()
};
let msg = if !app.view_state.status_msg.is_empty() {
app.view_state.status_msg.as_str()
} else {
app.hint_text()
let msg = match app.view_state.status_msg.is_empty() {
false => app.view_state.status_msg.clone(),
true => app.hint_text(),
};
let yank_indicator = if app.view_state.yanked.is_some() {
@@ -430,7 +429,10 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
app.model_state.workbook.active_view, yank_indicator
);
let left = format!(" {}{search_part} {msg}", app.view_state.mode.label());
let left = format!(
" {}{search_part} {msg}",
crate::protocol::mode_label(&app.view_state.mode)
);
let line = fill_line(left, &view_badge, area.width);
f.render_widget(
+54 -364
View File
@@ -5,6 +5,8 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget},
};
use crate::protocol::help::{self, HelpLine};
/// Number of help pages available (defined alongside the help-page state).
pub use crate::ui::app::HELP_PAGE_COUNT;
@@ -35,371 +37,57 @@ impl HelpStyles {
}
}
/// A styled line in a help page: two columns (key + description) plus a style.
/// When `desc` is empty the `key` text spans the full width.
struct HelpLine {
key: &'static str,
/// One drawn row: two columns and a style. The **content** is
/// `improvise_protocol::help`'s; only the styling is this file's (gotcha 26).
struct Row {
key: String,
desc: &'static str,
style: Style,
}
impl HelpLine {
fn heading(text: &'static str, styles: &HelpStyles) -> Self {
Self {
key: text,
desc: "",
style: styles.heading,
}
}
fn key(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self {
Self {
key: k,
desc: d,
style: styles.key,
}
}
fn dim(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self {
Self {
key: k,
desc: d,
style: styles.dim,
}
}
fn text(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.normal,
}
}
fn accent(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.accent,
}
}
fn banner(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.banner,
}
}
fn blank() -> Self {
Self {
key: "",
desc: "",
style: Style::default(),
}
}
/// The page titles shown in the tab bar, in the shared module's order.
fn page_titles() -> Vec<&'static str> {
help::pages().into_iter().map(|page| page.title).collect()
}
/// The page titles shown in the tab bar.
const PAGE_TITLES: [&str; HELP_PAGE_COUNT] = [
"Welcome",
"Navigation",
"Editing",
"Panels & Views",
"Commands",
];
// ── Page content builders ───────────────────────────────────────────────────
fn page_welcome(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::banner(" improvise", s),
HelpLine::text(
" A multi-dimensional data modeling tool in your terminal.",
s,
),
HelpLine::blank(),
HelpLine::text(
" improvise lets you build spreadsheet-like models organized",
s,
),
HelpLine::text(
" by categories (dimensions). Each category has items, and",
s,
),
HelpLine::text(
" every combination of items across categories forms a cell.",
s,
),
HelpLine::text(
" Think of it like a pivot table you can build from scratch.",
s,
),
HelpLine::blank(),
HelpLine::heading("Quick start", s),
HelpLine::blank(),
HelpLine::text(" 1. Create categories (dimensions) for your model:", s),
HelpLine::accent(" :add-cat Region :add-cat Product", s),
HelpLine::blank(),
HelpLine::text(" 2. Add items to each category:", s),
HelpLine::accent(" :add-items Region North South East West", s),
HelpLine::accent(" :add-items Product Widget Gadget", s),
HelpLine::blank(),
HelpLine::text(
" 3. Navigate with hjkl or arrow keys and press i to edit cells.",
s,
),
HelpLine::blank(),
HelpLine::text(" 4. Add formulas to compute values automatically:", s),
HelpLine::accent(" :formula Product Total = Widget + Gadget", s),
HelpLine::blank(),
HelpLine::text(" 5. Save your work:", s),
HelpLine::accent(" :w mymodel.improv", s),
HelpLine::blank(),
HelpLine::heading("Core concepts", s),
HelpLine::blank(),
HelpLine::text(
" Category A dimension of your data (e.g. Region, Time, Product).",
s,
),
HelpLine::text(
" Item A member of a category (e.g. North, Q1, Widget).",
s,
),
HelpLine::text(
" View A saved layout: which categories go on rows, columns, or pages.",
s,
),
HelpLine::text(
" Tile The row/column/page assignment of a category.",
s,
),
HelpLine::text(
" Formula A computed item: derives its value from other items.",
s,
),
HelpLine::blank(),
HelpLine::dim(" Tip: press Tab or l/n to go to the next page.", "", s),
]
}
fn page_navigation(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Cursor movement", s),
HelpLine::blank(),
HelpLine::key(" hjkl / Arrow keys", "Move one cell", s),
HelpLine::key(" gg", "Jump to first row", s),
HelpLine::key(" G", "Jump to last row", s),
HelpLine::key(" 0 / Home", "Jump to first column", s),
HelpLine::key(" $ / End", "Jump to last column", s),
HelpLine::blank(),
HelpLine::heading("Scrolling", s),
HelpLine::blank(),
HelpLine::key(" Ctrl+D", "Scroll half-page down", s),
HelpLine::key(" Ctrl+U", "Scroll half-page up", s),
HelpLine::key(" PageDown", "Scroll three-quarters page down", s),
HelpLine::key(" PageUp", "Scroll three-quarters page up", s),
HelpLine::blank(),
HelpLine::heading("Page-axis cycling", s),
HelpLine::blank(),
HelpLine::text(" When a category is on the Page axis, only one item is", s),
HelpLine::text(
" visible at a time. Use [ and ] to cycle through them.",
s,
),
HelpLine::blank(),
HelpLine::key(" [", "Previous page-axis item", s),
HelpLine::key(" ]", "Next page-axis item", s),
HelpLine::blank(),
HelpLine::heading("Search", s),
HelpLine::blank(),
HelpLine::key(
" /",
"Start search — type a pattern, matching cells highlight",
s,
),
HelpLine::key(" n", "Jump to next match", s),
HelpLine::key(" N", "Jump to previous match", s),
HelpLine::key(" Esc or Enter", "Exit search mode", s),
HelpLine::blank(),
HelpLine::heading("View history", s),
HelpLine::blank(),
HelpLine::key(" >", "Drill into selected cell (record view)", s),
HelpLine::key(" <", "Go back to previous view", s),
]
}
fn page_editing(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Entering edit mode", s),
HelpLine::blank(),
HelpLine::key(" i / a", "Edit current cell (insert mode)", s),
HelpLine::key(" Enter", "Edit current cell (same as i)", s),
HelpLine::key(" Esc", "Cancel edit, return to Normal mode", s),
HelpLine::blank(),
HelpLine::heading("While editing", s),
HelpLine::blank(),
HelpLine::text(" Type normally to enter a value. Values can be:", s),
HelpLine::accent(" Numbers: 42 3.14 -100", s),
HelpLine::accent(" Text: hello world", s),
HelpLine::blank(),
HelpLine::key(" Enter", "Commit value and move down", s),
HelpLine::key(
" Tab",
"Commit value and move right (stay in edit mode)",
s,
),
HelpLine::key(" Esc", "Discard edits and return to Normal", s),
HelpLine::blank(),
HelpLine::heading("Copy and paste", s),
HelpLine::blank(),
HelpLine::key(" yy", "Yank (copy) the current cell value", s),
HelpLine::key(" p", "Paste the yanked value into the current cell", s),
HelpLine::blank(),
HelpLine::heading("Cell operations", s),
HelpLine::blank(),
HelpLine::key(" x", "Clear the current cell", s),
HelpLine::blank(),
HelpLine::heading("Formulas", s),
HelpLine::blank(),
HelpLine::text(
" Formulas are computed items. A formula belongs to a category",
s,
),
HelpLine::text(
" and derives its value from other items in that category.",
s,
),
HelpLine::blank(),
HelpLine::text(
" Example: in a Product category with items Widget and Gadget:",
s,
),
HelpLine::accent(" :formula Product Total = Widget + Gadget", s),
HelpLine::blank(),
HelpLine::text(" Supported operators: + - * /", s),
HelpLine::text(
" Formula cells update automatically when source values change.",
s,
),
]
}
fn page_panels(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Side panels", s),
HelpLine::blank(),
HelpLine::text(
" Panels open on the right side of the screen and give you",
s,
),
HelpLine::text(" quick access to formulas, categories, and views.", s),
HelpLine::blank(),
HelpLine::key(" F", "Toggle Formula panel", s),
HelpLine::dim(" n", "New formula", s),
HelpLine::dim(" d", "Delete selected formula", s),
HelpLine::blank(),
HelpLine::key(" C", "Toggle Category panel", s),
HelpLine::dim(" n", "New category", s),
HelpLine::dim(" a", "Add items to selected category", s),
HelpLine::dim(" d", "Delete selected category/item", s),
HelpLine::blank(),
HelpLine::key(" N", "Quick-add a new category (from anywhere)", s),
HelpLine::blank(),
HelpLine::key(" V", "Toggle View panel", s),
HelpLine::dim(" n", "New view", s),
HelpLine::dim(" d", "Delete selected view", s),
HelpLine::dim(" Enter", "Switch to selected view", s),
HelpLine::blank(),
HelpLine::key(" Tab", "Cycle focus between open panels", s),
HelpLine::blank(),
HelpLine::heading("Tile select mode (T)", s),
HelpLine::blank(),
HelpLine::text(" Tiles control which axis each category is placed on.", s),
HelpLine::text(" Press T to enter tile-select mode, then:", s),
HelpLine::blank(),
HelpLine::key(" h / l (← →)", "Select previous / next category tile", s),
HelpLine::key(" Space / Enter", "Cycle axis: Row → Col → Page", s),
HelpLine::key(" r", "Set axis to Row", s),
HelpLine::key(" c", "Set axis to Col", s),
HelpLine::key(" p", "Set axis to Page", s),
HelpLine::key(" Esc", "Exit tile-select mode", s),
HelpLine::blank(),
HelpLine::heading("Groups and visibility", s),
HelpLine::blank(),
HelpLine::key(" z", "Toggle collapse of nearest group above cursor", s),
HelpLine::key(" H", "Hide current row item", s),
HelpLine::dim(" :show-item <cat> <item>", "Restore a hidden item", s),
]
}
fn page_commands(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Command line ( : )", s),
HelpLine::blank(),
HelpLine::text(
" Press : to open the command line. Commands are entered",
s,
),
HelpLine::text(" vim-style and executed with Enter. Esc cancels.", s),
HelpLine::blank(),
HelpLine::heading("File operations", s),
HelpLine::blank(),
HelpLine::key(" :w [path]", "Save (path optional after first save)", s),
HelpLine::key(" :wq", "Save and quit", s),
HelpLine::key(" :q", "Quit (warns if unsaved changes)", s),
HelpLine::key(" :q!", "Force quit without saving", s),
HelpLine::key(" ZZ", "Save and quit (same as :wq)", s),
HelpLine::key(" Ctrl+S", "Save (same as :w)", s),
HelpLine::blank(),
HelpLine::heading("Import and export", s),
HelpLine::blank(),
HelpLine::key(" :import <path>", "Open the JSON/CSV import wizard", s),
HelpLine::key(" :export [path.csv]", "Export the active view to CSV", s),
HelpLine::blank(),
HelpLine::heading("Model building", s),
HelpLine::blank(),
HelpLine::key(" :add-cat <name>", "Add a new category", s),
HelpLine::key(" :add-item <cat> <item>", "Add one item to a category", s),
HelpLine::key(
" :add-items <cat> a b c ...",
"Add multiple items at once",
s,
),
HelpLine::key(" :formula <cat> <Name=expr>", "Add a formula", s),
HelpLine::blank(),
HelpLine::heading("Views", s),
HelpLine::blank(),
HelpLine::key(" :add-view [name]", "Create a new view", s),
HelpLine::blank(),
HelpLine::heading("Display", s),
HelpLine::blank(),
HelpLine::key(" :set-format <fmt>", "Set number format (e.g. ',.2')", s),
HelpLine::blank(),
HelpLine::heading("Other keys", s),
HelpLine::blank(),
HelpLine::key(" ? or F1", "Open this help screen", s),
HelpLine::key(" :help", "Open this help screen", s),
]
}
/// Builds the content lines for a given page index.
fn page_content(page: usize) -> Vec<HelpLine> {
let styles = HelpStyles::new();
match page {
0 => page_welcome(&styles),
1 => page_navigation(&styles),
2 => page_editing(&styles),
3 => page_panels(&styles),
4 => page_commands(&styles),
_ => page_welcome(&styles),
}
/// A page's lines, resolved to terminal styles. An out-of-range index draws
/// the first page, as it always has.
fn page_content(page: usize) -> Vec<Row> {
let s = HelpStyles::new();
let pages = help::pages();
let page = pages.get(page).unwrap_or(&pages[0]);
let plain = |text: &'static str, style| Row {
key: text.to_string(),
desc: "",
style,
};
page.lines
.iter()
.map(|line| match line {
HelpLine::Blank => plain("", Style::default()),
HelpLine::Banner(text) => plain(text, s.banner),
HelpLine::Heading(text) => plain(text, s.heading),
HelpLine::Text(text) => plain(text, s.normal),
HelpLine::Accent(text) => plain(text, s.accent),
HelpLine::Keys { keys, description } => Row {
key: format!(" {}", keys.join(" ")),
desc: description,
style: s.key,
},
HelpLine::Literal {
keys,
description,
dim,
} => Row {
key: format!(" {keys}"),
desc: description,
style: match dim {
true => s.dim,
false => s.key,
},
},
})
.collect()
}
// ── Widget ──────────────────────────────────────────────────────────────────
@@ -469,11 +157,13 @@ impl Widget for HelpWidget {
if line.desc.is_empty() {
// Single-column line (headings, text, blanks)
buf.set_string(inner.x, ly, line.key, line.style);
buf.set_string(inner.x, ly, &line.key, line.style);
} else {
// Two-column line: key on the left, description on the right
buf.set_string(inner.x, ly, line.key, line.style);
let desc_x = inner.x + key_col_width as u16;
// Two-column line: key on the left, description on the right.
// A derived key list can outgrow the column, so it pushes.
buf.set_string(inner.x, ly, &line.key, line.style);
let pushed = line.key.chars().count() as u16 + 2;
let desc_x = inner.x + (key_col_width as u16).max(pushed);
if desc_x < inner.x + inner.width {
buf.set_string(desc_x, ly, line.desc, normal_style);
}
@@ -508,7 +198,7 @@ fn render_tab_bar(buf: &mut Buffer, x: u16, y: u16, width: u16, active_page: usi
let mut col = x;
let max_col = x + width;
for (i, title) in PAGE_TITLES.iter().enumerate() {
for (i, title) in page_titles().iter().enumerate() {
if col >= max_col {
break;
}
+9 -8
View File
@@ -1,3 +1,4 @@
use crate::protocol::Hint;
use ratatui::{
buffer::Buffer,
layout::Rect,
@@ -6,13 +7,13 @@ use ratatui::{
};
/// A compact popup showing available key completions after a prefix key,
/// Emacs which-key style.
/// Emacs which-key style. The rows are `crate::protocol::help::which_key`'s.
pub struct WhichKeyWidget<'a> {
hints: &'a [(String, &'static str)],
hints: &'a [Hint],
}
impl<'a> WhichKeyWidget<'a> {
pub fn new(hints: &'a [(String, &'static str)]) -> Self {
pub fn new(hints: &'a [Hint]) -> Self {
Self { hints }
}
}
@@ -27,7 +28,7 @@ impl Widget for WhichKeyWidget<'_> {
let content_width = self
.hints
.iter()
.map(|(k, cmd)| k.len() + 2 + cmd.len())
.map(|hint| hint.keys.concat().len() + 2 + hint.caption.len())
.max()
.unwrap_or(10);
let popup_w = (content_width as u16 + 4).min(area.width); // +4 for border + padding
@@ -52,15 +53,15 @@ impl Widget for WhichKeyWidget<'_> {
.add_modifier(Modifier::BOLD);
let cmd_style = Style::default().fg(Color::Gray);
for (i, (key_label, cmd_name)) in self.hints.iter().enumerate() {
for (i, hint) in self.hints.iter().enumerate() {
if i >= inner.height as usize {
break;
}
let y = inner.y + i as u16;
buf.set_string(inner.x + 1, y, key_label, key_style);
let cmd_x = inner.x + 4; // fixed column for command names
buf.set_string(inner.x + 1, y, hint.keys.concat(), key_style);
let cmd_x = inner.x + 4; // fixed column for what the key does
if cmd_x < inner.x + inner.width {
buf.set_string(cmd_x, y, cmd_name, cmd_style);
buf.set_string(cmd_x, y, hint.caption, cmd_style);
}
}
}
+43 -2
View File
@@ -31,6 +31,7 @@ const el = {
categories: document.getElementById("categories"),
mode: document.getElementById("mode"),
hint: document.getElementById("hint"),
help: document.getElementById("help"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
@@ -177,9 +178,10 @@ function repaint() {
painted = model.revision;
el.mode.textContent = model.mode;
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
// keys itself; see `RenderModel::hint`.
// The mode's own hint, off `help::hint_line`. The page spells no keys
// itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
paintHelp(model);
modelStatus = model.status;
paintStatus();
el.viewName.textContent = model.view_name;
@@ -197,6 +199,45 @@ function repaint() {
if (changed) sendViewport();
}
/** The help overlay. An empty `help` is what says it is closed -- the page
* asks no question about the mode, and writes none of the content: the rows,
* their keys and the page titles are `improvise_protocol::help`'s, the same
* ones the TUI draws (improvise-ktv1). */
function paintHelp(model) {
el.help.hidden = model.help.length === 0;
if (el.help.hidden) {
el.help.replaceChildren();
return;
}
const tabs = document.createElement("div");
tabs.className = "help-tabs";
model.help_titles.forEach((title, i) => {
const tab = document.createElement("span");
tab.textContent = title;
tab.className = i === model.help_page ? "help-tab active" : "help-tab";
tabs.append(tab);
});
const body = document.createElement("dl");
body.className = "help-body";
for (const row of model.help) {
const keys = document.createElement("dt");
keys.textContent = row.keys;
keys.className = `help-${row.kind}`;
body.append(keys);
// A prose row has no description and spans both columns (its class says
// so); emitting an empty `dd` beside it would leave a blank grid cell.
if (row.description) {
const desc = document.createElement("dd");
desc.textContent = row.description;
body.append(desc);
}
}
el.help.replaceChildren(tabs, body);
}
/** The grid, painted from a model in which every frame decision is already
* made (improvise-anr9). The page decides nothing here: which labels repeat
* and are therefore blank, how many header levels there are, which columns
+2
View File
@@ -79,6 +79,8 @@
<div id="grid" tabindex="0" aria-label="Grid"></div>
</main>
<div id="help" role="dialog" aria-label="Help" hidden></div>
<script type="module" src="./app.js"></script>
</body>
</html>
+59
View File
@@ -172,3 +172,62 @@ td input {
background: color-mix(in srgb, var(--line) 45%, transparent);
padding: 0 0.25rem;
}
/* The help overlay. Content is improvise_protocol::help's; this is paint. */
#help {
position: fixed;
inset: 2rem;
overflow: auto;
padding: 1rem;
border: 1px solid var(--accent);
background: var(--bg);
font-size: 0.9rem;
}
.help-tabs {
display: flex;
gap: 0.75rem;
border-bottom: 1px solid var(--line);
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.help-tab.active {
color: var(--accent);
font-weight: 600;
}
.help-body {
display: grid;
grid-template-columns: 16rem 1fr;
gap: 0 1rem;
margin: 0;
}
.help-body dt,
.help-body dd {
margin: 0;
min-height: 1.2em;
}
.help-heading {
grid-column: 1 / -1;
font-weight: 600;
color: var(--accent);
}
.help-banner {
grid-column: 1 / -1;
font-weight: 700;
}
.help-text,
.help-accent,
.help-blank {
grid-column: 1 / -1;
white-space: pre;
}
.help-dim {
opacity: 0.7;
}