//! 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 serde::{Deserialize, Serialize}; 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, FormulaId, FormulaSlot, 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), /// 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) -> 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 { // `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> { 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) } /// Focus the panel, move its cursor, then open the editor on the row — what /// `F` then `Enter` do. `edit-formula-at-cursor` is the activation (km71). fn click_formula_row(state: &ClientState, index: usize) -> Option> { state.formulas.formulas.get(index)?; let mut commands = focus(state, Panel::Formula); commands.push(Command::SetPanelCursor { panel: Panel::Formula, cursor: index, }); // Off `index`, never the cursor: `SetPanelCursor` is not folded yet. commands.extend(edit_formula_at(state, index)); Some(commands) } /// `EditFormulaAtCursor::execute` at an arbitrary index: open the editor on /// that formula, naming it, and seed the buffer with its text. fn edit_formula_at(state: &ClientState, index: usize) -> Vec { match state.formulas.formulas.get(index) { Some(entry) => vec![ Command::ChangeMode(AppMode::formula_replace( entry.id.clone(), entry.raw.clone(), )), write_buffer("formula", entry.raw.clone()), ], None => vec![], } } /// 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> { // 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> { 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> { 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 { 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)], ) -> Option> { 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), Declined(Vec), 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 { 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 { 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 command names this client has no arm for, so their keys go upstream to /// a host that answers nothing (gotcha 47). The one list: see clients.md. pub const NEEDS_THE_HOST: &[&str] = &[ "enter-advance", "exit-search-mode", "force-quit", "hide-selected-row-item", "search", "search-navigate", "toggle-group-under-cursor", ]; /// Whether a key running `name` does nothing in this client. pub fn needs_the_host(name: &str) -> bool { NEEDS_THE_HOST.contains(&name) } /// Whether `:` opens a command line this client can run; it cannot, so an /// ex-command it advertises is unreachable (improvise-uln5). pub fn runs_the_command_line() -> bool { AppMode::from_name("command").is_some_and(|mode| stranding(&mode).is_none()) } /// A help description, marked as naming something this client cannot do. pub fn not_here(description: &str) -> String { format!("{description} (not 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> { 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(typed_into(state, args.first()?, value)) } "pop-char" => { let mut value = read_buffer(&state.view, args.first()?); value.pop(); Some(typed_into(state, 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(crate::opening_tile(&state.tiles.tiles)), 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(), )], }), // ── the formula panel ──────────────────────────────────────── "edit-formula-at-cursor" => Some(edit_formula_at(state, state.view.formula_cursor)), "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 `` argument names — `parse_panel`'s spellings, which /// `Panel::name` is required to match (gotcha 12). fn named_panel(name: &str) -> Option { 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 { 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(), } } /// A user's edit of buffer `name`: the write, then the open editor marked /// touched. `AppendChar`/`PopChar`, line for line (improvise-2054). fn typed_into(state: &ClientState, name: &str, value: String) -> Vec { let mut commands = vec![write_buffer(name, value)]; commands.extend( edited(state.view.opened_editor.as_ref(), name) .map(|opened| Command::SetOpenedEditor(Some(opened))), ); commands } 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 { let mut commands = Vec::new(); if let Some(mb) = mode.minibuffer() { commands.push(write_buffer(&mb.buffer_key, String::new())); } // An editor opened this way captured no seed, so no earlier one may stand. if mode.is_editing() { commands.push(Command::SetOpenedEditor(None)); } // 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. /// The one home: the TUI's `Move` calls it too (improvise-5xbq). pub fn page_rows(window_rows: usize) -> i32 { (window_rows as i32 * 3 / 4).max(1) } fn grid_move(state: &ClientState, kind: GridMove) -> Option> { 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 { 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> { 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> { 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> { // 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(opened_on( state, cell.edit_text.clone().unwrap_or_default(), 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(derived_decline(target))]), // 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"))]), } } /// Open the cell editor on `seed`, or await it while any write is unanswered: the /// cache predates that write (improvise-mdfc/p6v6, `protocol.md` §3). fn opened_on(state: &ClientState, seed: String, mode: AppMode) -> Vec { match state.in_flight.iter().any(|sent| sent.awaited) { true => awaiting_seed(mode), false => seeded(seed, mode), } } /// The buffer, what the editor captured, then the mode: `EditOrDrill`'s and /// `EnterEditAtCursor`'s shared shape (improvise-ixxx). fn seeded(seed: String, mode: AppMode) -> Vec { vec![ Command::SetBuffer { name: "edit".to_string(), value: seed.clone(), }, Command::SetOpenedEditor(Some(OpenedEditor::on(seed))), Command::ChangeMode(mode), ] } /// 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 { 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> { 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 = 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> { 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> { 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 = written(state, id, &text)?; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(cleared(&edit_mode)); let writes = !untouched(state.view.opened_editor.as_ref(), &text); commands.extend(advanced(state, edit_mode, advance, writes)?); Some(commands) } /// The commit's own clear of `edit_mode`'s buffer, at its head: the keymap /// welds none to these keys, so a re-seed survives (improvise-fy9o). fn cleared(edit_mode: &AppMode) -> Option { edit_mode .minibuffer() .map(|mb| write_buffer(&mb.buffer_key, String::new())) } /// The write a commit makes: none when the buffer is the seed the editor /// captured as it opened, and only the write is skipped (improvise-ixxx). fn written(state: &ClientState, id: &CellId, text: &str) -> Option> { match untouched(state.view.opened_editor.as_ref(), text) { true => Some(Vec::new()), false => Some(vec![Command::Model { basis: state.basis.claim([record_at_cursor(state)?])?, cmd: typed_cell_command(id, text)?, }]), } } /// `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> { // `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)]; commands.extend(cleared(&edit_mode)); 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, !name.is_empty())?); 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, writes: bool, ) -> Option> { 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); // This frame's own write makes the cache stale too; `opened_on` answers for // earlier ones (improvise-3uzg/mdfc/p6v6, `protocol.md` §3). commands.extend(reopened_at(state, nr, nc, edit_mode, writes)); Some(commands) } /// Open the cell editor with its seed still to come: the client settles it from /// the host's answer, once nothing this client sent is unanswered (improvise-3uzg). fn awaiting_seed(mode: AppMode) -> Vec { vec![ Command::SetBuffer { name: EDIT_BUFFER.to_string(), value: String::new(), }, Command::SetOpenedEditor(Some(OpenedEditor::awaiting())), Command::ChangeMode(mode), ] } /// 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 { 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 { 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 { cell_at(state, row, col) .and_then(|cell| cell.edit_text.clone()) .unwrap_or_default() } /// The projected cell at an absolute grid position, `None` outside the window. fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> { let cursor = state.view.cursor(); let r = row.checked_sub(cursor.row_offset)?; let c = col.checked_sub(cursor.col_offset)?; state.grid.cell(r, c) } /// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` /// would decline that cell. The host asks `uneditable`; this asks the same id. fn reopened_at( state: &ClientState, row: usize, col: usize, mode: AppMode, writes: bool, ) -> Vec { match cell_at(state, row, col).and_then(|cell| cell.id.as_ref()) { Some(CellId::Aggregate(_)) => vec![Command::SetStatus(AGGREGATE_DECLINE.to_string())], Some(CellId::Derived { target }) => vec![Command::SetStatus(derived_decline(target))], Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => match writes { true => awaiting_seed(mode), false => opened_on(state, seed_at(state, row, col), mode), }, } } /// What the host says of an aggregate, the fold of a collapsed group included. const AGGREGATE_DECLINE: &str = "Cannot write to an aggregate — it combines several records, so there is no one cell to write"; /// What both ends say of a formula's result: the host's `derived_refusal`. fn derived_decline(target: &str) -> String { format!("{target} is computed by a formula — edit the formula, not the cell") } /// What a cell editor captured as it **opened**, so its commit is judged /// against that and never a seed read again at commit (improvise-ixxx). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, /// The seed is still to come from the host's answer (improvise-3uzg). Not /// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis. pub awaiting: bool, } impl OpenedEditor { /// An editor that has just opened on `seed`, untouched. pub fn on(seed: String) -> Self { Self { seed, touched: false, awaiting: false, } } /// An editor opened before its seed is known: empty until it settles. pub fn awaiting() -> Self { Self { seed: String::new(), touched: false, awaiting: true, } } } /// The buffer every cell editor types into. const EDIT_BUFFER: &str = "edit"; /// Whether committing `text` is the editor's own seed back, untouched: such a /// commit writes nothing. The **one** home, beside [`typed`] (improvise-ixxx). pub fn untouched(opened: Option<&OpenedEditor>, text: &str) -> bool { opened.is_some_and(|opened| !opened.touched && opened.seed == text) } /// The open editor marked touched by a user edit of `buffer`, or `None` when /// that edit changes nothing to record (improvise-2054, `protocol.md` §3). pub fn edited(opened: Option<&OpenedEditor>, buffer: &str) -> Option { opened .filter(|opened| buffer == EDIT_BUFFER && !opened.touched) .map(|opened| OpenedEditor { touched: true, ..opened.clone() }) } /// 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 { match text.is_empty() { true => None, false => Some(match text.parse::() { 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 { 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 { 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 { 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 { 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 { 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 { flatten_cat_tree(&state.categories, &state.view.expanded_cats) .into_iter() .nth(index) } fn cat_at_cursor(state: &ClientState) -> Option { 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 { 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 { toggle_cat_expand_at(state, state.view.cat_panel_cursor) } fn toggle_cat_expand_at(state: &ClientState, index: usize) -> Vec { 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> { filter_to_item_at(state, state.view.cat_panel_cursor) } fn filter_to_item_at(state: &ClientState, index: usize) -> Option> { 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> { 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 { 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 { 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 { 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> { 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> { 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) -> Option> { tile_axis_at(state, state.view.tile_cat_idx, axis) } fn tile_axis_at(state: &ClientState, index: usize, axis: Option) -> Option> { 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 { 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 as /// `parse_commit_kind` then `CommitKind::in_mode` do; there is no registry here. enum CommitKind { Formula, ReplaceFormula { id: FormulaId, was: String }, Category, Item { category: String }, } impl CommitKind { fn of(arg: &str, mode: &AppMode) -> Option { match (arg, mode) { ( "formula", AppMode::FormulaEdit { slot: FormulaSlot::Existing { id, was }, .. }, ) => Some(Self::ReplaceFormula { id: id.clone(), was: was.clone(), }), ("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 | Self::ReplaceFormula { .. } => "formula", Self::Category => "category", Self::Item { .. } => "item", } } fn on_empty(&self) -> AppMode { match self { Self::Formula | Self::ReplaceFormula { .. } => AppMode::FormulaPanel, Self::Category | Self::Item { .. } => AppMode::CategoryPanel, } } fn on_commit(&self) -> Option { match self { Self::Formula | Self::ReplaceFormula { .. } => Some(AppMode::FormulaPanel), Self::Category | Self::Item { .. } => None, } } fn status(&self, name: &str) -> String { match self { Self::Formula => "Formula added".to_string(), Self::ReplaceFormula { .. } => "Formula updated".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::ReplaceFormula { id, was } => ModelCommand::ReplaceFormula { id: id.clone(), was: was.clone(), raw: name.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> { 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, regular: true, }, crate::TileEntry { category: "Product".into(), axis: Axis::Column, regular: true, }, ]; 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's `Enter` edits (improvise-km71), so a click opens the /// editor on the row CLICKED — `Margin`, where the cursor is not. #[test] fn a_formula_row_click_navigates_and_opens_the_editor_on_that_row() { let state = with_panels(); let margin = state.formulas.formulas[1].clone(); 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 }, Command::ChangeMode(AppMode::formula_replace(margin.id, margin.raw.clone())), Command::SetBuffer { name: "formula".into(), value: margin.raw, }, ] ); } /// A click mirrors `Enter`, so the tie is asserted against what `Enter` /// resolves to, not against a reading of it. See `context/code/clients.md`. #[test] fn a_formula_row_click_ends_with_what_enter_does_on_that_row() { let mut state = with_panels(); state.view.mode = AppMode::FormulaPanel; state.view.formula_cursor = 1; let KeyOutcome::Local(enter) = resolve(&state, ev(KeyCode::Enter)) else { panic!("Enter in the formula panel resolves here"); }; assert!(!enter.is_empty(), "Enter on a formula must do something"); let clicked = click(&with_panels(), Click::FormulaRow(1)); assert!( clicked.ends_with(&enter), "the click's activation must be Enter's: {clicked:?} vs {enter:?}" ); } /// 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(crate::FormulaEntry::from) .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, regular: true, }) .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>)> { 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> { 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> = 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)); } } } // A black hole the browser marks is the next test's, by NEEDS_THE_HOST. let marked = |got: &Heard| *got == Heard::Silent && needs_the_host(command); for (where_, got) in report .into_iter() .filter(|(_, got)| *got != Heard::Acts && !marked(got)) { short .entry((where_, command, got)) .or_default() .push(keys.clone()); } } } let short: Vec = 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", // 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 = 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::().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::>(), 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)>) { 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() { // `N` left with improvise-koh6, Ctrl-S with jz6w, and `>`, `R`, `P`, // Ctrl-W, Ctrl-E and `<` with gd28, rmvx and cfly: see clients.md. for name in NEEDS_THE_HOST { let mut sequences = Vec::new(); for (mode, search) in [(AppMode::Normal, false), (AppMode::Normal, true)] { let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode"); let keymap = Keymaps::shared().get(&key).expect("a keymap"); for sequence in sequences_running(keymap, name, None) { sequences.push((mode.clone(), search, sequence)); } } assert!( !sequences.is_empty(), "{name} is bound nowhere this test looks" ); for (mode, search, sequence) in sequences { let mut state = in_mode(mode); state.view.search_mode = search; state.grid.extent = GridExtent { rows: 4, cols: 4 }; state.window = (4, 4); let (key, prefix) = sequence.split_last().expect("a key"); for armed in prefix { if let KeyOutcome::Local(commands) = resolve(&state, *armed) { commands.iter().for_each(|c| state.fold(c)); } } let key = *key; assert_eq!( resolve(&state, key), KeyOutcome::Upstream(key), "{name} ({:?}) was resolved locally; if that is deliberate, it \ leaves NEEDS_THE_HOST, and the browser stops marking it", key.code ); } } } /// improvise-uln5: the help rows a browser cannot run are exactly the ones /// it marks, and no hint names one. See `context/test-evidence/clients.md`. #[test] fn every_help_row_the_browser_cannot_run_is_one_it_marks() { for mode in AppMode::all() { for (command, _, _) in crate::help::advertised(&mode) { assert!( !needs_the_host(command), "the {mode:?} hint line advertises {command}, which a browser \ cannot run and which the hint line never marks" ); } } let mut silent = Vec::new(); let mut marked = Vec::new(); for (key, command, args, _) in crate::help::help_bindings() { let (mode, search) = match &key { ModeKey::SearchMode => (AppMode::Normal, true), key => ( AppMode::all() .into_iter() .find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(key)) .expect("a help row's mode is reachable"), false, ), }; let keymap = Keymaps::shared().get(&key).expect("a keymap"); for sequence in sequences_running(keymap, command, args.as_deref()) { let mut state = furnished(mode.clone()); state.view.search_mode = search; if press(state, &sequence) == Heard::Silent { silent.push(command); } } if needs_the_host(command) { marked.push(command); } } for list in [&mut silent, &mut marked] { list.sort(); list.dedup(); } assert_eq!( silent, marked, "left: help rows whose keys do nothing in a browser; right: the rows \ it marks as needing the terminal. They must be the same rows." ); } /// 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) { 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, regular: true, }]; 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::SetOpenedEditor(Some(OpenedEditor::on("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)); // The commit owns its clear, at the head (improvise-fy9o). assert_eq!( commands[2], Command::SetBuffer { name: "edit".into(), value: String::new(), } ); assert_eq!(commands[3], Command::SetSelected { row: 1, col: 0 }); assert_eq!( commands.last(), Some(&Command::ChangeMode(AppMode::editing())), "the advance re-enters editing on the cell it landed on: {commands:?}" ); assert!( commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))), "after a write the reopened editor awaits the host's seed: {commands:?}" ); for command in &commands { client.fold(command); } assert_eq!( client.view.buffers.get("edit").map(String::as_str), Some("900"), "an answered client seeds the reopened editor from the cell it landed on" ); } /// Three rows by one column, the cursor on row 1, whose record cell's edit /// text is `edit_text`: never the first cell. fn client_on_row_one(edit_text: &str) -> ClientState { let mut cell = CellDisplay::text(edit_text); cell.edit_text = Some(edit_text.into()); 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", "South", "West", ]))), Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("900")), ((1, 0), cell), ((2, 0), CellDisplay::number("800")), ])), Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 1 })), Projection::Basis(published()), ], }); client.window = (3, 1); client.view.cursor_mut().selected = (1, 0); client } /// Resolve `code` against `client`, fold what it composed, and return it. fn press_folded(client: &mut ClientState, code: KeyCode) -> Vec { let KeyOutcome::Local(commands) = resolve(client, ev(code)) else { panic!("{code:?} must resolve here"); }; for command in &commands { client.fold(command); } commands } /// Tab in an editor lands one column right, off a centred cursor that no /// edge clamps (improvise-rq3f). Evidence: `context/test-evidence/clients.md`. #[test] fn tab_in_an_editor_commits_and_lands_one_column_right() { let mut client = centred_on(record_cell()); press_folded(&mut client, KeyCode::Char('i')); assert!(client.view.mode.is_editing(), "setup: the editor opened"); let commands = press_folded(&mut client, KeyCode::Tab); assert!( commands.contains(&Command::SetSelected { row: 1, col: 2 }), "Tab must advance right from (1, 1): {commands:?}" ); assert_eq!(client.view.cursor().selected, (1, 2)); assert!( client.view.mode.is_editing(), "and re-enter the editor there" ); } /// Right on the last tile stays on it, and still emits, as `MoveTileCursor` /// does (improvise-rq3f). Evidence: `context/test-evidence/clients.md`. #[test] fn the_tile_cursor_stops_on_the_last_tile() { let mut client = furnished(AppMode::TileSelect); let last = client.tiles.tiles.len() - 1; assert_eq!( client.view.tile_cat_idx, last - 1, "setup: one short of the end" ); assert_eq!( press_folded(&mut client, KeyCode::Right), vec![Command::SetTileCatIdx(last)], "the control: a move that is not clamped" ); for code in [KeyCode::Right, KeyCode::Char('l')] { assert_eq!( press_folded(&mut client, code), vec![Command::SetTileCatIdx(last)], "{code:?} past the last tile must clamp to it" ); } assert_eq!(client.view.tile_cat_idx, last); } /// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`. #[test] fn an_untouched_browser_commit_writes_nothing_and_still_advances() { for seed in ["007", "1e3", "inf", "ERR:boom", "1.23456789"] { let mut client = client_on_row_one(seed); press_folded(&mut client, KeyCode::Char('i')); assert_eq!(read_buffer(&client.view, "edit"), seed, "wrong seed"); let commands = press_folded(&mut client, KeyCode::Enter); assert!( !commands.iter().any(|c| matches!(c, Command::Model { .. })), "{seed}: an untouched commit wrote: {commands:?}" ); assert!( commands.contains(&Command::SetSelected { row: 2, col: 0 }) && commands.contains(&Command::ChangeMode(AppMode::editing())), "{seed}: the advance and re-entry did not run: {commands:?}" ); assert!( commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::on( "800".into() )))), "{seed}: the reopened editor did not capture its own seed: {commands:?}" ); } } /// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`. #[test] fn an_untouched_browser_edit_does_not_overwrite_a_write_made_while_it_was_open() { let mut client = client_on_row_one("007"); press_folded(&mut client, KeyCode::Char('i')); let mut moved = CellDisplay::number("5"); moved.id = Some(record_cell()); client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( vec![((1, 0), moved)], )))); let commands = press_folded(&mut client, KeyCode::Enter); assert!( !commands.iter().any(|c| matches!(c, Command::Model { .. })), "the stale seed was written over the other client's value: {commands:?}" ); } /// improvise-465t: the editor an advance reopens starts untouched, even /// after a typed commit. See `context/test-evidence/clients.md`. #[test] fn the_editor_an_advance_reopens_starts_untouched() { let mut client = client_on_row_one("7"); press_folded(&mut client, KeyCode::Char('i')); press_folded(&mut client, KeyCode::Char('9')); assert_eq!( client.view.opened_editor.as_ref().map(|o| o.touched), Some(true), "the first editor was never touched, so this proves nothing" ); press_folded(&mut client, KeyCode::Enter); assert_eq!( client.view.cursor().selected, (2, 0), "the advance did not run" ); assert_eq!( client.view.opened_editor, Some(OpenedEditor::on("800".into())), "the reopened editor inherited the previous editor's touched flag" ); } /// BUG (improvise-2054) — see `context/test-evidence/clients.md`. #[test] fn a_browser_editor_typed_back_to_its_seed_still_writes() { let mut client = client_on_row_one("7"); press_folded(&mut client, KeyCode::Char('i')); let mut moved = CellDisplay::number("5"); moved.id = Some(record_cell()); client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( vec![((1, 0), moved)], )))); press_folded(&mut client, KeyCode::Backspace); assert_eq!( client.view.opened_editor.as_ref().map(|o| o.touched), Some(true), "Backspace alone did not mark the editor touched" ); press_folded(&mut client, KeyCode::Char('7')); assert_eq!(read_buffer(&client.view, "edit"), "7"); let commands = press_folded(&mut client, KeyCode::Enter); assert!( commands.iter().any(|c| matches!( c, Command::Model { cmd: ModelCommand::SetCell { value: CellValue::Number(7.0), .. }, .. } )), "a value the user typed was dropped because it spelled the seed: {commands:?}" ); } /// Either user edit marks the open editor, and nothing else does: a key /// into another buffer leaves it alone. Evidence: clients.md. #[test] fn only_an_edit_of_the_editors_buffer_marks_it_touched() { for key in [KeyCode::Char('x'), KeyCode::Backspace] { let mut client = client_on_row_one("7"); press_folded(&mut client, KeyCode::Char('i')); assert_eq!( client.view.opened_editor.as_ref().map(|o| o.touched), Some(false) ); press_folded(&mut client, key); assert_eq!( client.view.opened_editor.as_ref().map(|o| o.touched), Some(true), "{key:?} did not mark the editor touched" ); } let mut state = client_on_row_one("7"); state.view.opened_editor = Some(OpenedEditor::on("7".into())); assert_eq!( typed_into(&state, "command", "q".into()), vec![Command::SetBuffer { name: "command".into(), value: "q".into() }], "typing into another buffer marked the cell editor" ); } /// BUG (improvise-wv3h) — see `context/test-evidence/clients.md`. #[test] fn a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed() { let mut client = client_on_row_one(""); press_folded(&mut client, KeyCode::Char('i')); press_folded(&mut client, KeyCode::Esc); assert!( !client.view.mode.is_editing(), "Esc did not close the editor" ); let mut moved = CellDisplay::number("9"); moved.id = Some(record_cell()); client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( vec![((1, 0), moved)], )))); let entered = composed( &client, ev(KeyCode::Enter), "enter-mode", &["editing".into()], ) .expect("enter-mode editing composes here"); for command in &entered { client.fold(command); } assert!( client.view.mode.is_editing(), "enter-mode did not open an editor" ); let commands = press_folded(&mut client, KeyCode::Enter); assert!( commands.iter().any(|c| matches!( c, Command::Model { cmd: ModelCommand::ClearCell(_), .. } )), "the empty commit was skipped against the earlier editor's seed: {commands:?}" ); } /// `untouched` is exact: whitespace is content, and an edited editor is /// never untouched even when it spells its seed (improvise-wv3h, 2054). #[test] fn untouched_is_exact_and_honours_an_edit() { let fresh = |seed: &str| OpenedEditor::on(seed.into()); assert!(untouched(Some(&fresh("7")), "7")); for (seed, text) in [("7", "7 "), ("7", " 7"), (" 7", "7"), ("a", "A")] { assert!(!untouched(Some(&fresh(seed)), text), "{seed:?} vs {text:?}"); } let edited = OpenedEditor { touched: true, ..fresh("7") }; assert!(!untouched(Some(&edited), "7")); assert!(!untouched(None, "")); } /// A typed change is still a write, and `007` typed fresh is still a /// number: classification is `typed()`'s and out of this ticket's scope. #[test] fn a_touched_browser_commit_still_writes() { let mut client = client_on_row_one("7"); press_folded(&mut client, KeyCode::Char('i')); press_folded(&mut client, KeyCode::Backspace); for c in ['0', '0', '7'] { press_folded(&mut client, KeyCode::Char(c)); } let commands = press_folded(&mut client, KeyCode::Enter); assert!( commands.iter().any(|c| matches!( c, Command::Model { cmd: ModelCommand::SetCell { value: CellValue::Number(7.0), .. }, .. } )), "{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"); }; commands.into_iter().find_map(|command| match command { Command::Model { cmd, .. } => Some(cmd), _ => None, }) }; assert_eq!( committed(&client), None, "Enter on an editor showing the cell must not clear it; untouched, it writes nothing" ); 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!( matches!( committed(&client), Some(ModelCommand::SetCell { value: CellValue::Number(n), .. }) if n == 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" ); } } #[cfg(test)] mod opened_editor_wire_tests { use super::OpenedEditor; use crate::{ClientFrame, Command}; /// improvise-mgis: `awaiting: false` is the claim that the seed is settled, /// so a frame that dropped it is refused. `context/test-evidence/clients.md`. #[test] fn a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse() { let frame = ClientFrame::Commands(vec![Command::SetOpenedEditor(Some( OpenedEditor::awaiting(), ))]); let mut json = serde_json::to_value(&frame).expect("a ClientFrame serializes"); let opened = json .pointer_mut("/Commands/0/SetOpenedEditor") .and_then(serde_json::Value::as_object_mut) .expect("the editor is where this test expects it"); assert!( opened.remove("awaiting").is_some() && opened.contains_key("seed"), "the field must have been there, and the rest of the editor must remain" ); let err = serde_json::from_value::(json) .expect_err("an editor that states no awaiting flag states no claim"); assert!( err.to_string().contains("missing field `awaiting`"), "it must fail as the MISSING FIELD, not as broken JSON: {err}" ); } }