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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:?}"),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user