9512a2db1f
21x21 grid, 8x8 window, start (10,10): moves land at 10+d, page-scroll at 10+page_rows(8)*d, jumps at 0/20. grid_move's page rule is page_rows(), which the test reads rather than restating. Doubled move-selection, a one-row scroll-rows and a page-size-blind page-scroll each fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
3641 lines
144 KiB
Rust
3641 lines
144 KiB
Rust
//! One keypress → the `Command`s a thin client may compose for itself. Shared
|
|
//! by every front end; every decision here is in `context/code/clients.md`.
|
|
|
|
use improvise_core::view::Axis;
|
|
|
|
use crate::client::ClientState;
|
|
use crate::frame::ClientFrame;
|
|
use crate::keymap::{Binding, Keymaps, ModeKey};
|
|
use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree};
|
|
use crate::{
|
|
AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent,
|
|
ModelCommand, Panel, RecordCell, ViewState, records_named,
|
|
};
|
|
|
|
/// The virtual category a new measure is filed under, spelled as
|
|
/// `commit.rs::add_measure` spells it.
|
|
const MEASURE_CATEGORY: &str = "_Measure";
|
|
|
|
/// What this client decided to do with a keypress.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum KeyOutcome {
|
|
/// Resolved here: the caller folds them locally *and* sends them, as one
|
|
/// frame, in order. Never empty. See `context/code/clients.md`.
|
|
Local(Vec<Command>),
|
|
/// Resolved here as a **request**: the caller sends the frame and folds
|
|
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
|
|
Request(Request),
|
|
/// Bound to a command this client cannot compose. Forwarded to the host,
|
|
/// which today answers nothing — `context/code/clients.md`.
|
|
Upstream(KeyEvent),
|
|
/// Nothing to send: either nothing is bound, or what is bound resolves
|
|
/// to no change.
|
|
Ignored,
|
|
}
|
|
|
|
/// What a key may ask its **host** to do, as opposed to what it may compose.
|
|
/// Narrow on purpose — the argument is in `context/code/clients.md`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Request {
|
|
/// Write the document back where the host keeps it (improvise-368h).
|
|
Save,
|
|
}
|
|
|
|
impl Request {
|
|
/// The frame that carries it. The one mapping, so a resolver never names
|
|
/// a [`ClientFrame`] and cannot compose one this outcome does not mean.
|
|
pub fn frame(self) -> ClientFrame {
|
|
match self {
|
|
Request::Save => ClientFrame::Save,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl KeyOutcome {
|
|
/// `Local` when there is something to say, `Ignored` when not — the one
|
|
/// constructor, so "never empty" is a property rather than a rule.
|
|
fn of(commands: Vec<Command>) -> Self {
|
|
match commands.is_empty() {
|
|
true => KeyOutcome::Ignored,
|
|
false => KeyOutcome::Local(commands),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decide a keypress against this client's own state. Pure, and the lookup is
|
|
/// the shared one; only what happens to the **name** differs.
|
|
pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
|
|
// `Quit` has no keymap, on purpose: `ModeKey::from_app_mode` is exhaustive,
|
|
// so a new mode without bindings is a compile error.
|
|
let Some(mode) = ModeKey::from_app_mode(&state.view.mode, state.view.search_mode) else {
|
|
return KeyOutcome::Ignored;
|
|
};
|
|
// An armed prefix takes priority and is spent either way, mirroring
|
|
// `App::dispatch_key` (improvise-jc8) — `context/code/clients.md`.
|
|
if let Some((key, mods)) = state.view.transient_prefix {
|
|
return prefixed(state, &mode, event, key, mods);
|
|
}
|
|
// Nothing bound is not "the host might know better" — the host looks the
|
|
// key up in this same table. It is `Ignored`, and that is new.
|
|
let Some(binding) = Keymaps::shared().lookup(&mode, event.code, event.modifiers) else {
|
|
return KeyOutcome::Ignored;
|
|
};
|
|
match binding {
|
|
// Arming is a `Command`, so the second key resolves against a state
|
|
// this client really holds — improvise-hs0u, `context/code/clients.md`.
|
|
Binding::Prefix(_) => KeyOutcome::Local(vec![Command::SetTransientPrefix(Some((
|
|
event.code,
|
|
event.modifiers,
|
|
)))]),
|
|
Binding::Cmd { name, args } => match step(state, event, name, args) {
|
|
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
|
|
Step::Request(request) => KeyOutcome::Request(request),
|
|
Step::Upstream => KeyOutcome::Upstream(event),
|
|
},
|
|
// All or nothing, each step resolved against the *unchanged* state.
|
|
// See `context/code/clients.md`.
|
|
Binding::Sequence(steps) => match sequence(state, event, steps) {
|
|
Some(commands) => KeyOutcome::of(commands),
|
|
None => KeyOutcome::Upstream(event),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// The second key of a chord, resolved in the sub-keymap the first one
|
|
/// opened. Why every outcome carries the disarm: `context/code/clients.md`.
|
|
fn prefixed(
|
|
state: &ClientState,
|
|
mode: &ModeKey,
|
|
event: KeyEvent,
|
|
key: KeyCode,
|
|
mods: crate::KeyModifiers,
|
|
) -> KeyOutcome {
|
|
let binding = Keymaps::shared()
|
|
.prefix_keymap(mode, key, mods)
|
|
.and_then(|sub| sub.lookup(event.code, event.modifiers).cloned());
|
|
chord(state, event, binding.as_ref())
|
|
}
|
|
|
|
/// The second key's outcome with the disarm in front of it. Split from
|
|
/// [`prefixed`] so a test can hand it a binding — `context/code/clients.md`.
|
|
fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> KeyOutcome {
|
|
// Unmatched is not `Ignored`: leaving the prefix armed strands every key
|
|
// after it in a sub-keymap the user has forgotten about.
|
|
let resolved = match binding {
|
|
None => None,
|
|
// Flat by `prefix_keymaps_are_flat`, so a second prefix cannot occur.
|
|
Some(Binding::Prefix(_)) => None,
|
|
Some(Binding::Cmd { name, args }) => match step(state, event, name, args) {
|
|
Step::Local(commands) | Step::Declined(commands) => Some(commands),
|
|
// DISCARDED, which is why the guard refuses both classes rather
|
|
// than counting them answered — `context/code/clients.md`.
|
|
Step::Request(_) | Step::Upstream => None,
|
|
},
|
|
Some(Binding::Sequence(steps)) => sequence(state, event, steps),
|
|
};
|
|
let mut commands = vec![Command::SetTransientPrefix(None)];
|
|
commands.extend(resolved.unwrap_or_default());
|
|
KeyOutcome::Local(commands)
|
|
}
|
|
|
|
/// What a pointer did, resolved here for the same reason a keypress is: a
|
|
/// front end paints and wires, it does not decide. `context/code/clients.md`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Click {
|
|
/// A row of the category panel, indexing the flattened tree.
|
|
CategoryRow(usize),
|
|
/// A row of the view panel, indexing `ViewListCache::views`.
|
|
ViewRow(usize),
|
|
/// A row of the formula panel, indexing `FormulaCache::formulas`.
|
|
FormulaRow(usize),
|
|
/// A tile, indexing `TileBarCache::tiles`.
|
|
Tile(usize),
|
|
/// A grid cell, in **viewport** coordinates as the page painted it.
|
|
Cell { row: usize, col: usize },
|
|
/// An item of a page axis, both named by index: `axis` into
|
|
/// `GridCache::page_coords`, `item` into that axis's own item list.
|
|
PageItem { axis: usize, item: usize },
|
|
}
|
|
|
|
/// One rule for all the widgets: **go there, then do what `Enter` does
|
|
/// there.** Why that rather than one answer each: `context/code/clients.md`.
|
|
pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
|
|
// `None` here means the whole click composed nothing, which is the
|
|
// invariant rather than a breach of it (improvise-hkr8) — clients.md.
|
|
let resolved = match target {
|
|
Click::CategoryRow(index) => click_category_row(state, index),
|
|
Click::ViewRow(index) => click_view_row(state, index),
|
|
Click::FormulaRow(index) => click_formula_row(state, index),
|
|
Click::Tile(index) => click_tile(state, index),
|
|
Click::Cell { row, col } => click_cell(state, row, col),
|
|
Click::PageItem { axis, item } => click_page_item(state, axis, item),
|
|
};
|
|
resolved.unwrap_or_default()
|
|
}
|
|
|
|
/// Focus the panel, move its cursor, then switch — what `V` then `Enter` do,
|
|
/// in one gesture. `switch-view-at-cursor` is the activation.
|
|
fn click_view_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
let mut commands = focus(state, Panel::View);
|
|
commands.push(Command::SetPanelCursor {
|
|
panel: Panel::View,
|
|
cursor: index,
|
|
});
|
|
// Off `index`, never the cursor: `SetPanelCursor` is not folded yet.
|
|
commands.extend(switch_view_at(state, index)?);
|
|
Some(commands)
|
|
}
|
|
|
|
/// The formula panel binds **no** `Enter`, so the rule yields navigation and
|
|
/// nothing else. That is the rule applying, not an omission — clients.md.
|
|
fn click_formula_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
state.formulas.formulas.get(index)?;
|
|
let mut commands = focus(state, Panel::Formula);
|
|
commands.push(Command::SetPanelCursor {
|
|
panel: Panel::Formula,
|
|
cursor: index,
|
|
});
|
|
Some(commands)
|
|
}
|
|
|
|
/// Focus the panel, move its cursor, then activate — the three things the
|
|
/// keyboard needs three keystrokes for, and `filter-to-item` is the activation.
|
|
fn click_category_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
// A click past the last row is a click on the panel's empty space, which
|
|
// is not a row and must not move the cursor onto one.
|
|
cat_tree_entry_at(state, index)?;
|
|
let mut commands = focus(state, Panel::Category);
|
|
commands.push(Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor: index,
|
|
});
|
|
// Against `index`, never the cursor: the `SetPanelCursor` above is not
|
|
// folded yet, so the cursor still reads the old row. Evidence file.
|
|
commands.extend(filter_to_item_at(state, index)?);
|
|
Some(commands)
|
|
}
|
|
|
|
/// Select the tile; a **second** click on the already-selected one cycles its
|
|
/// axis. Why a click never mutates on its own: `context/code/clients.md`.
|
|
fn click_tile(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
state.tiles.tiles.get(index)?;
|
|
// Mode *and* index, because `tile_cat_idx` is 0 before anything selects
|
|
// anything — index alone would cycle tile 0 on its first click.
|
|
let already_selected =
|
|
state.view.mode == AppMode::TileSelect && state.view.tile_cat_idx == index;
|
|
match already_selected {
|
|
true => tile_axis_at(state, index, None),
|
|
false => {
|
|
let mut commands = vec![Command::SetTileCatIdx(index)];
|
|
if state.view.mode != AppMode::TileSelect {
|
|
commands.push(Command::ChangeMode(AppMode::TileSelect));
|
|
}
|
|
Some(commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Move the selection to a clicked cell. Activation is the page's inline
|
|
/// editor, which is the pointer's `i` — `context/code/clients.md`.
|
|
fn click_cell(state: &ClientState, row: usize, col: usize) -> Option<Vec<Command>> {
|
|
let cursor = state.view.cursor();
|
|
let GridExtent { rows, cols } = state.grid.extent;
|
|
// The page paints a window; `SetSelected` is absolute (gotcha 42).
|
|
let (absolute_row, absolute_col) = (row + cursor.row_offset, col + cursor.col_offset);
|
|
if absolute_row >= rows || absolute_col >= cols {
|
|
return None;
|
|
}
|
|
Some(scrolled_to(state, absolute_row, absolute_col))
|
|
}
|
|
|
|
/// Open a panel and focus it, emitting only what is not already true — so a
|
|
/// click inside the focused panel is not a mode change every time.
|
|
fn focus(state: &ClientState, panel: Panel) -> Vec<Command> {
|
|
let mut commands = Vec::new();
|
|
if !state.view.panel_open(panel) {
|
|
commands.push(Command::SetPanelOpen { panel, open: true });
|
|
}
|
|
if state.view.mode.panel() != Some(panel) {
|
|
commands.push(Command::ChangeMode(panel.mode()));
|
|
}
|
|
commands
|
|
}
|
|
|
|
/// Every step of a `Binding::Sequence`, or none of them. A declining step takes
|
|
/// the whole sequence with it — `context/code/clients.md` (gotcha 21).
|
|
fn sequence(
|
|
state: &ClientState,
|
|
event: KeyEvent,
|
|
steps: &[(&'static str, Vec<String>)],
|
|
) -> Option<Vec<Command>> {
|
|
let mut commands = Vec::new();
|
|
for (name, args) in steps {
|
|
match step(state, event, name, args) {
|
|
Step::Local(more) => commands.extend(more),
|
|
Step::Declined(said) => return Some(said),
|
|
// A request is a frame of its own and a sequence is one frame, so
|
|
// there is nowhere to put it. No binding does — clients.md.
|
|
Step::Request(_) | Step::Upstream => return None,
|
|
}
|
|
}
|
|
Some(commands)
|
|
}
|
|
|
|
/// What one command name resolved to. `Declined` is composed here like
|
|
/// `Local`, and differs only in what it does to a sequence.
|
|
enum Step {
|
|
Local(Vec<Command>),
|
|
Declined(Vec<Command>),
|
|
Request(Request),
|
|
Upstream,
|
|
}
|
|
|
|
/// One name, answered. Each list is checked before the next, so no name is in
|
|
/// two of them and [`composed`] has no arm for any earlier one.
|
|
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
|
|
match requested(name) {
|
|
Some(request) => Step::Request(request),
|
|
None => match declined(name, args) {
|
|
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
|
None => match composed(state, event, name, args) {
|
|
Some(commands) => Step::Local(commands),
|
|
None => Step::Upstream,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/// The command names this client answers by **asking its host**, rather than
|
|
/// by composing anything. See `context/code/clients.md` (improvise-jz6w).
|
|
fn requested(name: &str) -> Option<Request> {
|
|
match name {
|
|
"save" => Some(Request::Save),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The commands this client cannot compose and answers **out loud**; the one
|
|
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
|
|
fn declined(name: &str, args: &[String]) -> Option<String> {
|
|
let what = match name {
|
|
"drill-into-cell" => "drill",
|
|
"toggle-prune-empty" => "hiding empty rows and columns",
|
|
"toggle-records-mode" => "records mode",
|
|
"add-record-row" => "adding a record row",
|
|
"delete-formula-at-cursor" => "deleting a formula",
|
|
// Reachable only behind a prefix, and a chord must be answered or it
|
|
// strands its own disarm — improvise-hs0u, `context/code/clients.md`.
|
|
"toggle-col-group-under-cursor" => "collapsing a column group",
|
|
"wq" => "saving and quitting",
|
|
// A door this client will not open still has to say so: entering is
|
|
// resolvable and the commit behind it is not.
|
|
"enter-mode" => stranding(&AppMode::from_name(args.first()?)?)?,
|
|
_ => return None,
|
|
};
|
|
Some(not_yet(what))
|
|
}
|
|
|
|
/// The one home for the decline sentence. A conditional arm of [`composed`]
|
|
/// reaches it too, which is why it is not inlined — clients.md.
|
|
fn not_yet(what: &str) -> String {
|
|
format!("{what} is not available in the browser yet")
|
|
}
|
|
|
|
/// The subset of command names this client can turn into `Command`s; `None`
|
|
/// means "the host's". Each arm mirrors its `Cmd` line for line — clients.md.
|
|
fn composed(
|
|
state: &ClientState,
|
|
event: KeyEvent,
|
|
name: &str,
|
|
args: &[String],
|
|
) -> Option<Vec<Command>> {
|
|
match name {
|
|
// ── the grid cursor ──────────────────────────────────────────
|
|
"move-selection" => grid_move(state, GridMove::Relative(num(args, 0)?, num(args, 1)?)),
|
|
"scroll-rows" => grid_move(state, GridMove::Relative(num(args, 0)?, 0)),
|
|
"jump-first-row" => grid_move(state, GridMove::ToStart(true)),
|
|
"jump-first-col" => grid_move(state, GridMove::ToStart(false)),
|
|
"jump-last-row" => grid_move(state, GridMove::ToEnd(true)),
|
|
"jump-last-col" => grid_move(state, GridMove::ToEnd(false)),
|
|
"page-scroll" => grid_move(state, GridMove::Page(num(args, 0)?)),
|
|
|
|
// ── the other two cursors ────────────────────────────────────
|
|
"move-panel-cursor" => panel_cursor(state, args.first()?, num(args, 1)?),
|
|
"move-tile-cursor" => tile_cursor(state, num(args, 0)?),
|
|
|
|
// ── minibuffer text entry ────────────────────────────────────
|
|
"append-char" => {
|
|
let KeyCode::Char(c) = event.code else {
|
|
return Some(Vec::new());
|
|
};
|
|
let mut value = read_buffer(&state.view, args.first()?);
|
|
value.push(c);
|
|
Some(vec![write_buffer(args.first()?, value)])
|
|
}
|
|
"pop-char" => {
|
|
let mut value = read_buffer(&state.view, args.first()?);
|
|
value.pop();
|
|
Some(vec![write_buffer(args.first()?, value)])
|
|
}
|
|
"clear-buffer" => Some(vec![write_buffer(args.first()?, String::new())]),
|
|
|
|
// ── mode entry ───────────────────────────────────────────────
|
|
"enter-mode" => {
|
|
let mode = AppMode::from_name(args.first()?)?;
|
|
// `declined` answers this first, and both halves hold the rule:
|
|
// no path opens a room this client cannot commit.
|
|
stranding(&mode).is_none().then(|| entered(mode))
|
|
}
|
|
|
|
// ── panels ───────────────────────────────────────────────────
|
|
"cycle-panel-focus" => Some(cycle_panel_focus(&state.view)),
|
|
"toggle-panel-visibility" => {
|
|
let panel = named_panel(args.first()?)?;
|
|
Some(vec![Command::SetPanelOpen {
|
|
panel,
|
|
open: !state.view.panel_open(panel),
|
|
}])
|
|
}
|
|
// The interactive arm of `TogglePanelAndFocus`: open-and-focus unless
|
|
// this panel is already both. See `context/code/clients.md`.
|
|
"toggle-panel-and-focus" => {
|
|
let panel = named_panel(args.first()?)?;
|
|
let closing = state.view.panel_open(panel) && state.view.mode.panel() == Some(panel);
|
|
Some(vec![
|
|
Command::SetPanelOpen {
|
|
panel,
|
|
open: !closing,
|
|
},
|
|
Command::ChangeMode(match closing {
|
|
true => AppMode::Normal,
|
|
false => panel.mode(),
|
|
}),
|
|
])
|
|
}
|
|
|
|
// `EnterTileSelect`'s gate is invariantly true, so there is nothing
|
|
// here to mirror it against. See `context/code/clients.md`.
|
|
"enter-tile-select" => Some(vec![
|
|
Command::SetTileCatIdx(0),
|
|
Command::ChangeMode(AppMode::TileSelect),
|
|
]),
|
|
|
|
// The view's shape. Neither the dirty flag nor the cursor reset is
|
|
// this client's to send; both are the host's — clients.md.
|
|
"transpose" => Some(vec![Command::Model {
|
|
basis: state.basis.claim([])?,
|
|
cmd: ModelCommand::TransposeAxes {
|
|
view: state.view.active_view.clone(),
|
|
},
|
|
}]),
|
|
|
|
// ── editing ──────────────────────────────────────────────────
|
|
"edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?),
|
|
"commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down),
|
|
"commit-and-advance-right" => {
|
|
commit(state, AppMode::from_name(args.first()?)?, Advance::Right)
|
|
}
|
|
|
|
// ── the category panel ───────────────────────────────────────
|
|
"cycle-axis-at-cursor" => Some(match cat_at_cursor(state) {
|
|
Some(cat) => {
|
|
let cycled = cat_axis(state, &cat).map(Axis::next);
|
|
let mut commands = vec![model(
|
|
state,
|
|
ModelCommand::CycleAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: cat.clone(),
|
|
},
|
|
)?];
|
|
commands
|
|
.extend(cycled.map(|axis| Command::SetStatus(axis_change_status(&cat, axis))));
|
|
commands
|
|
}
|
|
None => vec![Command::SetStatus(
|
|
"Move cursor to a category header to change axis".to_string(),
|
|
)],
|
|
}),
|
|
"open-item-add-at-cursor" => Some(match cat_at_cursor(state) {
|
|
Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))],
|
|
None => vec![Command::SetStatus(
|
|
"No category selected. Press n to add a category first.".to_string(),
|
|
)],
|
|
}),
|
|
// Both arms answered HERE, the unresolvable one out loud: the
|
|
// host-needed list is a property of the name — clients.md, gotcha 47.
|
|
"search-or-category-add" => Some(match state.view.search_query.is_empty() {
|
|
true => vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true,
|
|
},
|
|
Command::ChangeMode(AppMode::category_add()),
|
|
],
|
|
false => vec![Command::SetStatus(not_yet(
|
|
"stepping back through search matches",
|
|
))],
|
|
}),
|
|
|
|
"toggle-cat-expand" => Some(toggle_cat_expand(state)),
|
|
"filter-to-item" => filter_to_item(state),
|
|
"delete-category-at-cursor" => delete_at_cat_cursor(state),
|
|
|
|
// ── the view panel ───────────────────────────────────────────
|
|
"switch-view-at-cursor" => {
|
|
Some(switch_view_at(state, state.view.view_panel_cursor).unwrap_or_default())
|
|
}
|
|
"create-and-switch-view" => {
|
|
let name = improvise_core::view::unused_view_name(
|
|
state.views.views.iter().map(|v| v.name.as_str()),
|
|
);
|
|
Some(vec![
|
|
model(state, ModelCommand::CreateView(name.clone()))?,
|
|
Command::SwitchView(name),
|
|
Command::ChangeMode(AppMode::Normal),
|
|
])
|
|
}
|
|
"delete-view-at-cursor" => delete_view_at_cursor(state),
|
|
|
|
// ── view history ─────────────────────────────────────────────
|
|
|
|
// Asymmetric in the `Cmd` and mirrored as such; why the drill commit
|
|
// is not reconstructed here: `context/code/clients.md`.
|
|
"view-back" => Some(step_history(
|
|
&state.view.view_back_stack,
|
|
Command::ViewBack,
|
|
"No previous view",
|
|
)),
|
|
"view-forward" => Some(step_history(
|
|
&state.view.view_forward_stack,
|
|
Command::ViewForward,
|
|
"No forward view",
|
|
)),
|
|
|
|
// ── tile select ──────────────────────────────────────────────
|
|
"cycle-axis-for-tile" => tile_axis(state, None),
|
|
"set-axis-for-tile" => tile_axis(state, Some(parse_axis(args.first()?)?)),
|
|
|
|
// ── the help overlay ─────────────────────────────────────────
|
|
"page-next" => page_cycle(state, Odometer::Next),
|
|
"page-prev" => page_cycle(state, Odometer::Prev),
|
|
|
|
"help-page-next" => Some(vec![Command::HelpPage(HelpPage::Next)]),
|
|
"help-page-prev" => Some(vec![Command::HelpPage(HelpPage::Prev)]),
|
|
|
|
// ── minibuffer commits ───────────────────────────────────────
|
|
"commit-buffer" => commit_buffer(state, CommitKind::of(args.first()?, &state.view.mode)?),
|
|
|
|
// ── cell operations ──────────────────────────────────────────
|
|
"clear-cell" => Some(vec![model(
|
|
state,
|
|
ModelCommand::ClearCell(record_cell_at_cursor(state)?),
|
|
)?]),
|
|
// `YankCell::execute`: the value, then the status. The projected
|
|
// value, never text re-parsed: improvise-dsxq.
|
|
"yank" => Some(vec![
|
|
Command::SetYanked(cell_at_cursor(state)?.value.clone()),
|
|
Command::SetStatus("Yanked".to_string()),
|
|
]),
|
|
"paste" => Some(match state.view.yanked.clone() {
|
|
Some(value) => vec![model(
|
|
state,
|
|
ModelCommand::SetCell {
|
|
cell: record_cell_at_cursor(state)?,
|
|
value,
|
|
},
|
|
)?],
|
|
None => Vec::new(),
|
|
}),
|
|
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The panel a `<panel>` argument names — `parse_panel`'s spellings, which
|
|
/// `Panel::name` is required to match (gotcha 12).
|
|
fn named_panel(name: &str) -> Option<Panel> {
|
|
Panel::ALL.into_iter().find(|p| p.name() == name)
|
|
}
|
|
|
|
/// One numeric argument, as the registry's interactive parsers read it.
|
|
fn num(args: &[String], i: usize) -> Option<i32> {
|
|
args.get(i)?.parse().ok()
|
|
}
|
|
|
|
// The named buffers. A runtime-named buffer may not be an entry in `buffers`
|
|
// at all: "search" is `ViewState::search_query` (improvise-gdl).
|
|
|
|
pub fn read_buffer(view: &ViewState, name: &str) -> String {
|
|
match name {
|
|
"search" => view.search_query.clone(),
|
|
other => view.buffers.get(other).cloned().unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn write_buffer(name: &str, value: String) -> Command {
|
|
match name {
|
|
"search" => Command::SetSearchQuery(value),
|
|
other => Command::SetBuffer {
|
|
name: other.to_string(),
|
|
value,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// A prompt this client can *enter* but cannot *commit*; `Some` is the subject
|
|
/// to decline by name. A dead room is worse than a locked door: clients.md.
|
|
fn stranding(mode: &AppMode) -> Option<&'static str> {
|
|
match mode {
|
|
AppMode::SavePrompt { .. } => Some("saving to a path"),
|
|
AppMode::ExportPrompt { .. } => Some("exporting"),
|
|
AppMode::CommandMode { .. } => Some("the command line"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// `EnterMode::execute`: clear the incoming mode's minibuffer, then change.
|
|
fn entered(mode: AppMode) -> Vec<Command> {
|
|
let mut commands = Vec::new();
|
|
if let Some(mb) = mode.minibuffer() {
|
|
commands.push(write_buffer(&mb.buffer_key, String::new()));
|
|
}
|
|
// Plain `Normal`: which member of the pair it settles to is the grid's, and
|
|
// `ClientState::fold` answers that — improvise-e5pf, `context/code/clients.md`.
|
|
commands.push(Command::ChangeMode(mode));
|
|
commands
|
|
}
|
|
|
|
// ── the grid cursor ─────────────────────────────────────────────────────
|
|
|
|
/// A mirror of `navigation.rs`'s `MoveKind`, spelled again because that crate
|
|
/// is not on a browser's dependency list. The *keys* are not spelled twice.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum GridMove {
|
|
Relative(i32, i32),
|
|
/// Jump to the start of an axis: `true` = row, `false` = column.
|
|
ToStart(bool),
|
|
ToEnd(bool),
|
|
/// A screenful, `+1` down and `-1` up.
|
|
Page(i32),
|
|
}
|
|
|
|
/// Rows one `page-scroll` step moves: three quarters of the window, at least one.
|
|
fn page_rows(window_rows: usize) -> i32 {
|
|
(window_rows as i32 * 3 / 4).max(1)
|
|
}
|
|
|
|
fn grid_move(state: &ClientState, kind: GridMove) -> Option<Vec<Command>> {
|
|
let GridExtent { rows, cols } = state.grid.extent;
|
|
let (window_rows, window_cols) = state.window;
|
|
// Two shapes with nothing to say, kept apart from "not ours": neither is
|
|
// a key the host could answer better. See `context/code/clients.md`.
|
|
if rows == 0 || cols == 0 || window_rows == 0 || window_cols == 0 {
|
|
return Some(Vec::new());
|
|
}
|
|
let cursor = state.view.cursor();
|
|
let row_max = (rows - 1) as i32;
|
|
let col_max = (cols - 1) as i32;
|
|
let (row, col) = (cursor.selected.0 as i32, cursor.selected.1 as i32);
|
|
let (nr, nc) = match kind {
|
|
GridMove::Relative(dr, dc) => (
|
|
(row + dr).clamp(0, row_max) as usize,
|
|
(col + dc).clamp(0, col_max) as usize,
|
|
),
|
|
GridMove::ToStart(true) => (0, cursor.selected.1),
|
|
GridMove::ToStart(false) => (cursor.selected.0, 0),
|
|
GridMove::ToEnd(true) => (row_max as usize, cursor.selected.1),
|
|
GridMove::ToEnd(false) => (cursor.selected.0, col_max as usize),
|
|
GridMove::Page(dir) => {
|
|
let delta = page_rows(window_rows) * dir;
|
|
((row + delta).clamp(0, row_max) as usize, cursor.selected.1)
|
|
}
|
|
};
|
|
Some(scrolled_to(state, nr, nc))
|
|
}
|
|
|
|
/// `SetSelected` plus whichever scroll offsets follow it: a mirror of
|
|
/// `navigation.rs::viewport_effects` over the window this client announced.
|
|
fn scrolled_to(state: &ClientState, nr: usize, nc: usize) -> Vec<Command> {
|
|
let cursor = state.view.cursor();
|
|
let (window_rows, window_cols) = state.window;
|
|
let mut commands = vec![Command::SetSelected { row: nr, col: nc }];
|
|
let mut row_offset = cursor.row_offset;
|
|
let mut col_offset = cursor.col_offset;
|
|
if nr < row_offset {
|
|
row_offset = nr;
|
|
}
|
|
if nr >= row_offset + window_rows {
|
|
row_offset = nr.saturating_sub(window_rows - 1);
|
|
}
|
|
if nc < col_offset {
|
|
col_offset = nc;
|
|
}
|
|
if nc >= col_offset + window_cols {
|
|
col_offset = nc.saturating_sub(window_cols - 1);
|
|
}
|
|
if row_offset != cursor.row_offset {
|
|
commands.push(Command::SetRowOffset(row_offset));
|
|
}
|
|
if col_offset != cursor.col_offset {
|
|
commands.push(Command::SetColOffset(col_offset));
|
|
}
|
|
commands
|
|
}
|
|
|
|
// ── the panel cursors ───────────────────────────────────────────────────
|
|
|
|
/// `MovePanelCursor::execute`, deliberately line for line — the three details
|
|
/// that differ from its siblings are in `context/code/clients.md`.
|
|
fn panel_cursor(state: &ClientState, panel: &str, delta: i32) -> Option<Vec<Command>> {
|
|
let view = &state.view;
|
|
let panel = named_panel(panel)?;
|
|
let (current, max) = match panel {
|
|
Panel::Formula => (view.formula_cursor, state.formulas.formulas.len()),
|
|
Panel::Category => (
|
|
view.cat_panel_cursor,
|
|
flatten_cat_tree(&state.categories, &view.expanded_cats).len(),
|
|
),
|
|
Panel::View => (view.view_panel_cursor, state.views.views.len()),
|
|
};
|
|
if max == 0 {
|
|
return Some(Vec::new());
|
|
}
|
|
let clamped = current.min(max - 1);
|
|
let new = (clamped as i32 + delta).clamp(0, (max - 1) as i32) as usize;
|
|
Some(match new == current {
|
|
true => Vec::new(),
|
|
false => vec![Command::SetPanelCursor { panel, cursor: new }],
|
|
})
|
|
}
|
|
|
|
// ── the tile cursor ─────────────────────────────────────────────────────
|
|
|
|
/// `move-tile-cursor ±1`, clamped against `TileBarCache::tiles`. Unlike the
|
|
/// panel cursor it emits even when the index does not move, as its `Cmd` does.
|
|
fn tile_cursor(state: &ClientState, delta: i32) -> Option<Vec<Command>> {
|
|
let count = state.tiles.tiles.len();
|
|
if count == 0 {
|
|
return Some(Vec::new());
|
|
}
|
|
let new = (state.view.tile_cat_idx as i32 + delta).clamp(0, (count - 1) as i32) as usize;
|
|
Some(vec![Command::SetTileCatIdx(new)])
|
|
}
|
|
|
|
// ── editing a cell ──────────────────────────────────────────────────────
|
|
|
|
/// The cell under the cursor in this viewport-indexed cache. `None` when the
|
|
/// selection is outside the window — reachable only by a projection race.
|
|
fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> {
|
|
let cursor = state.view.cursor();
|
|
let row = cursor.selected.0.checked_sub(cursor.row_offset)?;
|
|
let col = cursor.selected.1.checked_sub(cursor.col_offset)?;
|
|
state.grid.cell(row, col)
|
|
}
|
|
|
|
/// `EditOrDrill::execute`, with ADR 002 answering the branch, and drill
|
|
/// declining out loud rather than upstream. See `context/code/clients.md`.
|
|
fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
|
|
// The grid, not the mode: a records GRID is the observed fact and the mode
|
|
// follows it (improvise-bd9o, `context/code/clients.md`).
|
|
if state.grid.records {
|
|
return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]);
|
|
}
|
|
let cell = cell_at_cursor(state)?;
|
|
match cell.id.as_ref()? {
|
|
// `edit_text`, never `text`: the drawn text has been through
|
|
// `format_f64`. `tests/lockstep.rs` found exactly that.
|
|
CellId::Record(_) => Some(vec![
|
|
Command::SetBuffer {
|
|
name: "edit".to_string(),
|
|
value: cell.edit_text.clone().unwrap_or_default(),
|
|
},
|
|
Command::ChangeMode(mode),
|
|
]),
|
|
CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!(
|
|
"an aggregate is edited by drilling in — {}",
|
|
not_yet("drill")
|
|
))]),
|
|
CellId::Derived { target } => Some(vec![Command::SetStatus(format!(
|
|
"{target} is computed by a formula — edit the formula, not the cell"
|
|
))]),
|
|
// Both creating columns are records-only, so the guard above takes
|
|
// them; declining rather than `None` keeps that a door, not a hole.
|
|
CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]),
|
|
CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]),
|
|
}
|
|
}
|
|
|
|
/// One direction of the view history. An empty stack is answered out loud:
|
|
/// `reduce_view`'s own arm is silent there — `context/code/clients.md`.
|
|
fn step_history(stack: &[crate::ViewFrame], step: Command, empty: &str) -> Vec<Command> {
|
|
match stack.is_empty() {
|
|
true => vec![Command::SetStatus(empty.to_string())],
|
|
false => vec![step],
|
|
}
|
|
}
|
|
|
|
/// Which way the page odometer turns.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Odometer {
|
|
Next,
|
|
Prev,
|
|
}
|
|
|
|
/// `PageNext`/`PagePrev::execute`, mirrored: every page axis moves as one
|
|
/// odometer, least significant last. See `context/code/clients.md`.
|
|
fn page_cycle(state: &ClientState, way: Odometer) -> Option<Vec<Command>> {
|
|
let axes: Vec<(&str, &[String], usize)> = state
|
|
.grid
|
|
.page_coords
|
|
.iter()
|
|
.filter_map(|axis| {
|
|
let (items, at) = axis.cycle()?;
|
|
Some((axis.cat.as_str(), items, at))
|
|
})
|
|
.collect();
|
|
if axes.is_empty() {
|
|
return None;
|
|
}
|
|
let mut indices: Vec<usize> = axes.iter().map(|(_, _, at)| *at).collect();
|
|
let mut carry = true;
|
|
for i in (0..axes.len()).rev() {
|
|
if !carry {
|
|
break;
|
|
}
|
|
let len = axes[i].1.len();
|
|
match way {
|
|
Odometer::Next if indices[i] + 1 >= len => indices[i] = 0,
|
|
Odometer::Next => {
|
|
indices[i] += 1;
|
|
carry = false;
|
|
}
|
|
Odometer::Prev if indices[i] == 0 => indices[i] = len.saturating_sub(1),
|
|
Odometer::Prev => {
|
|
indices[i] -= 1;
|
|
carry = false;
|
|
}
|
|
}
|
|
}
|
|
axes.iter()
|
|
.enumerate()
|
|
.map(|(i, (cat, items, _))| {
|
|
Some(Command::Model {
|
|
basis: state.basis.claim([])?,
|
|
cmd: ModelCommand::SetPageSelection {
|
|
view: state.view.active_view.clone(),
|
|
category: (*cat).to_string(),
|
|
item: items[indices[i]].clone(),
|
|
},
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The user picked an item off a page axis directly. `PageAxis::cycle` is
|
|
/// asked, so a Filter axis composes nothing (gotcha 27).
|
|
fn click_page_item(state: &ClientState, axis: usize, item: usize) -> Option<Vec<Command>> {
|
|
let page = state.grid.page_coords.get(axis)?;
|
|
let (items, _) = page.cycle()?;
|
|
let picked = items.get(item)?;
|
|
Some(vec![Command::Model {
|
|
basis: state.basis.claim([])?,
|
|
cmd: ModelCommand::SetPageSelection {
|
|
view: state.view.active_view.clone(),
|
|
category: page.cat.clone(),
|
|
item: picked.clone(),
|
|
},
|
|
}])
|
|
}
|
|
|
|
/// Which way `CommitAndAdvance` moves after writing.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Advance {
|
|
Down,
|
|
Right,
|
|
}
|
|
|
|
/// `CommitAndAdvance::execute`, for the one case a thin client can compose: a
|
|
/// plain grid cell. The three-way fork and the mirror order: clients.md.
|
|
fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
|
|
let (window_rows, window_cols) = state.window;
|
|
if window_rows == 0 || window_cols == 0 {
|
|
return None;
|
|
}
|
|
let text = read_buffer(&state.view, "edit");
|
|
let id = cell_at_cursor(state)?.id.as_ref()?;
|
|
// Records mode needs `record_col` (gotcha 1) — except the two CREATING
|
|
// columns, which name nothing but themselves (improvise-gcvb).
|
|
if let CellId::Create(kind) = id {
|
|
return create_column(state, edit_mode, advance, kind, &text);
|
|
}
|
|
if state.grid.records {
|
|
return None;
|
|
}
|
|
let mut commands = vec![Command::Model {
|
|
basis: state.basis.claim([record_at_cursor(state)?])?,
|
|
cmd: typed_cell_command(id, &text)?,
|
|
}];
|
|
commands.push(Command::ChangeMode(AppMode::Normal));
|
|
commands.extend(advanced(state, edit_mode, advance)?);
|
|
Some(commands)
|
|
}
|
|
|
|
/// `CommitAndAdvance` over a creating column: a schema change over the typed
|
|
/// NAME. Why +cat does not advance: clients.md (improvise-gcvb).
|
|
fn create_column(
|
|
state: &ClientState,
|
|
edit_mode: AppMode,
|
|
advance: Advance,
|
|
kind: &CreateKind,
|
|
text: &str,
|
|
) -> Option<Vec<Command>> {
|
|
// `exit_mode_for`: the tidy-up goes FIRST, where an aborting
|
|
// `AddCategory` cannot reach it (gotcha 21).
|
|
let mut commands = vec![Command::ChangeMode(AppMode::Normal)];
|
|
let name = text.trim();
|
|
if !name.is_empty() {
|
|
commands.push(model(
|
|
state,
|
|
match kind {
|
|
CreateKind::Measure => ModelCommand::AddItem {
|
|
category: MEASURE_CATEGORY.to_string(),
|
|
item: name.to_string(),
|
|
},
|
|
CreateKind::Category => ModelCommand::AddCategory(name.to_string()),
|
|
},
|
|
)?);
|
|
}
|
|
let (row, col) = state.view.cursor().selected;
|
|
commands.push(Command::SetSelected { row, col });
|
|
// An inserted column renumbers every index to its right, so staying put is
|
|
// what lands on the new one (improvise-2zq, gotcha 23).
|
|
if matches!(kind, CreateKind::Category) {
|
|
return Some(commands);
|
|
}
|
|
commands.extend(advanced(state, edit_mode, advance)?);
|
|
Some(commands)
|
|
}
|
|
|
|
/// The advance and the re-entry `CommitAndAdvance` ends in. `None` where the
|
|
/// host would emit `AddRecordRow`, which this client cannot name (gotcha 1).
|
|
fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
|
|
let GridExtent { rows, cols } = state.grid.extent;
|
|
let row_max = rows.saturating_sub(1);
|
|
let col_max = cols.saturating_sub(1);
|
|
let (row, col) = state.view.cursor().selected;
|
|
if matches!(advance, Advance::Right) && state.grid.records && row >= row_max && col >= col_max {
|
|
return None;
|
|
}
|
|
let landed = match advance {
|
|
Advance::Down if row < row_max => Some((row + 1, col)),
|
|
Advance::Down if col < col_max => Some((0, col + 1)),
|
|
// `EnterAdvance` returns `AbortChain` at the bottom-right corner, so
|
|
// here that is simply the arm that appends nothing more.
|
|
Advance::Down => None,
|
|
// `Right` has no abort: it clamps at the rightmost column and the
|
|
// re-entry always runs.
|
|
Advance::Right => Some((row, (col + 1).min(col_max))),
|
|
};
|
|
let Some((nr, nc)) = landed else {
|
|
return Some(Vec::new());
|
|
};
|
|
let mut commands = scrolled_to(state, nr, nc);
|
|
// `EnterEditAtCursor`: seed from where we landed, then change mode. The
|
|
// new cell is a different cell, so a stale cache is correct here.
|
|
commands.push(Command::SetBuffer {
|
|
name: "edit".to_string(),
|
|
value: seed_at(state, nr, nc),
|
|
});
|
|
commands.push(Command::ChangeMode(edit_mode));
|
|
Some(commands)
|
|
}
|
|
|
|
/// The surrogate naming the record under the cursor, or `None` when the
|
|
/// cursor is not on a writable cell.
|
|
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
|
|
match cell_at_cursor(state)?.id.as_ref()? {
|
|
CellId::Record(cell) => Some(cell.record),
|
|
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What an editor opened on the cursor starts with, or `None` with no cell
|
|
/// there to seed from. Read by the fold, never a renderer (improvise-ktlk).
|
|
pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
|
|
cell_at_cursor(state)?.edit_text.clone()
|
|
}
|
|
|
|
/// The display text at an absolute grid position, as the cache holds it.
|
|
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
|
|
let cursor = state.view.cursor();
|
|
let Some(r) = row.checked_sub(cursor.row_offset) else {
|
|
return String::new();
|
|
};
|
|
let Some(c) = col.checked_sub(cursor.col_offset) else {
|
|
return String::new();
|
|
};
|
|
state
|
|
.grid
|
|
.cell(r, c)
|
|
.and_then(|cell| cell.edit_text.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// How a typed string becomes a [`CellValue`]: the **one** home, called by the
|
|
/// browser's compose path and by the host's own commit (improvise-j7v6).
|
|
pub fn typed(text: &str) -> Option<CellValue> {
|
|
match text.is_empty() {
|
|
true => None,
|
|
false => Some(match text.parse::<f64>() {
|
|
Ok(n) => CellValue::Number(n),
|
|
Err(_) => CellValue::Text(text.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// The `ModelCommand` for "the user typed `text` into this cell": the one
|
|
/// composition, shared by the mouse path and the keyboard path.
|
|
pub fn typed_cell_command(id: &CellId, text: &str) -> Option<ModelCommand> {
|
|
let CellId::Record(cell) = id else {
|
|
return None;
|
|
};
|
|
let cell: RecordCell = cell.clone();
|
|
Some(match typed(text) {
|
|
Some(value) => ModelCommand::SetCell { cell, value },
|
|
None => ModelCommand::ClearCell(cell),
|
|
})
|
|
}
|
|
|
|
// ── the second tranche (improvise-e24v) ─────────────────────────────────
|
|
|
|
/// A document mutation under the basis it claims. The records claimed are
|
|
/// `records_named`'s — the same function the server checks against (§2).
|
|
fn model(state: &ClientState, cmd: ModelCommand) -> Option<Command> {
|
|
Some(Command::Model {
|
|
basis: state.basis.claim(records_named(&cmd))?,
|
|
cmd,
|
|
})
|
|
}
|
|
|
|
/// `cell.rs`'s `CellKey` argument, as much of it as ADR 002 lets a thin client
|
|
/// name. An aggregate is not a cell this client may write — clients.md.
|
|
fn record_cell_at_cursor(state: &ClientState) -> Option<RecordCell> {
|
|
match cell_at_cursor(state)?.id.as_ref()? {
|
|
CellId::Record(cell) => Some(cell.clone()),
|
|
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `CyclePanelFocus::execute`, line for line. `start` is the panel *after* the
|
|
/// focused one, or the first when the caller is not in a panel.
|
|
fn cycle_panel_focus(view: &ViewState) -> Vec<Command> {
|
|
let order = [
|
|
(view.formula_panel_open, AppMode::FormulaPanel),
|
|
(view.category_panel_open, AppMode::CategoryPanel),
|
|
(view.view_panel_open, AppMode::ViewPanel),
|
|
];
|
|
let start = match view.mode {
|
|
AppMode::FormulaPanel => 1,
|
|
AppMode::CategoryPanel => 2,
|
|
AppMode::ViewPanel => 3,
|
|
_ => 0,
|
|
};
|
|
(0..order.len())
|
|
.map(|i| &order[(start + i) % order.len()])
|
|
.find(|(open, _)| *open)
|
|
.map(|(_, mode)| vec![Command::ChangeMode(mode.clone())])
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// `CmdContext::cat_tree_entry`, over the cache rather than the `Model`. The
|
|
/// flattening is the one shared derivation (improvise-edp).
|
|
fn cat_tree_entry(state: &ClientState) -> Option<CatTreeEntry> {
|
|
cat_tree_entry_at(state, state.view.cat_panel_cursor)
|
|
}
|
|
|
|
/// The flattened entry at an arbitrary index. The keyboard asks for the
|
|
/// cursor's; a click asks for the row it landed on (improvise-7ybz).
|
|
fn cat_tree_entry_at(state: &ClientState, index: usize) -> Option<CatTreeEntry> {
|
|
flatten_cat_tree(&state.categories, &state.view.expanded_cats)
|
|
.into_iter()
|
|
.nth(index)
|
|
}
|
|
|
|
fn cat_at_cursor(state: &ClientState) -> Option<String> {
|
|
cat_tree_entry(state).map(|e| e.cat_name().to_string())
|
|
}
|
|
|
|
/// The axis the panel's own cache holds for `cat`. An item row names its
|
|
/// category, which is why this is a lookup rather than a field on the entry.
|
|
fn cat_axis(state: &ClientState, cat: &str) -> Option<Axis> {
|
|
state
|
|
.categories
|
|
.categories
|
|
.iter()
|
|
.find(|row| row.name == cat)
|
|
.map(|row| row.axis)
|
|
}
|
|
|
|
/// `ToggleCatExpand::execute`. Reached by `filter-to-item` on a header row,
|
|
/// which is the only binding that leads here.
|
|
fn toggle_cat_expand(state: &ClientState) -> Vec<Command> {
|
|
toggle_cat_expand_at(state, state.view.cat_panel_cursor)
|
|
}
|
|
|
|
fn toggle_cat_expand_at(state: &ClientState, index: usize) -> Vec<Command> {
|
|
match cat_tree_entry_at(state, index) {
|
|
Some(entry) => vec![Command::ToggleCatExpand(entry.cat_name().to_string())],
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// `FilterToItem::execute`: an item row filters, a header row expands, and
|
|
/// nothing at all is the third arm rather than a status.
|
|
fn filter_to_item(state: &ClientState) -> Option<Vec<Command>> {
|
|
filter_to_item_at(state, state.view.cat_panel_cursor)
|
|
}
|
|
|
|
fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
match cat_tree_entry_at(state, index) {
|
|
Some(CatTreeEntry::Item {
|
|
cat_name,
|
|
item_name,
|
|
}) => Some(vec![
|
|
model(
|
|
state,
|
|
ModelCommand::SetAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: cat_name.clone(),
|
|
axis: Axis::Page,
|
|
},
|
|
)?,
|
|
model(
|
|
state,
|
|
ModelCommand::SetPageSelection {
|
|
view: state.view.active_view.clone(),
|
|
category: cat_name.clone(),
|
|
item: item_name.clone(),
|
|
},
|
|
)?,
|
|
Command::SetStatus(format!("Filter: {cat_name} = {item_name}")),
|
|
]),
|
|
Some(CatTreeEntry::Category { .. }) => Some(toggle_cat_expand_at(state, index)),
|
|
None => Some(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// `DeleteCategoryAtCursor::execute`: a header deletes the category, an item
|
|
/// row deletes the item, and an empty tree says so.
|
|
fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
|
|
match cat_tree_entry(state) {
|
|
Some(CatTreeEntry::Category { name, .. }) => Some(vec![
|
|
model(state, ModelCommand::RemoveCategory(name.clone()))?,
|
|
Command::SetStatus(format!("Deleted category '{name}'")),
|
|
]),
|
|
Some(CatTreeEntry::Item {
|
|
cat_name,
|
|
item_name,
|
|
}) => {
|
|
let mut commands = vec![
|
|
model(
|
|
state,
|
|
ModelCommand::RemoveItem {
|
|
category: cat_name.clone(),
|
|
item: item_name.clone(),
|
|
},
|
|
)?,
|
|
Command::SetStatus(format!("Deleted item '{item_name}' from '{cat_name}'")),
|
|
];
|
|
commands.extend(retreat_from_deleted_item(state));
|
|
Some(commands)
|
|
}
|
|
None => Some(vec![Command::SetStatus(
|
|
"No category to delete".to_string(),
|
|
)]),
|
|
}
|
|
}
|
|
|
|
/// Where the cursor may sit after an item is deleted, in the tree the deletion
|
|
/// leaves behind. improvise-uej8, `context/code/clients.md`.
|
|
fn retreat_from_deleted_item(state: &ClientState) -> Option<Command> {
|
|
let tree = flatten_cat_tree(&state.categories, &state.view.expanded_cats);
|
|
let cursor = state.view.cat_panel_cursor;
|
|
safe_landing(&tree, cursor)
|
|
.filter(|&landing| landing != cursor)
|
|
.map(|cursor| Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor,
|
|
})
|
|
}
|
|
|
|
/// The nearest row `d` deletes an ITEM from, indexed in the post-deletion
|
|
/// tree. `None` means there is none — clients.md names what that costs.
|
|
fn safe_landing(tree: &[CatTreeEntry], cursor: usize) -> Option<usize> {
|
|
let item_at = |i: usize| {
|
|
tree.get(i)
|
|
.is_some_and(|e| matches!(e, CatTreeEntry::Item { .. }))
|
|
};
|
|
let closed_up = item_at(cursor + 1).then_some(cursor);
|
|
closed_up
|
|
.or_else(|| (0..cursor).rev().find(|&j| item_at(j)))
|
|
.or_else(|| {
|
|
((cursor + 1)..tree.len())
|
|
.find(|&j| item_at(j))
|
|
.map(|j| j - 1)
|
|
})
|
|
}
|
|
|
|
/// The view the panel cursor names. `ViewListCache` is flat and in the
|
|
/// workbook's own order, which is what `ctx.workbook.views.keys()` walks.
|
|
fn view_at_cursor(state: &ClientState) -> Option<String> {
|
|
state
|
|
.views
|
|
.views
|
|
.get(state.view.view_panel_cursor)
|
|
.map(|summary| summary.name.clone())
|
|
}
|
|
|
|
/// `SwitchViewAtCursor::execute`'s whole answer, at an arbitrary index. The
|
|
/// keyboard passes its cursor and a click passes the row — clients.md.
|
|
fn switch_view_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
|
let summary = state.views.views.get(index)?;
|
|
Some(vec![
|
|
Command::SwitchView(summary.name.clone()),
|
|
Command::ChangeMode(AppMode::Normal),
|
|
])
|
|
}
|
|
|
|
/// `DeleteViewAtCursor::execute`, including the cursor step back that only
|
|
/// happens away from the top of the list.
|
|
fn delete_view_at_cursor(state: &ClientState) -> Option<Vec<Command>> {
|
|
let Some(name) = view_at_cursor(state) else {
|
|
return Some(Vec::new());
|
|
};
|
|
let mut commands = vec![model(state, ModelCommand::DeleteView(name))?];
|
|
if state.view.view_panel_cursor > 0 {
|
|
commands.push(Command::SetPanelCursor {
|
|
panel: Panel::View,
|
|
cursor: state.view.view_panel_cursor - 1,
|
|
});
|
|
}
|
|
Some(commands)
|
|
}
|
|
|
|
/// `TileAxisOp::execute` for both of its names: `None` cycles, `Some` sets.
|
|
/// The status is the *new* axis either way, which is why it is computed here.
|
|
fn tile_axis(state: &ClientState, axis: Option<Axis>) -> Option<Vec<Command>> {
|
|
tile_axis_at(state, state.view.tile_cat_idx, axis)
|
|
}
|
|
|
|
fn tile_axis_at(state: &ClientState, index: usize, axis: Option<Axis>) -> Option<Vec<Command>> {
|
|
let Some(tile) = state.tiles.tiles.get(index) else {
|
|
return Some(Vec::new());
|
|
};
|
|
let new_axis = axis.unwrap_or_else(|| tile.axis.next());
|
|
let cmd = match axis {
|
|
Some(axis) => ModelCommand::SetAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: tile.category.clone(),
|
|
axis,
|
|
},
|
|
None => ModelCommand::CycleAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: tile.category.clone(),
|
|
},
|
|
};
|
|
Some(vec![
|
|
model(state, cmd)?,
|
|
Command::SetStatus(axis_change_status(&tile.category, new_axis)),
|
|
])
|
|
}
|
|
|
|
/// `core.rs::parse_axis`'s spellings. Spelled again because that crate is not
|
|
/// on a browser's dependency list; the *keys* are not spelled twice.
|
|
fn parse_axis(s: &str) -> Option<Axis> {
|
|
match s.to_lowercase().as_str() {
|
|
"row" => Some(Axis::Row),
|
|
"column" | "col" => Some(Axis::Column),
|
|
"page" => Some(Axis::Page),
|
|
"none" => Some(Axis::None),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A mirror of `commit.rs`'s `CommitKind`, resolved against the mode exactly
|
|
/// as `parse_commit_kind` does — only `Item` needs the mode.
|
|
enum CommitKind {
|
|
Formula,
|
|
Category,
|
|
Item { category: String },
|
|
}
|
|
|
|
impl CommitKind {
|
|
fn of(arg: &str, mode: &AppMode) -> Option<Self> {
|
|
match (arg, mode) {
|
|
("formula", _) => Some(Self::Formula),
|
|
("category", _) => Some(Self::Category),
|
|
("item", AppMode::ItemAdd { category, .. }) => Some(Self::Item {
|
|
category: category.clone(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn buffer(&self) -> &'static str {
|
|
match self {
|
|
Self::Formula => "formula",
|
|
Self::Category => "category",
|
|
Self::Item { .. } => "item",
|
|
}
|
|
}
|
|
|
|
fn on_empty(&self) -> AppMode {
|
|
match self {
|
|
Self::Formula => AppMode::FormulaPanel,
|
|
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
|
|
}
|
|
}
|
|
|
|
fn on_commit(&self) -> Option<AppMode> {
|
|
match self {
|
|
Self::Formula => Some(AppMode::FormulaPanel),
|
|
Self::Category | Self::Item { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn status(&self, name: &str) -> String {
|
|
match self {
|
|
Self::Formula => "Formula added".to_string(),
|
|
Self::Category => format!("Added category \"{name}\""),
|
|
Self::Item { .. } => format!("Added \"{name}\""),
|
|
}
|
|
}
|
|
|
|
fn add(&self, name: &str) -> ModelCommand {
|
|
match self {
|
|
Self::Formula => ModelCommand::AddFormula {
|
|
raw: name.to_string(),
|
|
target_category: "_Measure".to_string(),
|
|
},
|
|
Self::Category => ModelCommand::AddCategory(name.to_string()),
|
|
Self::Item { category } => ModelCommand::AddItem {
|
|
category: category.clone(),
|
|
item: name.to_string(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `CommitBuffer::execute`: the status is optimistic and goes **first**, so
|
|
/// the add's own failure message wins on the host (improvise-6t3).
|
|
fn commit_buffer(state: &ClientState, kind: CommitKind) -> Option<Vec<Command>> {
|
|
let raw = read_buffer(&state.view, kind.buffer());
|
|
let name = raw.trim();
|
|
match name.is_empty() {
|
|
true => Some(vec![Command::ChangeMode(kind.on_empty())]),
|
|
false => {
|
|
let mut commands = vec![
|
|
Command::SetStatus(kind.status(name)),
|
|
model(state, kind.add(name))?,
|
|
];
|
|
commands.extend(kind.on_commit().map(Command::ChangeMode));
|
|
Some(commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::keymap::Keymap;
|
|
use crate::{AxisFrame, CellDisplay, GridUpdate, KeyModifiers, Projection, Snapshot};
|
|
|
|
fn ev(code: KeyCode) -> KeyEvent {
|
|
KeyEvent::new(code, KeyModifiers::NONE)
|
|
}
|
|
|
|
/// A client in `mode` with **empty caches** — no grid, no panels, no
|
|
/// tiles, and no announced window.
|
|
fn in_mode(mode: AppMode) -> ClientState {
|
|
ClientState {
|
|
view: ViewState {
|
|
mode,
|
|
..ViewState::default()
|
|
},
|
|
..ClientState::default()
|
|
}
|
|
}
|
|
|
|
/// The user's ruling: the category panel is open at launch. It is the
|
|
/// *shared* default, so both front ends open with it — clients.md.
|
|
#[test]
|
|
fn a_fresh_session_opens_with_the_category_panel() {
|
|
let fresh = ViewState::default();
|
|
assert!(
|
|
fresh.category_panel_open,
|
|
"a client that has pressed nothing can already see its categories"
|
|
);
|
|
assert!(
|
|
!fresh.formula_panel_open && !fresh.view_panel_open,
|
|
"and only that one — the ruling names the category panel alone"
|
|
);
|
|
}
|
|
|
|
// ── the pointer (improvise-7ybz) ────────────────────────────────
|
|
|
|
fn with_categories() -> ClientState {
|
|
let mut state = ClientState::default();
|
|
// Explicitly shut: these tests are about a click *opening* the panel,
|
|
// which the launch default would otherwise answer for them.
|
|
state.view.category_panel_open = false;
|
|
state.fold(&Command::Project(Projection::CategoryTree(
|
|
crate::CategoryTreeUpdate::Categories(vec![
|
|
crate::CategoryRow {
|
|
name: "Region".into(),
|
|
items: vec!["North".into(), "South".into()],
|
|
axis: Axis::Row,
|
|
},
|
|
crate::CategoryRow {
|
|
name: "Product".into(),
|
|
items: vec!["Shirts".into()],
|
|
axis: Axis::Column,
|
|
},
|
|
]),
|
|
)));
|
|
state
|
|
}
|
|
|
|
/// A click is three intents in one, and the third must be resolved
|
|
/// against the row clicked — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row() {
|
|
let state = with_categories();
|
|
assert_eq!(
|
|
state.view.cat_panel_cursor, 0,
|
|
"the cursor starts elsewhere"
|
|
);
|
|
|
|
let commands = click(&state, Click::CategoryRow(1));
|
|
assert_eq!(
|
|
commands,
|
|
vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true
|
|
},
|
|
Command::ChangeMode(AppMode::CategoryPanel),
|
|
Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor: 1
|
|
},
|
|
// `Product`, the row clicked — NOT `Region`, which is where
|
|
// the cursor still is when this is resolved.
|
|
Command::ToggleCatExpand("Product".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// The focus half emits only what is not already true, so a click inside
|
|
/// the focused panel is not a mode change every time.
|
|
#[test]
|
|
fn a_click_in_an_already_focused_panel_only_moves_and_activates() {
|
|
let mut state = with_categories();
|
|
state.view.category_panel_open = true;
|
|
state.view.mode = AppMode::CategoryPanel;
|
|
assert_eq!(
|
|
click(&state, Click::CategoryRow(0)),
|
|
vec![
|
|
Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor: 0
|
|
},
|
|
Command::ToggleCatExpand("Region".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// A click on the panel's empty space is not a click on a row, and must
|
|
/// not drag the cursor onto one.
|
|
#[test]
|
|
fn a_click_past_the_last_row_resolves_to_nothing() {
|
|
assert!(click(&with_categories(), Click::CategoryRow(9)).is_empty());
|
|
assert!(click(&ClientState::default(), Click::Tile(0)).is_empty());
|
|
assert!(click(&ClientState::default(), Click::ViewRow(0)).is_empty());
|
|
assert!(click(&ClientState::default(), Click::FormulaRow(0)).is_empty());
|
|
}
|
|
|
|
/// Two tiles and a **published basis**, so a missing `Command::Model` is
|
|
/// a decision rather than a client that could not compose one.
|
|
fn with_tiles() -> ClientState {
|
|
let mut state = ClientState::default();
|
|
state.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![Projection::Basis(published())],
|
|
});
|
|
state.tiles.tiles = vec![
|
|
crate::TileEntry {
|
|
category: "Region".into(),
|
|
axis: Axis::Row,
|
|
},
|
|
crate::TileEntry {
|
|
category: "Product".into(),
|
|
axis: Axis::Column,
|
|
},
|
|
];
|
|
state
|
|
}
|
|
|
|
/// The user's ruling: a click **selects**, it never mutates. The basis is
|
|
/// published here, so nothing stops the cycle but the rule itself.
|
|
#[test]
|
|
fn a_first_tile_click_selects_without_touching_the_document() {
|
|
let state = with_tiles();
|
|
let commands = click(&state, Click::Tile(1));
|
|
assert_eq!(
|
|
commands,
|
|
vec![
|
|
Command::SetTileCatIdx(1),
|
|
Command::ChangeMode(AppMode::TileSelect),
|
|
],
|
|
"select and enter TileSelect, and stop there"
|
|
);
|
|
assert!(
|
|
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
|
"a click must not change the document; the axis cycle needs a second one"
|
|
);
|
|
}
|
|
|
|
/// `tile_cat_idx` is 0 before anything has selected anything, so the
|
|
/// already-selected test needs the **mode** as well as the index.
|
|
#[test]
|
|
fn the_first_click_on_tile_zero_selects_it_like_any_other() {
|
|
let state = with_tiles();
|
|
assert_eq!(state.view.tile_cat_idx, 0, "the default index is the trap");
|
|
let commands = click(&state, Click::Tile(0));
|
|
assert_eq!(
|
|
commands,
|
|
vec![
|
|
Command::SetTileCatIdx(0),
|
|
Command::ChangeMode(AppMode::TileSelect),
|
|
]
|
|
);
|
|
assert!(
|
|
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
|
"index alone would read tile 0 as already selected and cycle it"
|
|
);
|
|
}
|
|
|
|
/// Entering `TileSelect` by keyboard leaves `tile_cat_idx` at 0, so a click
|
|
/// on the leftmost tile cycles on the FIRST gesture. See clients.md.
|
|
#[test]
|
|
fn t_then_a_click_on_the_leftmost_tile_cycles_it_immediately() {
|
|
let mut state = with_tiles();
|
|
state.view.mode = AppMode::TileSelect;
|
|
assert_eq!(state.view.tile_cat_idx, 0, "what `T` leaves behind");
|
|
let commands = click(&state, Click::Tile(0));
|
|
assert!(
|
|
commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
|
"the tile really is selected, and a click does what Enter does there"
|
|
);
|
|
assert!(
|
|
!click(&state, Click::Tile(1))
|
|
.iter()
|
|
.any(|c| matches!(c, Command::Model { .. })),
|
|
"its neighbour answers the identical gesture by selecting — deliberate"
|
|
);
|
|
}
|
|
|
|
/// The second click is on the **already-selected** tile, not merely the
|
|
/// second click of the session — otherwise the guarantee holds once.
|
|
#[test]
|
|
fn a_second_click_on_the_selected_tile_cycles_its_axis() {
|
|
let mut state = with_tiles();
|
|
state.view.mode = AppMode::TileSelect;
|
|
state.view.tile_cat_idx = 1;
|
|
assert_eq!(
|
|
click(&state, Click::Tile(1)),
|
|
vec![
|
|
Command::Model {
|
|
basis: state.basis.claim([]).unwrap(),
|
|
cmd: ModelCommand::CycleAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: "Product".into(),
|
|
},
|
|
},
|
|
// `Column.next()` is `Page` — the *new* axis, as `Enter` says.
|
|
Command::SetStatus("Product → Page".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Clicking A then B selects B. Cycling B would make "a click does not
|
|
/// mutate" true only of the first click in a session.
|
|
#[test]
|
|
fn clicking_a_different_tile_selects_it_rather_than_cycling_it() {
|
|
let mut state = with_tiles();
|
|
state.view.mode = AppMode::TileSelect;
|
|
state.view.tile_cat_idx = 0;
|
|
let commands = click(&state, Click::Tile(1));
|
|
assert_eq!(
|
|
commands,
|
|
vec![Command::SetTileCatIdx(1)],
|
|
"already in TileSelect, so only the index moves"
|
|
);
|
|
assert!(!commands.iter().any(|c| matches!(c, Command::Model { .. })));
|
|
}
|
|
|
|
/// improvise-uej8: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category() {
|
|
let mut state = with_categories();
|
|
state.fold(&Command::Project(Projection::Basis(published())));
|
|
state.view.expanded_cats.insert("Region".into());
|
|
state.view.mode = AppMode::CategoryPanel;
|
|
state.view.cat_panel_cursor = 2;
|
|
assert!(
|
|
matches!(
|
|
cat_tree_entry_at(&state, 3),
|
|
Some(CatTreeEntry::Category { .. })
|
|
),
|
|
"row 3 is the NEXT category's header — the row d would land on"
|
|
);
|
|
|
|
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
|
|
assert!(
|
|
commands.contains(&Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor: 1,
|
|
}),
|
|
"the cursor stayed over a category header, so one more d removes a \
|
|
whole category: {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// The other direction, which a cursor that always steps back would break:
|
|
/// holding `d` must keep clearing the category out, not walk backwards.
|
|
#[test]
|
|
fn deleting_an_item_with_a_sibling_below_it_leaves_the_cursor_alone() {
|
|
let mut state = with_categories();
|
|
state.fold(&Command::Project(Projection::Basis(published())));
|
|
state.view.expanded_cats.insert("Region".into());
|
|
state.view.cat_panel_cursor = 1;
|
|
|
|
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
|
|
assert!(
|
|
!commands
|
|
.iter()
|
|
.any(|c| matches!(c, Command::SetPanelCursor { .. })),
|
|
"South takes row 1; the cursor has nowhere to go: {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// improvise-uej8, the singleton arm: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn deleting_a_categorys_only_item_does_not_arm_the_next_d_with_remove_category() {
|
|
let mut state = with_categories();
|
|
state.fold(&Command::Project(Projection::Basis(published())));
|
|
state.view.expanded_cats.insert("Region".into());
|
|
state.view.expanded_cats.insert("Product".into());
|
|
state.view.cat_panel_cursor = 4;
|
|
assert_eq!(
|
|
cat_tree_entry_at(&state, 4),
|
|
Some(CatTreeEntry::Item {
|
|
cat_name: "Product".into(),
|
|
item_name: "Shirts".into(),
|
|
}),
|
|
"row 4 is Product's ONLY item — no sibling above it and none below"
|
|
);
|
|
assert!(
|
|
cat_tree_entry_at(&state, 5).is_none()
|
|
&& matches!(
|
|
cat_tree_entry_at(&state, 3),
|
|
Some(CatTreeEntry::Category { .. })
|
|
),
|
|
"and it is fenced by its own header above and the end below"
|
|
);
|
|
|
|
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
|
|
let landed = commands
|
|
.iter()
|
|
.find_map(|c| match c {
|
|
Command::SetPanelCursor { cursor, .. } => Some(*cursor),
|
|
_ => None,
|
|
})
|
|
.unwrap_or(state.view.cat_panel_cursor);
|
|
|
|
let mut after = with_categories();
|
|
after.fold(&Command::Project(Projection::Basis(published())));
|
|
after.categories.categories[1].items.clear();
|
|
after.view.expanded_cats.insert("Region".into());
|
|
after.view.expanded_cats.insert("Product".into());
|
|
after.view.cat_panel_cursor = landed;
|
|
assert!(
|
|
!matches!(
|
|
cat_tree_entry_at(&after, landed),
|
|
Some(CatTreeEntry::Category { .. })
|
|
),
|
|
"the cursor landed on a category header, so holding d deletes the \
|
|
category next: row {landed} is {:?}",
|
|
cat_tree_entry_at(&after, landed)
|
|
);
|
|
assert!(
|
|
!delete_at_cat_cursor(&after)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.any(|c| matches!(
|
|
c,
|
|
Command::Model {
|
|
cmd: ModelCommand::RemoveCategory(_),
|
|
..
|
|
}
|
|
)),
|
|
"the second d composed a RemoveCategory"
|
|
);
|
|
}
|
|
|
|
/// improvise-25bf: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn cycling_an_axis_from_the_category_panel_says_so_as_the_tile_bar_does() {
|
|
let mut state = with_categories();
|
|
state.fold(&Command::Project(Projection::Basis(published())));
|
|
state.view.mode = AppMode::CategoryPanel;
|
|
state.view.cat_panel_cursor = 0;
|
|
|
|
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char(' '))) else {
|
|
panic!("Space is advertised in the CATEGORIES hint; it must answer");
|
|
};
|
|
assert!(
|
|
matches!(commands.first(), Some(Command::Model { .. })),
|
|
"the status must accompany the mutation, not replace it"
|
|
);
|
|
assert_eq!(
|
|
commands.last(),
|
|
Some(&Command::SetStatus("Region → Col".to_string())),
|
|
"the tile route says it and this one did not — one operation, two \
|
|
derivations, only one of which tells the user"
|
|
);
|
|
}
|
|
|
|
/// improvise-koh6: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn n_in_normal_opens_the_category_prompt_rather_than_falling_through() {
|
|
let state = in_mode(AppMode::Normal);
|
|
assert!(state.view.search_query.is_empty(), "no search is running");
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('N'))),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true,
|
|
},
|
|
Command::ChangeMode(AppMode::category_add()),
|
|
]),
|
|
"the help overlay advertises N, and an Upstream here is a black \
|
|
hole that leaves the keyboard in a MUTATING mode"
|
|
);
|
|
}
|
|
|
|
/// The rule `enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream`
|
|
/// protects: whether the host is needed is a property of the NAME.
|
|
#[test]
|
|
fn search_or_category_add_is_answered_here_whatever_the_search_query_holds() {
|
|
for query in ["", "North"] {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
state.view.search_query = query.to_string();
|
|
let outcome = resolve(&state, ev(KeyCode::Char('N')));
|
|
assert!(
|
|
matches!(outcome, KeyOutcome::Local(_)),
|
|
"query {query:?} sent N upstream: {outcome:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `None` from a `click_*` means "this gesture composed nothing"; `None`
|
|
/// from `step` means "not this client's command". See clients.md.
|
|
#[test]
|
|
fn enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream() {
|
|
let state = in_mode(AppMode::ViewPanel);
|
|
assert!(state.views.views.is_empty(), "no views to switch to");
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Enter)),
|
|
KeyOutcome::Ignored,
|
|
"an empty panel is a state, not a command the host must answer — \
|
|
Upstream here would make the host-needed list state-dependent"
|
|
);
|
|
}
|
|
|
|
/// improvise-hkr8: a click is all of its intents or none of them. With no
|
|
/// published basis the activation cannot be composed, so nothing is.
|
|
#[test]
|
|
fn a_click_that_cannot_compose_its_activation_composes_nothing() {
|
|
let mut state = with_categories();
|
|
state.view.expanded_cats.insert("Region".into());
|
|
assert!(
|
|
matches!(
|
|
cat_tree_entry_at(&state, 1),
|
|
Some(CatTreeEntry::Item { .. })
|
|
),
|
|
"row 1 is an item row, whose activation is a document change"
|
|
);
|
|
assert!(
|
|
state.basis.claim([]).is_none(),
|
|
"and this client has been published no basis"
|
|
);
|
|
assert!(
|
|
click(&state, Click::CategoryRow(1)).is_empty(),
|
|
"the focus and the cursor move must not survive the activation's failure"
|
|
);
|
|
}
|
|
|
|
/// The same click, once the basis has arrived: all four intents.
|
|
#[test]
|
|
fn the_same_click_composes_everything_once_a_basis_is_published() {
|
|
let mut state = with_categories();
|
|
state.view.expanded_cats.insert("Region".into());
|
|
state.fold(&Command::Project(Projection::Basis(published())));
|
|
let basis = state.basis.claim([]).unwrap();
|
|
assert_eq!(
|
|
click(&state, Click::CategoryRow(1)),
|
|
vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true
|
|
},
|
|
Command::ChangeMode(AppMode::CategoryPanel),
|
|
Command::SetPanelCursor {
|
|
panel: Panel::Category,
|
|
cursor: 1
|
|
},
|
|
Command::Model {
|
|
basis: basis.clone(),
|
|
cmd: ModelCommand::SetAxis {
|
|
view: state.view.active_view.clone(),
|
|
category: "Region".into(),
|
|
axis: Axis::Page
|
|
},
|
|
},
|
|
Command::Model {
|
|
basis,
|
|
cmd: ModelCommand::SetPageSelection {
|
|
view: state.view.active_view.clone(),
|
|
category: "Region".into(),
|
|
item: "North".into()
|
|
},
|
|
},
|
|
Command::SetStatus("Filter: Region = North".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
fn with_panels() -> ClientState {
|
|
let mut state = ClientState::default();
|
|
state.fold(&Command::Project(Projection::ViewList(vec![
|
|
crate::ViewSummary {
|
|
name: "Default".into(),
|
|
axes: vec![],
|
|
},
|
|
crate::ViewSummary {
|
|
name: "Plan".into(),
|
|
axes: vec![],
|
|
},
|
|
])));
|
|
state.fold(&Command::Project(Projection::Formulas(vec![
|
|
"Profit = Revenue - Cost".into(),
|
|
"Margin = Profit / Revenue".into(),
|
|
])));
|
|
state
|
|
}
|
|
|
|
/// improvise-iioi: the same three intents the category panel gets, with
|
|
/// `switch-view-at-cursor` as the activation.
|
|
#[test]
|
|
fn a_view_row_click_focuses_moves_and_switches_to_the_row_clicked() {
|
|
let state = with_panels();
|
|
assert_eq!(
|
|
click(&state, Click::ViewRow(1)),
|
|
vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::View,
|
|
open: true
|
|
},
|
|
Command::ChangeMode(AppMode::ViewPanel),
|
|
Command::SetPanelCursor {
|
|
panel: Panel::View,
|
|
cursor: 1
|
|
},
|
|
// `Plan`, the row clicked — not `Default`, where the cursor is.
|
|
Command::SwitchView("Plan".into()),
|
|
Command::ChangeMode(AppMode::Normal),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// The formula panel binds no `Enter`, so the rule yields navigation and
|
|
/// nothing else. A mode change here would be an invented activation.
|
|
#[test]
|
|
fn a_formula_row_click_navigates_and_activates_nothing() {
|
|
let state = with_panels();
|
|
let commands = click(&state, Click::FormulaRow(1));
|
|
assert_eq!(
|
|
commands,
|
|
vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Formula,
|
|
open: true
|
|
},
|
|
Command::ChangeMode(AppMode::FormulaPanel),
|
|
Command::SetPanelCursor {
|
|
panel: Panel::Formula,
|
|
cursor: 1
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
/// The asymmetry is a fact about the keymap, so it is asserted against
|
|
/// the keymap — not against a reading of it. See `context/code/clients.md`.
|
|
#[test]
|
|
fn the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does() {
|
|
let keymaps = Keymaps::shared();
|
|
assert!(
|
|
keymaps
|
|
.lookup(&ModeKey::FormulaPanel, KeyCode::Enter, KeyModifiers::NONE)
|
|
.is_none(),
|
|
"if FormulaPanel gains an Enter binding, click_formula_row must \
|
|
activate too — the click mirrors Enter, and this is the only \
|
|
thing tying the two together"
|
|
);
|
|
assert!(
|
|
keymaps
|
|
.lookup(&ModeKey::ViewPanel, KeyCode::Enter, KeyModifiers::NONE)
|
|
.is_some(),
|
|
"and the view panel's click activates because its Enter does"
|
|
);
|
|
}
|
|
|
|
/// The pointer and the keyboard are one cursor: a click resolves to the
|
|
/// same `SetSelected` a movement key would, offsets reconciled.
|
|
#[test]
|
|
fn a_cell_click_is_absolute_even_though_the_page_paints_a_window() {
|
|
let mut state = ClientState::default();
|
|
state.fold(&Command::Project(Projection::Grid(GridUpdate::Extent(
|
|
crate::GridExtent { rows: 40, cols: 9 },
|
|
))));
|
|
state.window = (10, 4);
|
|
state.view.cursor_mut().row_offset = 12;
|
|
state.view.cursor_mut().col_offset = 3;
|
|
|
|
assert_eq!(
|
|
click(&state, Click::Cell { row: 2, col: 1 }),
|
|
vec![Command::SetSelected { row: 14, col: 4 }],
|
|
"viewport (2,1) under offsets (12,3) is absolute (14,4)"
|
|
);
|
|
assert!(
|
|
click(&state, Click::Cell { row: 39, col: 0 }).is_empty(),
|
|
"a click below the grid names no cell"
|
|
);
|
|
}
|
|
|
|
// ── text entry ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn typing_a_character_in_any_text_entry_mode_resolves_locally() {
|
|
for mode in AppMode::text_entry() {
|
|
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
|
let mut state = in_mode(mode.clone());
|
|
state.view.buffers.insert(buffer.clone(), "Reg".into());
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('i'))),
|
|
KeyOutcome::Local(vec![Command::SetBuffer {
|
|
name: buffer,
|
|
value: "Regi".into(),
|
|
}]),
|
|
"{} should type locally",
|
|
crate::help::mode_label(&mode)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The old classifier sent a modified character upstream in a text-entry
|
|
/// mode; reading the same table removes the question. Evidence file.
|
|
#[test]
|
|
fn a_modified_character_now_types_because_the_fallback_is_the_shared_one() {
|
|
let mut state = in_mode(AppMode::editing());
|
|
state.view.buffers.insert("edit".into(), "R".into());
|
|
assert_eq!(
|
|
resolve(
|
|
&state,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)
|
|
),
|
|
KeyOutcome::Local(vec![Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "Rx".into(),
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_pops_locally_everywhere_except_command_mode() {
|
|
for mode in AppMode::text_entry() {
|
|
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
|
let mut state = in_mode(mode.clone());
|
|
state.view.buffers.insert(buffer.clone(), "Regi".into());
|
|
let outcome = resolve(&state, ev(KeyCode::Backspace));
|
|
if matches!(mode, AppMode::CommandMode { .. }) {
|
|
assert_eq!(
|
|
outcome,
|
|
KeyOutcome::Upstream(ev(KeyCode::Backspace)),
|
|
"CommandMode rebinds Backspace to a command that leaves the mode"
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
outcome,
|
|
KeyOutcome::Local(vec![Command::SetBuffer {
|
|
name: buffer,
|
|
value: "Reg".into(),
|
|
}]),
|
|
"{} should pop locally",
|
|
crate::help::mode_label(&mode)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `Esc` is a `Binding::Sequence` — clear the buffer, then leave — and
|
|
/// this client resolves both halves. Half would be worse than none.
|
|
#[test]
|
|
fn escape_clears_the_buffer_and_leaves_the_mode() {
|
|
let mut state = in_mode(AppMode::editing());
|
|
state
|
|
.view
|
|
.buffers
|
|
.insert("edit".into(), "half typed".into());
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Esc)),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: String::new(),
|
|
},
|
|
Command::ChangeMode(AppMode::Normal),
|
|
])
|
|
);
|
|
}
|
|
|
|
/// Search typing resolves here only because the keymap is shared, and
|
|
/// "search" is not a `buffers` entry. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn search_typing_writes_the_query_and_not_a_buffer() {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
state.view.search_mode = true;
|
|
state.view.search_query = "Nor".into();
|
|
state.grid.extent = GridExtent { rows: 9, cols: 9 };
|
|
state.window = (9, 9);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('t'))),
|
|
KeyOutcome::Local(vec![Command::SetSearchQuery("Nort".into())]),
|
|
"j/t in search mode types; it must not move the cursor"
|
|
);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Esc)),
|
|
KeyOutcome::Upstream(ev(KeyCode::Esc)),
|
|
"exit-search-mode owns the query and is the host's"
|
|
);
|
|
}
|
|
|
|
// ── what is bound and what is not ───────────────────────────────
|
|
|
|
/// A three-by-three grid with `id` at (1,1) and the cursor on it: no move
|
|
/// from there is clamped by an edge (improvise-6562).
|
|
fn centred_on(id: CellId) -> ClientState {
|
|
let mut centre = CellDisplay::number("1200");
|
|
centre.id = Some(id);
|
|
let mut cells: Vec<_> = (0..3)
|
|
.flat_map(|r| (0..3).map(move |c| ((r, c), CellDisplay::number("7"))))
|
|
.collect();
|
|
cells[4].1 = centre;
|
|
let mut client = ClientState::default();
|
|
client.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"North", "South", "East",
|
|
]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
|
"Revenue", "Cost", "Profit",
|
|
]))),
|
|
Projection::Grid(GridUpdate::Cells(cells)),
|
|
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 3 })),
|
|
Projection::Basis(published()),
|
|
],
|
|
});
|
|
client.window = (3, 3);
|
|
client.view.cursor_mut().selected = (1, 1);
|
|
client
|
|
}
|
|
|
|
/// A client whose every widget has something in it, so an `Upstream` names
|
|
/// an **unhandled command** and never a state that said no (improvise-rmvx).
|
|
fn furnished(mode: AppMode) -> ClientState {
|
|
furnished_on(mode, record_cell())
|
|
}
|
|
|
|
/// `furnished`, with `cell` under the cursor. A history in both directions
|
|
/// and a yanked value, so no `Cmd` is refused by an empty stack (3t5x).
|
|
fn furnished_on(mode: AppMode, cell: CellId) -> ClientState {
|
|
let mut state = centred_on(cell);
|
|
let frame = |name: &str| crate::ViewFrame {
|
|
view_name: name.into(),
|
|
mode: AppMode::Normal,
|
|
};
|
|
state.view.view_back_stack = vec![frame("Second")];
|
|
state.view.view_forward_stack = vec![frame("Third")];
|
|
state.view.yanked = Some(CellValue::Number(5.0));
|
|
// A records MODE over a non-records GRID is a state no host can
|
|
// produce, so the fixture projects both (improvise-e5pf).
|
|
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
|
|
state.view.mode = mode;
|
|
state.grid.page_coords = vec![crate::PageAxis {
|
|
cat: "Quarter".into(),
|
|
selected: "Q2".into(),
|
|
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
|
|
}];
|
|
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
|
|
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
|
|
crate::WidgetCache::apply(
|
|
&mut state.categories,
|
|
&crate::CategoryTreeUpdate::Categories(
|
|
["Region", "Product", "Quarter"]
|
|
.into_iter()
|
|
.map(|name| crate::CategoryRow {
|
|
name: name.into(),
|
|
items: vec!["North".into(), "South".into()],
|
|
axis: Axis::Row,
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
state.formulas.formulas = [
|
|
"Profit = Revenue - Cost",
|
|
"Tax = Revenue * 0.1",
|
|
"Net = Profit",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect();
|
|
state.views.views = ["Default", "Second", "Third"]
|
|
.into_iter()
|
|
.map(|name| crate::ViewSummary {
|
|
name: name.into(),
|
|
axes: Vec::new(),
|
|
})
|
|
.collect();
|
|
state.tiles.tiles = ["Region", "Product", "Quarter"]
|
|
.into_iter()
|
|
.map(|category| crate::TileEntry {
|
|
category: category.into(),
|
|
axis: Axis::Row,
|
|
})
|
|
.collect();
|
|
state.view.tile_cat_idx = 1;
|
|
state.view.help_page = 1;
|
|
state.view.cat_panel_cursor = 1;
|
|
state.view.formula_cursor = 1;
|
|
state.view.view_panel_cursor = 1;
|
|
state
|
|
.view
|
|
.buffers
|
|
.insert("formula".into(), "Profit = Revenue".into());
|
|
state
|
|
.view
|
|
.buffers
|
|
.insert("category".into(), "Product".into());
|
|
state.view.buffers.insert("item".into(), "Shirts".into());
|
|
state.view.buffers.insert("edit".into(), "42".into());
|
|
state
|
|
}
|
|
|
|
/// What a user sees an advertised key do. A status alone is `Declines`:
|
|
/// honest, but not what the caption promised (improvise-3t5x).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
enum Heard {
|
|
Acts,
|
|
Declines,
|
|
Silent,
|
|
}
|
|
|
|
/// Judged by what the fold MOVED, never by a command's type: a clamped move
|
|
/// or a lone disarm changes nothing (improvise-6562). `Model` is sent.
|
|
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
|
|
let commands = match outcome {
|
|
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
|
|
KeyOutcome::Request(_) => return Heard::Acts,
|
|
KeyOutcome::Local(commands) => commands,
|
|
};
|
|
let quiet = |view: &ViewState| ViewState {
|
|
status_msg: String::new(),
|
|
transient_prefix: None,
|
|
..view.clone()
|
|
};
|
|
let before = quiet(&state.view);
|
|
commands.iter().for_each(|c| state.fold(c));
|
|
let sent = commands.iter().any(|c| matches!(c, Command::Model { .. }));
|
|
let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_)));
|
|
match (sent || quiet(&state.view) != before, said) {
|
|
(true, _) => Heard::Acts,
|
|
(false, true) => Heard::Declines,
|
|
(false, false) => Heard::Silent,
|
|
}
|
|
}
|
|
|
|
/// Every `(mode, search, command, args)` either table advertises: the
|
|
/// status-bar hint and the help pages (improvise-wk18).
|
|
fn advertised_everywhere() -> Vec<(AppMode, bool, &'static str, Option<Vec<String>>)> {
|
|
let mut rows = Vec::new();
|
|
for mode in AppMode::all() {
|
|
for (command, args, _) in crate::help::advertised(&mode) {
|
|
rows.push((mode.clone(), false, command, args));
|
|
}
|
|
}
|
|
for (key, command, args, _) in crate::help::help_bindings() {
|
|
let (mode, search) = match key {
|
|
ModeKey::SearchMode => (AppMode::Normal, true),
|
|
key => match AppMode::all()
|
|
.into_iter()
|
|
.find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(&key))
|
|
{
|
|
Some(mode) => (mode, false),
|
|
None => panic!("a help row names {key:?}, which no mode reaches"),
|
|
},
|
|
};
|
|
rows.push((mode, search, command, args));
|
|
}
|
|
rows.sort_by_key(|(m, s, c, a)| (crate::mode_label(m), *s, *c, a.clone()));
|
|
rows.dedup();
|
|
rows
|
|
}
|
|
|
|
/// Every key sequence that runs `command` in `keymap`: one key, or a
|
|
/// prefix and the key after it.
|
|
fn sequences_running(
|
|
keymap: &Keymap,
|
|
command: &str,
|
|
args: Option<&[String]>,
|
|
) -> Vec<Vec<KeyEvent>> {
|
|
let one = keymap
|
|
.keys_running(command, args)
|
|
.into_iter()
|
|
.map(|(code, mods)| vec![KeyEvent::new(code, mods)]);
|
|
let two = keymap
|
|
.prefixed_keys_running(command, args)
|
|
.into_iter()
|
|
.map(|pair| pair.map(|(code, mods)| KeyEvent::new(code, mods)).to_vec());
|
|
one.chain(two).collect()
|
|
}
|
|
|
|
/// What pressing `sequence` in `state` does, arming any prefix first.
|
|
fn press(mut state: ClientState, sequence: &[KeyEvent]) -> Heard {
|
|
let (last, prefix) = sequence.split_last().expect("a sequence has a key");
|
|
for key in prefix {
|
|
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
|
|
commands.iter().for_each(|c| state.fold(c));
|
|
}
|
|
}
|
|
let outcome = resolve(&state, *last);
|
|
heard(&mut state, outcome)
|
|
}
|
|
|
|
/// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or
|
|
/// is named below saying why not. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn every_advertised_command_answers_in_its_own_mode() {
|
|
let aggregate = CellId::Aggregate(
|
|
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
|
);
|
|
let derived = CellId::Derived {
|
|
target: "Profit".into(),
|
|
};
|
|
// (mode, cell, command, heard) -> the keys that heard it.
|
|
let mut short: std::collections::BTreeMap<(String, &str, Heard), Vec<String>> =
|
|
Default::default();
|
|
for (mode, search, command, args) in advertised_everywhere() {
|
|
let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode");
|
|
let keymap = Keymaps::shared().get(&key).expect("a keymap");
|
|
let at = |cell: &CellId| {
|
|
let mut state = furnished_on(mode.clone(), cell.clone());
|
|
state.view.search_mode = search;
|
|
state
|
|
};
|
|
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
|
|
for sequence in sequences_running(keymap, command, args.as_deref()) {
|
|
let keys: String = sequence
|
|
.iter()
|
|
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
|
|
.collect();
|
|
let on_record = press(at(&record_cell()), &sequence);
|
|
let mut report = vec![(label.clone(), on_record)];
|
|
// Only a grid mode stands on another kind of cell, and a cell
|
|
// matters only where it changes the answer.
|
|
if mode == AppMode::Normal {
|
|
for (on, cell) in [
|
|
(" on an aggregate", &aggregate),
|
|
(" on a derived cell", &derived),
|
|
] {
|
|
let got = press(at(cell), &sequence);
|
|
if got != on_record {
|
|
report.push((label.clone() + on, got));
|
|
}
|
|
}
|
|
}
|
|
for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) {
|
|
short
|
|
.entry((where_, command, got))
|
|
.or_default()
|
|
.push(keys.clone());
|
|
}
|
|
}
|
|
}
|
|
let short: Vec<String> = short
|
|
.into_iter()
|
|
.map(|((at, command, got), mut keys)| {
|
|
keys.sort();
|
|
keys.dedup();
|
|
format!("{at}: {} {command} {got:?}", keys.join("/"))
|
|
})
|
|
.collect();
|
|
// Closing one has to come here and say so; adding one fails. A caption
|
|
// is a promise, and each line here is a promise a browser does not keep.
|
|
let expected: &[&str] = &[
|
|
// Reached only from a mode `declined` refuses at the door, so a
|
|
// browser is never standing in front of these.
|
|
"COMMAND: Enter execute-command Silent",
|
|
"EXPORT: Enter commit-export Silent",
|
|
"SAVE AS: Enter commit-save Silent",
|
|
"NORMAL SEARCH: Enter/Esc exit-search-mode Silent",
|
|
// Commands the browser has no arm for: upstream, so a black hole
|
|
// (improvise-uln5). Each is also in the host-only list below.
|
|
"NORMAL: Enter enter-advance Silent",
|
|
"NORMAL: H hide-selected-row-item Silent",
|
|
"NORMAL: / search Silent",
|
|
"NORMAL: n search-navigate Silent",
|
|
"NORMAL: z toggle-group-under-cursor Silent",
|
|
// Honestly unavailable, and saying so in `declined`'s words.
|
|
"FORMULAS: Del/d delete-formula-at-cursor Declines",
|
|
"NORMAL: > drill-into-cell Declines",
|
|
"NORMAL: : enter-mode Declines",
|
|
"NORMAL: P toggle-prune-empty Declines",
|
|
"NORMAL: R toggle-records-mode Declines",
|
|
"NORMAL: ZZ wq Declines",
|
|
"RECORDS: o add-record-row Declines",
|
|
"RECORDS: a/i edit-or-drill Declines",
|
|
"RECORDS: : enter-mode Declines",
|
|
"RECORDS: P toggle-prune-empty Declines",
|
|
"RECORDS: R toggle-records-mode Declines",
|
|
// `i` on an aggregate IS drill, which the browser lacks (s8h7); on a
|
|
// derived cell the browser names the formula instead (ir7l).
|
|
"NORMAL on an aggregate: a/i edit-or-drill Declines",
|
|
"NORMAL on a derived cell: a/i edit-or-drill Declines",
|
|
// No record under the cursor to write. The TUI writes onto the
|
|
// aggregate's partial key, which is its own bug (improvise-l4mp).
|
|
"NORMAL on an aggregate: p paste Silent",
|
|
"NORMAL on an aggregate: x clear-cell Silent",
|
|
"NORMAL on a derived cell: p paste Silent",
|
|
"NORMAL on a derived cell: x clear-cell Silent",
|
|
// A records cell commit needs `record_col` (gotcha 1); only the two
|
|
// creating columns resolve, and this fixture is on a record cell.
|
|
"RECORDS INSERT: Tab commit-and-advance-right Silent",
|
|
"RECORDS INSERT: Enter commit-cell-edit Silent",
|
|
];
|
|
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
|
expected.sort();
|
|
let mut short = short;
|
|
short.sort();
|
|
assert_eq!(
|
|
short,
|
|
expected,
|
|
"advertised keys that do not do what they say, in full:\n{}",
|
|
short.join("\n")
|
|
);
|
|
}
|
|
|
|
/// improvise-6562: "it moved" cannot see a move the WRONG way, so every grid
|
|
/// key is pressed from the centre and must land where it says. Evidence file.
|
|
#[test]
|
|
fn every_grid_move_lands_where_its_binding_says() {
|
|
let keymap = Keymaps::shared().get(&ModeKey::Normal).expect("Normal");
|
|
let mut bound = Vec::new();
|
|
bound_commands(keymap, &mut bound);
|
|
bound.sort();
|
|
bound.dedup();
|
|
// Far from every edge of a grid far larger than any step, so a landing
|
|
// shows magnitude as well as sign (improvise-5kdr); a jump's is its name.
|
|
const START: (usize, usize) = (10, 10);
|
|
const WINDOW: (usize, usize) = (8, 8);
|
|
let d = |a: &String| a.parse::<isize>().expect("a delta");
|
|
let at = |base: usize, by: isize| base.checked_add_signed(by).expect("inside the grid");
|
|
let page = page_rows(WINDOW.0) as isize;
|
|
let destination = |name: &str, args: &[String]| match name {
|
|
"move-selection" => Some((at(START.0, d(&args[0])), at(START.1, d(&args[1])))),
|
|
"scroll-rows" => Some((at(START.0, d(&args[0])), START.1)),
|
|
"page-scroll" => Some((at(START.0, page * d(&args[0])), START.1)),
|
|
"jump-first-row" => Some((0, START.1)),
|
|
"jump-last-row" => Some((20, START.1)),
|
|
"jump-first-col" => Some((START.0, 0)),
|
|
"jump-last-col" => Some((START.0, 20)),
|
|
_ => None,
|
|
};
|
|
let mut unstated = Vec::new();
|
|
let mut unpressed = Vec::new();
|
|
let mut pressed = std::collections::BTreeSet::new();
|
|
for (name, args) in &bound {
|
|
let to = destination(name, args);
|
|
let sequences = sequences_running(keymap, name, Some(args.as_slice()));
|
|
if to.is_some() && sequences.is_empty() {
|
|
unpressed.push(format!("{name} {args:?}"));
|
|
}
|
|
for sequence in sequences {
|
|
let mut state = furnished(AppMode::Normal);
|
|
state.grid.extent = GridExtent { rows: 21, cols: 21 };
|
|
state.window = WINDOW;
|
|
*state.view.cursor_mut() = crate::ViewCursor {
|
|
selected: START,
|
|
row_offset: 5,
|
|
col_offset: 5,
|
|
};
|
|
let view = state.view.active_view.clone();
|
|
for key in &sequence {
|
|
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
|
|
commands.iter().for_each(|c| state.fold(c));
|
|
}
|
|
}
|
|
let landed = state.view.cursor().selected;
|
|
if to.is_some() {
|
|
pressed.insert(name.as_str());
|
|
}
|
|
match to {
|
|
Some(to) => {
|
|
assert_eq!(
|
|
landed, to,
|
|
"{sequence:?} runs `{name} {args:?}` from {START:?}"
|
|
)
|
|
}
|
|
None if state.view.active_view == view && landed != START => {
|
|
unstated.push(format!("{name} {args:?} -> {landed:?}"))
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
unpressed.is_empty(),
|
|
"stated and never pressed: {unpressed:?}"
|
|
);
|
|
assert!(
|
|
unstated.is_empty(),
|
|
"these move the cursor and nothing states where to: {unstated:?}"
|
|
);
|
|
let every = [
|
|
"jump-first-col",
|
|
"jump-first-row",
|
|
"jump-last-col",
|
|
"jump-last-row",
|
|
"move-selection",
|
|
"page-scroll",
|
|
"scroll-rows",
|
|
];
|
|
assert_eq!(
|
|
pressed.into_iter().collect::<Vec<_>>(),
|
|
every,
|
|
"each kind is pressed"
|
|
);
|
|
}
|
|
|
|
/// improvise-rmvx. `step` checks `declined` first, so a name answered
|
|
/// there makes an arm in `composed` UNREACHABLE. Evidence file.
|
|
#[test]
|
|
fn nothing_declined_is_also_composed() {
|
|
let mut shadowed = Vec::new();
|
|
for mode in AppMode::all() {
|
|
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
|
|
continue;
|
|
};
|
|
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
|
|
continue;
|
|
};
|
|
let state = furnished(mode.clone());
|
|
// Derived from `declined` itself rather than from a list beside
|
|
// it: a seventh decline is covered without coming here.
|
|
let mut vocabulary = Vec::new();
|
|
bound_commands(keymap, &mut vocabulary);
|
|
for (name, args) in vocabulary {
|
|
if declined(&name, &args).is_none() {
|
|
continue;
|
|
}
|
|
if composed(&state, ev(KeyCode::Char('x')), &name, &args).is_some() {
|
|
shadowed.push(format!("{}: {name} {args:?}", crate::mode_label(&mode)));
|
|
}
|
|
}
|
|
}
|
|
shadowed.sort();
|
|
shadowed.dedup();
|
|
assert!(
|
|
shadowed.is_empty(),
|
|
"these are answered by BOTH `declined` and `composed`, and `step` \
|
|
reaches `declined` first — so the browser keeps saying \"not \
|
|
available yet\" while the working code sits unreachable:\n{}",
|
|
shadowed.join("\n")
|
|
);
|
|
}
|
|
|
|
/// improvise-lltt is deferred on a PREMISE, not a judgement: no shipped
|
|
/// client emits `SwitchView`. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn the_keys_that_compose_a_switch_view_are_these() {
|
|
let mut emitters = Vec::new();
|
|
for mode in AppMode::all() {
|
|
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
|
|
continue;
|
|
};
|
|
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
|
|
continue;
|
|
};
|
|
let state = furnished(mode.clone());
|
|
let mut vocabulary = Vec::new();
|
|
bound_commands(keymap, &mut vocabulary);
|
|
for (name, args) in vocabulary {
|
|
let Some(commands) = composed(&state, ev(KeyCode::Char('x')), &name, &args) else {
|
|
continue;
|
|
};
|
|
if commands.iter().any(|c| matches!(c, Command::SwitchView(_))) {
|
|
emitters.push(format!("{}: {name} {args:?}", crate::mode_label(&mode)));
|
|
}
|
|
}
|
|
}
|
|
emitters.sort();
|
|
emitters.dedup();
|
|
// NOT an exemption list. improvise-lltt was deferred on the premise
|
|
// that this list is EMPTY, and it is not: these two are live.
|
|
let known = [
|
|
"VIEWS: create-and-switch-view []",
|
|
"VIEWS: switch-view-at-cursor []",
|
|
];
|
|
assert_eq!(
|
|
emitters, known,
|
|
"improvise-lltt is LIVE through every name here. A client that \
|
|
switches view and then transposes moves the HOST's view, \
|
|
silently. A third name means a third live path; an empty list \
|
|
means lltt can go back to being deferred, and this test with it."
|
|
);
|
|
}
|
|
|
|
/// Every `(name, args)` a keymap, its parents and its prefixes bind —
|
|
/// the whole vocabulary a keypress in that mode can reach.
|
|
fn bound_commands(keymap: &Keymap, out: &mut Vec<(String, Vec<String>)>) {
|
|
for binding in keymap.bindings().values() {
|
|
match binding {
|
|
Binding::Cmd { name, args } => out.push((name.to_string(), args.clone())),
|
|
Binding::Sequence(steps) => out.extend(
|
|
steps
|
|
.iter()
|
|
.map(|(name, args)| (name.to_string(), args.clone())),
|
|
),
|
|
// Prefixes are flat (gotcha 13), so this terminates.
|
|
Binding::Prefix(sub) => bound_commands(sub, out),
|
|
}
|
|
}
|
|
if let Some(parent) = keymap.parent() {
|
|
bound_commands(parent, out);
|
|
}
|
|
}
|
|
|
|
/// The command **names** this client cannot execute, one by one, so that
|
|
/// closing one of these gaps has to come here and say so. Evidence file.
|
|
#[test]
|
|
fn the_command_names_that_still_need_the_host_are_these() {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
state.grid.extent = GridExtent { rows: 4, cols: 4 };
|
|
state.window = (4, 4);
|
|
for (key, name) in [
|
|
// The three prefixes left with improvise-hs0u: arming is a
|
|
// `Command`, and the second key resolves in the sub-keymap.
|
|
(ev(KeyCode::Char('H')), "hide-selected-row-item"),
|
|
(ev(KeyCode::Char('z')), "toggle-group-under-cursor"),
|
|
// `>`, `R`, `P`, Ctrl-W and Ctrl-E are NOT here any more: each
|
|
// answers with a status of its own (improvise-gd28, improvise-rmvx).
|
|
|
|
// `<` left too, and further: since improvise-cfly it composes a
|
|
// real `Command::ViewBack` rather than a refusal.
|
|
(ev(KeyCode::Char('/')), "search"),
|
|
(ev(KeyCode::Char('n')), "search-navigate"),
|
|
// `N` left this list with improvise-koh6: both its arms are
|
|
// answered here, one of them out loud. See clients.md.
|
|
(ev(KeyCode::Enter), "enter-advance"),
|
|
// Ctrl-S left this list with improvise-jz6w: it is a `Request`,
|
|
// not a `Command`, and the host has answered it since 368h.
|
|
(
|
|
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL),
|
|
"force-quit",
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
resolve(&state, key),
|
|
KeyOutcome::Upstream(key),
|
|
"{name} ({:?}) was resolved locally; if that is deliberate, \
|
|
this list is where it stops being a gap",
|
|
key.code
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-jz6w: the outcome a resolver had no way to name. See
|
|
/// `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn ctrl_s_asks_the_host_to_save_and_composes_nothing() {
|
|
let state = in_mode(AppMode::Normal);
|
|
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
|
assert_eq!(
|
|
resolve(&state, key),
|
|
KeyOutcome::Request(Request::Save),
|
|
"Ctrl-S must resolve to the save REQUEST"
|
|
);
|
|
assert_eq!(
|
|
Request::Save.frame(),
|
|
crate::ClientFrame::Save,
|
|
"and the one mapping must name the frame the host already answers"
|
|
);
|
|
assert_eq!(
|
|
composed(&state, key, "save", &[]),
|
|
None,
|
|
"`save` must compose NO command: a Commands batch is all-or-\
|
|
nothing, which is why Save is a frame (gotcha 45)"
|
|
);
|
|
}
|
|
|
|
/// A request is one frame, so a sequence has nowhere to put one. The arm
|
|
/// that refuses is unreachable, and this is what keeps it so.
|
|
#[test]
|
|
fn no_binding_puts_a_request_inside_a_sequence() {
|
|
fn steps_of(keymap: &Keymap, out: &mut Vec<String>) {
|
|
for binding in keymap.bindings().values() {
|
|
match binding {
|
|
Binding::Sequence(steps) => {
|
|
out.extend(steps.iter().map(|(name, _)| name.to_string()))
|
|
}
|
|
// Prefixes are flat (gotcha 13), so this terminates.
|
|
Binding::Prefix(sub) => steps_of(sub, out),
|
|
Binding::Cmd { .. } => {}
|
|
}
|
|
}
|
|
if let Some(parent) = keymap.parent() {
|
|
steps_of(parent, out);
|
|
}
|
|
}
|
|
|
|
let mut names = Vec::new();
|
|
for (_, keymap) in Keymaps::shared().iter() {
|
|
steps_of(keymap, &mut names);
|
|
}
|
|
assert!(
|
|
!names.is_empty(),
|
|
"the walk found no sequence steps at all, so it proves nothing"
|
|
);
|
|
for name in &names {
|
|
assert_eq!(
|
|
requested(name),
|
|
None,
|
|
"`{name}` is bound inside a sequence, and a request cannot \
|
|
travel in one: see clients.md"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-cfly. The two directions of the view history disagree in the
|
|
/// `Cmd`, and the client mirrors the disagreement — evidence file.
|
|
#[test]
|
|
fn the_view_history_keys_answer_in_both_directions() {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
// Empty first. `stepped` is a silent no-op on an empty stack where the
|
|
// `Cmd` speaks, so sending the bare command would lose the sentence.
|
|
let KeyOutcome::Local(cmds) = resolve(&state, ev(KeyCode::Char('<'))) else {
|
|
panic!("`<` must answer locally");
|
|
};
|
|
assert_eq!(
|
|
cmds,
|
|
vec![Command::SetStatus("No previous view".to_string())],
|
|
"an empty stack is answered out loud, in the Cmd's own words"
|
|
);
|
|
|
|
state.view.view_back_stack.push(crate::ViewFrame {
|
|
view_name: "Default".to_string(),
|
|
mode: AppMode::Normal,
|
|
});
|
|
let KeyOutcome::Local(cmds) = resolve(&state, ev(KeyCode::Char('<'))) else {
|
|
panic!("`<` must answer locally");
|
|
};
|
|
assert_eq!(
|
|
cmds,
|
|
vec![Command::ViewBack],
|
|
"with history, `<` steps back and says nothing — and emits NO \
|
|
drill commit, which is the decline this replaced"
|
|
);
|
|
}
|
|
|
|
/// The forward sibling, and the key that reaches it in both front ends
|
|
/// (improvise-cfly, improvise-htl6). See the evidence file.
|
|
#[test]
|
|
fn the_forward_step_mirrors_its_cmd_and_one_key_reaches_it() {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
assert_eq!(
|
|
composed(&state, ev(KeyCode::Char('<')), "view-forward", &[]),
|
|
Some(vec![Command::SetStatus("No forward view".to_string())]),
|
|
"an empty forward stack is answered out loud"
|
|
);
|
|
state.view.view_forward_stack.push(crate::ViewFrame {
|
|
view_name: "Second".to_string(),
|
|
mode: AppMode::Normal,
|
|
});
|
|
assert_eq!(
|
|
composed(&state, ev(KeyCode::Char('<')), "view-forward", &[]),
|
|
Some(vec![Command::ViewForward]),
|
|
"and a populated one steps forward"
|
|
);
|
|
|
|
let mut bound = Vec::new();
|
|
for (_, keymap) in Keymaps::shared().iter() {
|
|
bound_commands(keymap, &mut bound);
|
|
}
|
|
// improvise-htl6 inverted this: it asserted view-forward was bound by
|
|
// NOTHING, which is where the gap was meant to stop being one.
|
|
assert!(
|
|
bound.iter().any(|(name, _)| name == "view-forward"),
|
|
"view-forward has lost its key again and the arm above is \
|
|
unreachable: the TUI is back to :view-forward and the browser to \
|
|
nothing at all"
|
|
);
|
|
assert!(
|
|
bound.iter().any(|(name, _)| name == "view-back"),
|
|
"and the control: view-back is bound too, so the check above is \
|
|
not vacuous over a table that binds everything or nothing"
|
|
);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char(','))),
|
|
KeyOutcome::Local(vec![Command::ViewForward]),
|
|
"and the key is `,` — the unshifted twin of `<`, so both halves of \
|
|
the history sit on one physical key"
|
|
);
|
|
}
|
|
|
|
/// `i` on an aggregate IS drill in the TUI, so both doors name one missing
|
|
/// feature; only `i` says why. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key() {
|
|
let aggregate = CellId::Aggregate(
|
|
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
|
);
|
|
let state = client_on(aggregate);
|
|
let said = |key| match resolve(&state, ev(key)) {
|
|
KeyOutcome::Local(commands) => match &commands[..] {
|
|
[Command::SetStatus(said)] => said.clone(),
|
|
other => panic!("expected one status, got {other:?}"),
|
|
},
|
|
other => panic!("{key:?} is advertised; it must answer, got {other:?}"),
|
|
};
|
|
let (drill, edit) = (said(KeyCode::Char('>')), said(KeyCode::Char('i')));
|
|
assert!(
|
|
drill.contains("drill") && drill.contains("browser"),
|
|
"the status must name drill and say it is the browser that lacks it: {drill}"
|
|
);
|
|
assert!(
|
|
!drill.contains("aggregate"),
|
|
"`>` is refused whatever the cell is, so it must not blame the cell: {drill}"
|
|
);
|
|
assert!(
|
|
edit.contains(&drill),
|
|
"`i` reached the same missing feature and must name it the same way: \
|
|
{edit:?} does not contain {drill:?}"
|
|
);
|
|
assert!(
|
|
edit.contains("aggregate"),
|
|
"and `i` must still say why it drilled rather than edited: {edit}"
|
|
);
|
|
}
|
|
|
|
/// The refusal above is only half the property: the bug guarded is
|
|
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
|
let state = in_mode(AppMode::Normal);
|
|
for (key, mode, subject) in [
|
|
(
|
|
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
|
"save-prompt",
|
|
"saving",
|
|
),
|
|
(
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
|
"export-prompt",
|
|
"exporting",
|
|
),
|
|
(ev(KeyCode::Char(':')), "command-mode", "command line"),
|
|
] {
|
|
let KeyOutcome::Local(said) = resolve(&state, key) else {
|
|
panic!("{mode} must be refused *out loud*, not in silence");
|
|
};
|
|
let [Command::SetStatus(msg)] = &said[..] else {
|
|
panic!("{mode}: expected one status, got {said:?}");
|
|
};
|
|
assert!(
|
|
msg.contains(subject),
|
|
"{mode}: the refusal must name its own subject, got {msg}"
|
|
);
|
|
assert!(
|
|
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
|
"{mode} must not be entered by a client that cannot commit it"
|
|
);
|
|
}
|
|
|
|
// Negative arm, half one: a mode that is not refused is still entered.
|
|
// Why `?` and not `:` — `context/test-evidence/clients.md`.
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('?'))),
|
|
KeyOutcome::Local(vec![Command::ChangeMode(AppMode::Help)]),
|
|
"a mode this client can finish must still be entered, or the \
|
|
guard above is just \"refuse every mode\""
|
|
);
|
|
|
|
// Half two: the predicate is about *finishing*, so something has to
|
|
// press Enter and observe.
|
|
let mut client = client_on(record_cell());
|
|
client.view.mode = AppMode::editing();
|
|
client.view.buffers.insert("edit".into(), "42".into());
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
|
panic!("Enter in Editing must resolve here — that is the arm");
|
|
};
|
|
assert!(
|
|
commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
|
"and it must actually write something, not merely change mode: \
|
|
{commands:?}"
|
|
);
|
|
}
|
|
|
|
/// The evidence behind listing `CommandMode` in `strands_the_user`, and
|
|
/// the test improvise-e24v should break. See the evidence file.
|
|
#[test]
|
|
fn the_command_line_cannot_be_committed_by_this_client() {
|
|
let mut state = in_mode(AppMode::command_mode());
|
|
state.view.buffers.insert("command".into(), "q".into());
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Enter)),
|
|
KeyOutcome::Upstream(ev(KeyCode::Enter)),
|
|
"`:q` — and every other ex-command — resolves to nothing here"
|
|
);
|
|
}
|
|
|
|
/// The keys an independent browser smoke test found inert. Each asserts
|
|
/// the *shape* composed: "resolves locally" would pass on a wrong command.
|
|
#[test]
|
|
fn the_keys_the_smoke_test_found_inert_now_resolve() {
|
|
let state = in_mode(AppMode::Normal);
|
|
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('?'))),
|
|
KeyOutcome::Local(vec![Command::ChangeMode(AppMode::Help)]),
|
|
"? opens help"
|
|
);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('T'))),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetTileCatIdx(0),
|
|
Command::ChangeMode(AppMode::TileSelect),
|
|
]),
|
|
"T enters tile select"
|
|
);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('F'))),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Formula,
|
|
open: true,
|
|
},
|
|
Command::ChangeMode(AppMode::FormulaPanel),
|
|
]),
|
|
"F opens and focuses the formula panel"
|
|
);
|
|
assert_eq!(
|
|
resolve(
|
|
&state,
|
|
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)
|
|
),
|
|
KeyOutcome::Local(vec![Command::SetPanelOpen {
|
|
panel: Panel::Formula,
|
|
open: true,
|
|
}]),
|
|
"Ctrl-f shows the panel without focusing it"
|
|
);
|
|
}
|
|
|
|
/// `F` on an already-focused panel closes it: the interactive arm of
|
|
/// `TogglePanelAndFocus`, not its script arm.
|
|
#[test]
|
|
fn a_focused_panel_toggles_shut() {
|
|
let mut state = in_mode(AppMode::FormulaPanel);
|
|
state.view.formula_panel_open = true;
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('F'))),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetPanelOpen {
|
|
panel: Panel::Formula,
|
|
open: false,
|
|
},
|
|
Command::ChangeMode(AppMode::Normal),
|
|
])
|
|
);
|
|
}
|
|
|
|
/// `CyclePanelFocus`'s wrap arms, driven directly: `PanelKeymap` binds Tab
|
|
/// to `enter-mode normal` inside a panel, so no key reaches them.
|
|
#[test]
|
|
fn panel_focus_cycles_from_the_focused_panel_not_the_first() {
|
|
// Spelled out rather than inherited: this test is about skipping a
|
|
// *closed* panel, so the default's own answer must not decide it.
|
|
let open_two = |mode: AppMode| ViewState {
|
|
mode,
|
|
formula_panel_open: true,
|
|
category_panel_open: false,
|
|
view_panel_open: true,
|
|
..ViewState::default()
|
|
};
|
|
assert_eq!(
|
|
cycle_panel_focus(&open_two(AppMode::Normal)),
|
|
vec![Command::ChangeMode(AppMode::FormulaPanel)],
|
|
"from outside a panel, the first open one"
|
|
);
|
|
assert_eq!(
|
|
cycle_panel_focus(&open_two(AppMode::FormulaPanel)),
|
|
vec![Command::ChangeMode(AppMode::ViewPanel)],
|
|
"from a panel, the next OPEN one, skipping the closed category panel"
|
|
);
|
|
assert_eq!(
|
|
cycle_panel_focus(&open_two(AppMode::ViewPanel)),
|
|
vec![Command::ChangeMode(AppMode::FormulaPanel)],
|
|
"and from the last, it wraps"
|
|
);
|
|
let nothing_open = ViewState {
|
|
category_panel_open: false,
|
|
..ViewState::default()
|
|
};
|
|
assert!(
|
|
cycle_panel_focus(¬hing_open).is_empty(),
|
|
"with nothing open there is nowhere to go"
|
|
);
|
|
}
|
|
|
|
/// `t` is a document mutation, so it carries a basis — and until one is
|
|
/// published there is nothing truthful to claim (ADR 001).
|
|
#[test]
|
|
fn transpose_needs_a_published_basis() {
|
|
let state = in_mode(AppMode::Normal);
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('t'))),
|
|
KeyOutcome::Upstream(ev(KeyCode::Char('t'))),
|
|
"with no basis this client has nothing it may claim"
|
|
);
|
|
|
|
let client = client_on(record_cell());
|
|
assert_eq!(
|
|
resolve(&client, ev(KeyCode::Char('t'))),
|
|
KeyOutcome::Local(vec![Command::Model {
|
|
basis: client.basis.claim([]).unwrap(),
|
|
cmd: ModelCommand::TransposeAxes {
|
|
view: client.view.active_view.clone(),
|
|
},
|
|
}])
|
|
);
|
|
}
|
|
|
|
/// A mode this client was never taught answers nothing itself: the
|
|
/// wizard's whole keymap is one `Any` binding into `HandleWizardKey`.
|
|
#[test]
|
|
fn an_untaught_mode_resolves_nothing() {
|
|
for code in [KeyCode::Char('j'), KeyCode::Down, KeyCode::Enter] {
|
|
assert_eq!(
|
|
resolve(&in_mode(AppMode::ImportWizard), ev(code)),
|
|
KeyOutcome::Upstream(ev(code)),
|
|
"the wizard is the host's"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A key nothing binds is `Ignored`, not `Upstream`: reading the same
|
|
/// table makes "nothing is bound" a fact rather than a deferred guess.
|
|
#[test]
|
|
fn an_unbound_key_is_ignored_rather_than_sent_into_the_void() {
|
|
let state = in_mode(AppMode::Normal);
|
|
assert_eq!(resolve(&state, ev(KeyCode::F(9))), KeyOutcome::Ignored);
|
|
assert_eq!(
|
|
resolve(&state, KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)),
|
|
KeyOutcome::Ignored
|
|
);
|
|
}
|
|
|
|
/// An empty widget is not an unanswerable key: the client knows perfectly
|
|
/// well that there is nowhere to go.
|
|
#[test]
|
|
fn an_empty_widget_answers_ignored_rather_than_forwarding() {
|
|
for (mode, code) in [
|
|
(AppMode::Normal, KeyCode::Char('j')),
|
|
(AppMode::TileSelect, KeyCode::Char('l')),
|
|
(AppMode::FormulaPanel, KeyCode::Char('j')),
|
|
] {
|
|
assert_eq!(
|
|
resolve(&in_mode(mode.clone()), ev(code)),
|
|
KeyOutcome::Ignored,
|
|
"{} / {code:?}",
|
|
crate::help::mode_label(&mode)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Which widgets say nothing about a move that goes nowhere is **not a
|
|
/// rule of this module** — it is the `Cmd`'s, and the three differ.
|
|
#[test]
|
|
fn a_move_that_goes_nowhere_follows_the_command_it_mirrors() {
|
|
let mut grid = in_mode(AppMode::Normal);
|
|
grid.grid.extent = GridExtent { rows: 2, cols: 2 };
|
|
grid.window = (10, 10);
|
|
assert_eq!(
|
|
resolve(&grid, ev(KeyCode::Char('k'))),
|
|
KeyOutcome::Local(vec![Command::SetSelected { row: 0, col: 0 }]),
|
|
"Move emits even when it clamps, so this must too"
|
|
);
|
|
assert_eq!(
|
|
resolve(&grid, ev(KeyCode::Char('j'))),
|
|
KeyOutcome::Local(vec![Command::SetSelected { row: 1, col: 0 }]),
|
|
"and the fixture is not simply inert"
|
|
);
|
|
|
|
let mut panel = in_mode(AppMode::FormulaPanel);
|
|
panel.formulas.formulas = vec!["Profit = Revenue - Cost".into()];
|
|
assert_eq!(
|
|
resolve(&panel, ev(KeyCode::Char('k'))),
|
|
KeyOutcome::Ignored,
|
|
"MovePanelCursor returns no effects when the cursor does not move"
|
|
);
|
|
|
|
let mut tiles = in_mode(AppMode::TileSelect);
|
|
tiles.tiles.tiles = vec![crate::TileEntry {
|
|
category: "Region".into(),
|
|
axis: improvise_core::view::Axis::Row,
|
|
}];
|
|
assert_eq!(
|
|
resolve(&tiles, ev(KeyCode::Char('h'))),
|
|
KeyOutcome::Local(vec![Command::SetTileCatIdx(0)]),
|
|
"MoveTileCursor emits regardless, like Move"
|
|
);
|
|
}
|
|
|
|
// ── the editing path ────────────────────────────────────────────
|
|
|
|
fn surrogate() -> crate::RecordHash {
|
|
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("a surrogate is hex")
|
|
}
|
|
|
|
fn record_cell() -> CellId {
|
|
CellId::Record(RecordCell {
|
|
record: surrogate(),
|
|
measure: Some("Revenue".into()),
|
|
})
|
|
}
|
|
|
|
/// A one-by-two grid whose first cell carries `id`, with a published basis
|
|
/// naming its record: the shape a client is in before the user types.
|
|
fn client_on(id: CellId) -> ClientState {
|
|
let mut first = CellDisplay::number("1200");
|
|
first.id = Some(id);
|
|
let mut client = ClientState::default();
|
|
client.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
|
Projection::Grid(GridUpdate::Cells(vec![
|
|
((0, 0), first),
|
|
((1, 0), CellDisplay::number("900")),
|
|
])),
|
|
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 2, cols: 1 })),
|
|
Projection::Basis(published()),
|
|
],
|
|
});
|
|
client.window = (2, 1);
|
|
client
|
|
}
|
|
|
|
fn published() -> crate::Basis {
|
|
crate::basis::published(7, 3, [(surrogate(), 4)].into_iter().collect())
|
|
}
|
|
|
|
/// `i` on a `Record` cell seeds the edit buffer and enters `Editing`:
|
|
/// `EditOrDrill`'s non-aggregated branch, in the order it emits.
|
|
#[test]
|
|
fn i_on_a_record_cell_enters_editing_seeded_from_the_cell() {
|
|
let client = client_on(record_cell());
|
|
assert_eq!(
|
|
resolve(&client, ev(KeyCode::Char('i'))),
|
|
KeyOutcome::Local(vec![
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "1200".into(),
|
|
},
|
|
Command::ChangeMode(AppMode::editing()),
|
|
])
|
|
);
|
|
}
|
|
|
|
/// `a` is bound to the same command with the same argument, and behaves
|
|
/// identically without a second arm here: the point of sharing the table.
|
|
#[test]
|
|
fn a_and_i_are_one_binding_with_two_keys() {
|
|
let client = client_on(record_cell());
|
|
assert_eq!(
|
|
resolve(&client, ev(KeyCode::Char('a'))),
|
|
resolve(&client, ev(KeyCode::Char('i')))
|
|
);
|
|
}
|
|
|
|
/// `i` on an aggregate says so; upstream would be a keypress the user
|
|
/// sees do nothing (`doctrine(an_upstream_key_is_a_black_hole)`).
|
|
#[test]
|
|
fn i_on_an_aggregate_declines_out_loud() {
|
|
let client = client_on(CellId::Aggregate(
|
|
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
|
|
));
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else {
|
|
panic!("an aggregate must not be a silent no-op");
|
|
};
|
|
assert!(
|
|
matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("drill")),
|
|
"expected a status naming drill, got {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// `i` on a formula-owned cell names the formula, so the user learns where
|
|
/// the value comes from rather than typing into a shadowed cell (ir7l).
|
|
#[test]
|
|
fn i_on_a_derived_cell_names_the_formula() {
|
|
let client = client_on(CellId::Derived {
|
|
target: "Profit".into(),
|
|
});
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else {
|
|
panic!("a derived cell must not be a silent no-op");
|
|
};
|
|
assert!(
|
|
matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("Profit")
|
|
&& msg.contains("formula")),
|
|
"expected a status naming the formula target, got {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// Two page axes, so the odometer's carry is visible: `Quarter` is least
|
|
/// significant and `Year` only moves when it wraps.
|
|
fn paged() -> ClientState {
|
|
let mut state = in_mode(AppMode::Normal);
|
|
crate::WidgetCache::apply(&mut state.basis, &published());
|
|
state.grid.page_coords = vec![
|
|
crate::PageAxis {
|
|
cat: "Year".into(),
|
|
selected: "2024".into(),
|
|
items: Some(vec!["2024".into(), "2025".into()]),
|
|
},
|
|
crate::PageAxis {
|
|
cat: "Quarter".into(),
|
|
selected: "Q4".into(),
|
|
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
|
|
},
|
|
];
|
|
state
|
|
}
|
|
|
|
fn selections(outcome: KeyOutcome) -> Vec<(String, String)> {
|
|
let KeyOutcome::Local(commands) = outcome else {
|
|
panic!("paging must resolve here, got {outcome:?}");
|
|
};
|
|
commands
|
|
.into_iter()
|
|
.map(|c| match c {
|
|
Command::Model {
|
|
cmd: ModelCommand::SetPageSelection { category, item, .. },
|
|
..
|
|
} => (category, item),
|
|
other => panic!("expected a page selection, got {other:?}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// improvise-p1ym: `]` had no item list to cycle, so it reached the host
|
|
/// and did nothing. `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn the_page_keys_turn_the_odometer_as_the_command_does() {
|
|
let state = paged();
|
|
assert_eq!(
|
|
selections(resolve(&state, ev(KeyCode::Char(']')))),
|
|
vec![
|
|
("Year".to_string(), "2025".to_string()),
|
|
("Quarter".to_string(), "Q1".to_string()),
|
|
],
|
|
"Q4 wraps to Q1 and carries into Year"
|
|
);
|
|
assert_eq!(
|
|
selections(resolve(&state, ev(KeyCode::Char('[')))),
|
|
vec![
|
|
("Year".to_string(), "2024".to_string()),
|
|
("Quarter".to_string(), "Q3".to_string()),
|
|
],
|
|
"no borrow, so Year stands still — and is still restated"
|
|
);
|
|
}
|
|
|
|
/// A Filter axis constrains without being navigable (gotcha 27), so it
|
|
/// carries no items and the odometer cannot reach it.
|
|
#[test]
|
|
fn a_filter_axis_is_projected_but_not_cycled() {
|
|
let mut state = paged();
|
|
state.grid.page_coords.push(crate::PageAxis {
|
|
cat: "Region".into(),
|
|
selected: "East".into(),
|
|
items: None,
|
|
});
|
|
let moved = selections(resolve(&state, ev(KeyCode::Char(']'))));
|
|
assert!(
|
|
!moved.iter().any(|(cat, _)| cat == "Region"),
|
|
"a Filter axis must not be paged, got {moved:?}"
|
|
);
|
|
assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}");
|
|
}
|
|
|
|
/// improvise-rw6l: picking straight off the bar, which is what a menu
|
|
/// does. It names ONE axis, so the odometer's carry must not appear.
|
|
#[test]
|
|
fn picking_a_page_item_moves_that_axis_and_only_that_axis() {
|
|
let state = paged();
|
|
let commands = click(&state, Click::PageItem { axis: 1, item: 0 });
|
|
assert_eq!(
|
|
commands,
|
|
vec![Command::Model {
|
|
// The claim `]` makes for the same document: this command
|
|
// names no record, so the record tier is omitted (gotcha 43).
|
|
basis: state.basis.claim([]).expect("the basis is published"),
|
|
cmd: ModelCommand::SetPageSelection {
|
|
view: state.view.active_view.clone(),
|
|
category: "Quarter".into(),
|
|
item: "Q1".into(),
|
|
},
|
|
}],
|
|
"Q4 to Q1 directly: Year is untouched, where `]` would have carried into it"
|
|
);
|
|
}
|
|
|
|
/// A Filter axis is on the bar and offers nothing to pick (gotcha 27).
|
|
/// Offering a menu that cannot act is worse than offering none.
|
|
#[test]
|
|
fn a_filter_axis_offers_no_item_to_pick() {
|
|
let mut state = paged();
|
|
state.grid.page_coords.push(crate::PageAxis {
|
|
cat: "Region".into(),
|
|
selected: "East".into(),
|
|
items: None,
|
|
});
|
|
assert!(
|
|
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
|
|
"a Filter axis has no item list, so there is nothing a click could mean"
|
|
);
|
|
assert!(
|
|
click(&state, Click::PageItem { axis: 9, item: 0 }).is_empty(),
|
|
"and an axis that is not on the bar is not one either"
|
|
);
|
|
assert!(
|
|
click(&state, Click::PageItem { axis: 0, item: 9 }).is_empty(),
|
|
"nor an item past the end of the list the bar was painted from"
|
|
);
|
|
}
|
|
|
|
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
|
|
/// and every part shows up here — including the odd-looking trailing clear.
|
|
#[test]
|
|
fn enter_commits_the_cell_advances_and_re_enters_editing() {
|
|
let mut client = client_on(record_cell());
|
|
client.view.mode = AppMode::editing();
|
|
client.view.buffers.insert("edit".into(), "42".into());
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
|
panic!("Enter in Editing must resolve here");
|
|
};
|
|
// The write, composed through the same function click-to-edit uses.
|
|
assert_eq!(
|
|
commands[0],
|
|
Command::Model {
|
|
basis: published(),
|
|
cmd: ModelCommand::SetCell {
|
|
cell: RecordCell {
|
|
record: surrogate(),
|
|
measure: Some("Revenue".into()),
|
|
},
|
|
value: CellValue::Number(42.0),
|
|
},
|
|
}
|
|
);
|
|
assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal));
|
|
assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 });
|
|
assert!(
|
|
commands.contains(&Command::ChangeMode(AppMode::editing())),
|
|
"the advance re-enters editing on the cell it landed on: {commands:?}"
|
|
);
|
|
assert_eq!(
|
|
commands.last(),
|
|
Some(&Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: String::new(),
|
|
}),
|
|
"the keymap welds `clear-buffer` to every commit: {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// An empty buffer is `ClearCell`, not `SetCell` of an empty string: the
|
|
/// decision `typed()` makes, reached by keyboard and by mouse alike.
|
|
#[test]
|
|
fn committing_an_empty_buffer_clears_the_cell() {
|
|
let mut client = client_on(record_cell());
|
|
client.view.mode = AppMode::editing();
|
|
client.view.buffers.insert("edit".into(), String::new());
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
|
panic!("Enter in Editing must resolve here");
|
|
};
|
|
assert!(matches!(
|
|
&commands[0],
|
|
Command::Model {
|
|
cmd: ModelCommand::ClearCell(_),
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
/// improvise-ktlk: what an editor shows and what Enter commits are one
|
|
/// fact. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_editor_entered_without_its_buffer_commits_and_extends_the_cell() {
|
|
let mut client = client_on(record_cell());
|
|
let mut cell = CellDisplay::number("1,200.50");
|
|
cell.edit_text = Some("1200.5".into());
|
|
cell.id = Some(record_cell());
|
|
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
|
|
vec![((0, 0), cell)],
|
|
))));
|
|
client.fold(&Command::ChangeMode(AppMode::editing()));
|
|
assert_eq!(
|
|
read_buffer(&client.view, "edit"),
|
|
"1200.5",
|
|
"the buffer is what the overlay paints and what a commit reads"
|
|
);
|
|
|
|
let committed = |client: &ClientState| {
|
|
let KeyOutcome::Local(commands) = resolve(client, ev(KeyCode::Enter)) else {
|
|
panic!("Enter in Editing must resolve here");
|
|
};
|
|
match &commands[0] {
|
|
Command::Model {
|
|
cmd: ModelCommand::SetCell { value, .. },
|
|
..
|
|
} => Some(value.clone()),
|
|
_ => None,
|
|
}
|
|
};
|
|
assert_eq!(
|
|
committed(&client),
|
|
Some(CellValue::Number(1200.5)),
|
|
"Enter on an editor showing the cell must not clear it"
|
|
);
|
|
|
|
let KeyOutcome::Local(typed) = resolve(&client, ev(KeyCode::Char('5'))) else {
|
|
panic!("a character in Editing is append-char");
|
|
};
|
|
for command in &typed {
|
|
client.fold(command);
|
|
}
|
|
assert_eq!(
|
|
committed(&client),
|
|
Some(CellValue::Number(1200.55)),
|
|
"a typed character extends what the editor showed, not replaces it"
|
|
);
|
|
}
|
|
|
|
/// At the bottom-right corner `EnterAdvance` returns `AbortChain`, so the
|
|
/// commit leaves the user in normal mode. See the evidence file.
|
|
#[test]
|
|
fn a_commit_in_the_last_cell_exits_editing_instead_of_advancing() {
|
|
// A one-by-one grid, so the only cell *is* the corner.
|
|
let mut cell = CellDisplay::number("1200");
|
|
cell.id = Some(record_cell());
|
|
let mut client = ClientState::default();
|
|
client.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
|
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
|
|
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 1, cols: 1 })),
|
|
Projection::Basis(published()),
|
|
],
|
|
});
|
|
client.window = (1, 1);
|
|
client.view.mode = AppMode::editing();
|
|
client.view.buffers.insert("edit".into(), "7".into());
|
|
|
|
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
|
|
panic!("Enter in Editing must resolve here");
|
|
};
|
|
assert!(
|
|
matches!(&commands[0], Command::Model { .. }),
|
|
"the write still happens: {commands:?}"
|
|
);
|
|
assert!(
|
|
!commands.contains(&Command::ChangeMode(AppMode::editing())),
|
|
"the corner must not re-enter editing: {commands:?}"
|
|
);
|
|
assert!(
|
|
commands.contains(&Command::ChangeMode(AppMode::Normal)),
|
|
"it drops to normal instead: {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// `settled_mode` mirrors `settle_normal_mode`, which settles BOTH members
|
|
/// of the pair. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn the_normal_pair_is_settled_in_both_directions() {
|
|
for (records, held, expected) in [
|
|
(true, AppMode::Normal, AppMode::RecordsNormal),
|
|
(true, AppMode::RecordsNormal, AppMode::RecordsNormal),
|
|
(false, AppMode::RecordsNormal, AppMode::Normal),
|
|
(false, AppMode::Normal, AppMode::Normal),
|
|
] {
|
|
assert_eq!(
|
|
crate::settled_mode(&held, records),
|
|
expected,
|
|
"records={records}, holding {held:?}"
|
|
);
|
|
}
|
|
// Everything else passes through, or this is a records-mode filter and
|
|
// not a settling — `Editing` is the one that must NOT move.
|
|
for other in [
|
|
AppMode::Help,
|
|
AppMode::editing(),
|
|
AppMode::records_editing(),
|
|
] {
|
|
assert_eq!(crate::settled_mode(&other, true), other);
|
|
assert_eq!(crate::settled_mode(&other, false), other);
|
|
}
|
|
}
|
|
|
|
/// Records mode is the fork this client does not have (gotcha 1), so its
|
|
/// commit goes upstream rather than writing the wrong cell.
|
|
#[test]
|
|
fn a_records_mode_commit_is_the_hosts() {
|
|
let mut client = client_on(record_cell());
|
|
crate::WidgetCache::apply(&mut client.grid, &GridUpdate::Records(true));
|
|
client.view.mode = AppMode::records_editing();
|
|
assert_eq!(
|
|
resolve(&client, ev(KeyCode::Enter)),
|
|
KeyOutcome::Upstream(ev(KeyCode::Enter))
|
|
);
|
|
}
|
|
|
|
/// The state a client is in after the prefix key: the arming folded, so
|
|
/// the second key is resolved against what the browser really holds.
|
|
fn arm(state: &mut ClientState, prefix: KeyEvent) {
|
|
let KeyOutcome::Local(commands) = resolve(state, prefix) else {
|
|
panic!("{prefix:?} is a prefix; arming it must resolve locally");
|
|
};
|
|
for command in &commands {
|
|
state.fold(command);
|
|
}
|
|
assert_eq!(
|
|
state.view.transient_prefix,
|
|
Some((prefix.code, prefix.modifiers)),
|
|
"arming must leave the prefix where `resolve` looks for it"
|
|
);
|
|
}
|
|
|
|
/// improvise-hs0u: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn gg_reaches_the_first_row_the_way_shift_g_reaches_the_last() {
|
|
let mut state = client_on(record_cell());
|
|
state.view.cursor_mut().selected = (1, 0);
|
|
state.view.cursor_mut().row_offset = 1;
|
|
|
|
arm(&mut state, ev(KeyCode::Char('g')));
|
|
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('g'))) else {
|
|
panic!("`gg` is bound to jump-first-row and the arm is implemented");
|
|
};
|
|
assert_eq!(
|
|
commands.first(),
|
|
Some(&Command::SetTransientPrefix(None)),
|
|
"the prefix is consumed FIRST, whatever the second key did"
|
|
);
|
|
assert!(
|
|
commands.contains(&Command::SetSelected { row: 0, col: 0 }),
|
|
"`gg` must move to the first row: {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// improvise-lut7: evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn yy_yanks_the_cell_under_the_cursor_and_p_pastes_what_it_yanked() {
|
|
let mut state = client_on(record_cell());
|
|
state.grid.cells[0][0].value = Some(CellValue::Number(1200.0));
|
|
arm(&mut state, ev(KeyCode::Char('y')));
|
|
let KeyOutcome::Local(yank) = resolve(&state, ev(KeyCode::Char('y'))) else {
|
|
panic!("`yy` is bound to yank");
|
|
};
|
|
assert!(
|
|
yank.contains(&Command::SetYanked(Some(CellValue::Number(1200.0)))),
|
|
"the cell under the cursor reads 1200: {yank:?}"
|
|
);
|
|
|
|
for command in &yank {
|
|
state.fold(command);
|
|
}
|
|
assert_eq!(
|
|
state.view.transient_prefix, None,
|
|
"and the y prefix is spent"
|
|
);
|
|
|
|
let KeyOutcome::Local(paste) = resolve(&state, ev(KeyCode::Char('p'))) else {
|
|
panic!(
|
|
"`p` composes a SetCell once something has been yanked; \
|
|
before improvise-lut7 nothing could ever yank"
|
|
);
|
|
};
|
|
assert!(
|
|
paste.iter().any(|c| matches!(
|
|
c,
|
|
Command::Model {
|
|
cmd: ModelCommand::SetCell {
|
|
value: CellValue::Number(n),
|
|
..
|
|
},
|
|
..
|
|
} if *n == 1200.0
|
|
)),
|
|
"`p` must write what `yy` took: {paste:?}"
|
|
);
|
|
}
|
|
|
|
/// A prefix is consumed whether or not the second key matched, mirroring
|
|
/// `App::dispatch_key`. Evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_second_key_that_matches_nothing_still_spends_the_prefix() {
|
|
let mut state = client_on(record_cell());
|
|
arm(&mut state, ev(KeyCode::Char('g')));
|
|
assert_eq!(
|
|
resolve(&state, ev(KeyCode::Char('q'))),
|
|
KeyOutcome::Local(vec![Command::SetTransientPrefix(None)]),
|
|
"`gq` binds nothing, and leaving the prefix armed strands every \
|
|
key after it in the g map"
|
|
);
|
|
}
|
|
|
|
/// A name behind a prefix that this client cannot compose must still
|
|
/// spend the prefix, and say so. Evidence in `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn zz_declines_out_loud_rather_than_stranding_the_prefix() {
|
|
let mut state = client_on(record_cell());
|
|
arm(&mut state, ev(KeyCode::Char('Z')));
|
|
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('Z'))) else {
|
|
panic!("an Upstream here leaves `transient_prefix` armed forever");
|
|
};
|
|
assert_eq!(commands.first(), Some(&Command::SetTransientPrefix(None)));
|
|
assert!(
|
|
matches!(commands.last(), Some(Command::SetStatus(said)) if said.contains("not available")),
|
|
"silence is worse (improvise-rmvx): {commands:?}"
|
|
);
|
|
}
|
|
|
|
/// The structural half: a name reachable only behind a prefix was
|
|
/// unreachable full stop, so nothing checked it. Evidence file.
|
|
#[test]
|
|
fn every_name_bound_behind_a_prefix_is_answered_by_this_client() {
|
|
let mut names = Vec::new();
|
|
for (_, keymap) in Keymaps::shared().iter() {
|
|
let mut map = Some(keymap.clone());
|
|
while let Some(current) = map {
|
|
for binding in current.bindings().values() {
|
|
// Prefixes are flat (gotcha 13), so the inner map has none.
|
|
if let Binding::Prefix(sub) = binding {
|
|
for inner in sub.bindings().values() {
|
|
match inner {
|
|
Binding::Cmd { name, args } => {
|
|
names.push((name.to_string(), args.clone()))
|
|
}
|
|
Binding::Sequence(steps) => names
|
|
.extend(steps.iter().map(|(n, a)| (n.to_string(), a.clone()))),
|
|
Binding::Prefix(_) => {
|
|
panic!("prefix_keymaps_are_flat is broken")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
map = current.parent().cloned();
|
|
}
|
|
}
|
|
names.sort();
|
|
names.dedup();
|
|
assert!(
|
|
!names.is_empty(),
|
|
"the walk found no prefix-bound names at all, so it proves nothing"
|
|
);
|
|
let state = client_on(record_cell());
|
|
for (name, args) in &names {
|
|
assert_eq!(
|
|
requested(name),
|
|
None,
|
|
"`{name}` is bound behind a prefix and is a REQUEST, which a \
|
|
chord discards: see the sibling test below"
|
|
);
|
|
let answered = declined(name, args).is_some()
|
|
|| composed(&state, ev(KeyCode::Char('x')), name, args).is_some();
|
|
assert!(
|
|
answered,
|
|
"`{name}` is reachable only behind a prefix, and this client \
|
|
answers it with nothing — which strands the prefix armed"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Why the guard above refuses a request rather than counting it answered
|
|
/// (ChatGPT Codex, PR #161). See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_request_bound_behind_a_prefix_would_be_silently_discarded() {
|
|
let state = client_on(record_cell());
|
|
assert!(
|
|
requested("save").is_some(),
|
|
"the request class must be non-empty, or the guard's refusal \
|
|
clause can never fire and asserts nothing"
|
|
);
|
|
assert_eq!(
|
|
chord(
|
|
&state,
|
|
ev(KeyCode::Char('s')),
|
|
Some(&Binding::Cmd {
|
|
name: "save",
|
|
args: Vec::new(),
|
|
}),
|
|
),
|
|
KeyOutcome::Local(vec![Command::SetTransientPrefix(None)]),
|
|
"the save request is GONE: a chord must carry its disarm, and \
|
|
`KeyOutcome::Request` has nowhere to put one"
|
|
);
|
|
}
|
|
}
|