//! One keypress → the `Command`s a thin client may compose for itself. Shared //! by every front end; every decision here is in `context/code/clients.md`. use improvise_core::view::Axis; use crate::client::ClientState; use crate::keymap::{Binding, Keymaps, ModeKey}; use crate::viewmodel::{CatTreeEntry, flatten_cat_tree}; use crate::{ AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent, ModelCommand, Panel, RecordCell, ViewState, records_named, }; /// The virtual category a new measure is filed under, spelled as /// `commit.rs::add_measure` spells it. const MEASURE_CATEGORY: &str = "_Measure"; /// What this client decided to do with a keypress. #[derive(Debug, Clone, PartialEq)] pub enum KeyOutcome { /// Resolved here: the caller folds them locally *and* sends them, as one /// frame, in order. Never empty. See `context/code/clients.md`. Local(Vec), /// 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, } 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; }; // 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 a prefix is composable (`Command::SetTransientPrefix`); what // the second key then runs is not. See `context/code/clients.md`. Binding::Prefix(_) => KeyOutcome::Upstream(event), Binding::Cmd { name, args } => match step(state, event, name, args) { Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands), 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), }, } } /// 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 }, } /// 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), }; 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) } /// The formula panel binds **no** `Enter`, so the rule yields navigation and /// nothing else. That is the rule applying, not an omission — clients.md. fn click_formula_row(state: &ClientState, index: usize) -> Option> { state.formulas.formulas.get(index)?; let mut commands = focus(state, Panel::Formula); commands.push(Command::SetPanelCursor { panel: Panel::Formula, cursor: index, }); Some(commands) } /// Focus the panel, move its cursor, then activate — the three things the /// keyboard needs three keystrokes for, and `filter-to-item` is the activation. fn click_category_row(state: &ClientState, index: usize) -> Option> { // 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), 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), Upstream, } /// One name, answered. A decline is checked first, so [`composed`] has no arm /// for any of them and the two lists cannot overlap. fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step { 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 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", // NOT "not built": it commits a drill first, so composing it would // commit another client's typing. Needs addressed commands — clients.md. "view-back" => "stepping back to the previous view", // 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(format!("{what} is not available in the browser yet")) } /// The subset of command names this client can turn into `Command`s; `None` /// means "the host's". Each arm mirrors its `Cmd` line for line — clients.md. fn composed( state: &ClientState, event: KeyEvent, name: &str, args: &[String], ) -> Option> { match name { // ── the grid cursor ────────────────────────────────────────── "move-selection" => grid_move(state, GridMove::Relative(num(args, 0)?, num(args, 1)?)), "scroll-rows" => grid_move(state, GridMove::Relative(num(args, 0)?, 0)), "jump-first-row" => grid_move(state, GridMove::ToStart(true)), "jump-first-col" => grid_move(state, GridMove::ToStart(false)), "jump-last-row" => grid_move(state, GridMove::ToEnd(true)), "jump-last-col" => grid_move(state, GridMove::ToEnd(false)), "page-scroll" => grid_move(state, GridMove::Page(num(args, 0)?)), // ── the other two cursors ──────────────────────────────────── "move-panel-cursor" => panel_cursor(state, args.first()?, num(args, 1)?), "move-tile-cursor" => tile_cursor(state, num(args, 0)?), // ── minibuffer text entry ──────────────────────────────────── "append-char" => { let KeyCode::Char(c) = event.code else { return Some(Vec::new()); }; let mut value = read_buffer(&state.view, args.first()?); value.push(c); Some(vec![write_buffer(args.first()?, value)]) } "pop-char" => { let mut value = read_buffer(&state.view, args.first()?); value.pop(); Some(vec![write_buffer(args.first()?, value)]) } "clear-buffer" => Some(vec![write_buffer(args.first()?, String::new())]), // ── mode entry ─────────────────────────────────────────────── "enter-mode" => { let mode = AppMode::from_name(args.first()?)?; // `declined` answers this first, and both halves hold the rule: // no path opens a room this client cannot commit. stranding(&mode).is_none().then(|| entered(mode)) } // ── panels ─────────────────────────────────────────────────── "cycle-panel-focus" => Some(cycle_panel_focus(&state.view)), "toggle-panel-visibility" => { let panel = named_panel(args.first()?)?; Some(vec![Command::SetPanelOpen { panel, open: !state.view.panel_open(panel), }]) } // The interactive arm of `TogglePanelAndFocus`: open-and-focus unless // this panel is already both. See `context/code/clients.md`. "toggle-panel-and-focus" => { let panel = named_panel(args.first()?)?; let closing = state.view.panel_open(panel) && state.view.mode.panel() == Some(panel); Some(vec![ Command::SetPanelOpen { panel, open: !closing, }, Command::ChangeMode(match closing { true => AppMode::Normal, false => panel.mode(), }), ]) } // `EnterTileSelect`'s gate is invariantly true, so there is nothing // here to mirror it against. See `context/code/clients.md`. "enter-tile-select" => Some(vec![ Command::SetTileCatIdx(0), Command::ChangeMode(AppMode::TileSelect), ]), // The view's shape. Neither the dirty flag nor the cursor reset is // this client's to send; both are the host's — clients.md. "transpose" => Some(vec![Command::Model { basis: state.basis.claim([])?, cmd: ModelCommand::TransposeAxes, }]), // ── 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) => vec![model(state, ModelCommand::CycleAxis(cat))?], None => vec![Command::SetStatus( "Move cursor to a category header to change axis".to_string(), )], }), "open-item-add-at-cursor" => Some(match cat_at_cursor(state) { Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))], None => vec![Command::SetStatus( "No category selected. Press n to add a category first.".to_string(), )], }), "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 = format!("View {}", state.views.views.len() + 1); Some(vec![ model(state, ModelCommand::CreateView(name.clone()))?, Command::SwitchView(name), Command::ChangeMode(AppMode::Normal), ]) } "delete-view-at-cursor" => delete_view_at_cursor(state), // ── 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)?), )?]), "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(), } } 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())); } 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), } 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 = (window_rows as i32 * 3 / 4).max(1) * 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> { let cell = cell_at_cursor(state)?; match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through // `format_f64`. `tests/lockstep.rs` found exactly that. CellId::Record(_) => Some(vec![ Command::SetBuffer { name: "edit".to_string(), value: cell.edit_text.clone().unwrap_or_default(), }, Command::ChangeMode(mode), ]), CellId::Aggregate(_) => Some(vec![Command::SetStatus( "drilling into an aggregate needs the host — not in the browser yet".to_string(), )]), CellId::Derived { target } => Some(vec![Command::SetStatus(format!( "{target} is computed by a formula — edit the formula, not the cell" ))]), // A creating column takes a name, and committing one is the records // fork this client does not have (improvise-poia). CellId::Create(_) => None, } } /// 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 { category: (*cat).to_string(), item: items[indices[i]].clone(), }, }) }) .collect() } /// 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.view.mode.is_records() { return None; } let mut commands = vec![Command::Model { basis: state.basis.claim([record_at_cursor(state)?])?, cmd: typed_cell_command(id, &text)?, }]; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(advanced(state, edit_mode, advance)?); Some(commands) } /// `CommitAndAdvance` over a creating column: a schema change over the typed /// NAME. Why +cat does not advance: clients.md (improvise-gcvb). fn create_column( state: &ClientState, edit_mode: AppMode, advance: Advance, kind: &CreateKind, text: &str, ) -> Option> { // `exit_mode_for`: the tidy-up goes FIRST, where an aborting // `AddCategory` cannot reach it (gotcha 21). let mut commands = vec![Command::ChangeMode(match state.view.mode.is_records() { true => AppMode::RecordsNormal, false => AppMode::Normal, })]; let name = text.trim(); if !name.is_empty() { commands.push(model( state, match kind { CreateKind::Measure => ModelCommand::AddItem { category: MEASURE_CATEGORY.to_string(), item: name.to_string(), }, CreateKind::Category => ModelCommand::AddCategory(name.to_string()), }, )?); } let (row, col) = state.view.cursor().selected; commands.push(Command::SetSelected { row, col }); // An inserted column renumbers every index to its right, so staying put is // what lands on the new one (improvise-2zq, gotcha 23). if matches!(kind, CreateKind::Category) { return Some(commands); } commands.extend(advanced(state, edit_mode, advance)?); Some(commands) } /// The advance and the re-entry `CommitAndAdvance` ends in. `None` where the /// host would emit `AddRecordRow`, which this client cannot name (gotcha 1). fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option> { 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.view.mode.is_records() && row >= row_max && col >= col_max { return None; } let landed = match advance { Advance::Down if row < row_max => Some((row + 1, col)), Advance::Down if col < col_max => Some((0, col + 1)), // `EnterAdvance` returns `AbortChain` at the bottom-right corner, so // here that is simply the arm that appends nothing more. Advance::Down => None, // `Right` has no abort: it clamps at the rightmost column and the // re-entry always runs. Advance::Right => Some((row, (col + 1).min(col_max))), }; let Some((nr, nc)) = landed else { return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); // `EnterEditAtCursor`: seed from where we landed, then change mode. The // new cell is a different cell, so a stale cache is correct here. commands.push(Command::SetBuffer { name: "edit".to_string(), value: seed_at(state, nr, nc), }); commands.push(Command::ChangeMode(edit_mode)); Some(commands) } /// The surrogate naming the record under the cursor, or `None` when the /// cursor is not on a writable cell. fn record_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.record), CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None, } } /// The display text at an absolute grid position, as the cache holds it. fn seed_at(state: &ClientState, row: usize, col: usize) -> String { let cursor = state.view.cursor(); let Some(r) = row.checked_sub(cursor.row_offset) else { return String::new(); }; let Some(c) = col.checked_sub(cursor.col_offset) else { return String::new(); }; state .grid .cell(r, c) .and_then(|cell| cell.edit_text.clone()) .unwrap_or_default() } /// How a typed string becomes a [`CellValue`] — a rule that genuinely lives in /// two places, with the obligation stated but not discharged. See clients.md. 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(_) => 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()) } /// `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 { category: cat_name.clone(), axis: Axis::Page, }, )?, model( state, ModelCommand::SetPageSelection { 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, }) => Some(vec![ model( state, ModelCommand::RemoveItem { category: cat_name.clone(), item: item_name.clone(), }, )?, Command::SetStatus(format!("Deleted item '{item_name}' from '{cat_name}'")), ]), None => Some(vec![Command::SetStatus( "No category to delete".to_string(), )]), } } /// 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 { category: tile.category.clone(), axis, }, None => ModelCommand::CycleAxis(tile.category.clone()), }; Some(vec![ model(state, cmd)?, Command::SetStatus(format!("{} → {}", tile.category, new_axis.plain_label())), ]) } /// `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 exactly /// as `parse_commit_kind` does — only `Item` needs the mode. enum CommitKind { Formula, Category, Item { category: String }, } impl CommitKind { fn of(arg: &str, mode: &AppMode) -> Option { match (arg, mode) { ("formula", _) => Some(Self::Formula), ("category", _) => Some(Self::Category), ("item", AppMode::ItemAdd { category, .. }) => Some(Self::Item { category: category.clone(), }), _ => None, } } fn buffer(&self) -> &'static str { match self { Self::Formula => "formula", Self::Category => "category", Self::Item { .. } => "item", } } fn on_empty(&self) -> AppMode { match self { Self::Formula => AppMode::FormulaPanel, Self::Category | Self::Item { .. } => AppMode::CategoryPanel, } } fn on_commit(&self) -> Option { match self { Self::Formula => Some(AppMode::FormulaPanel), Self::Category | Self::Item { .. } => None, } } fn status(&self, name: &str) -> String { match self { Self::Formula => "Formula added".to_string(), Self::Category => format!("Added category \"{name}\""), Self::Item { .. } => format!("Added \"{name}\""), } } fn add(&self, name: &str) -> ModelCommand { match self { Self::Formula => ModelCommand::AddFormula { raw: name.to_string(), target_category: "_Measure".to_string(), }, Self::Category => ModelCommand::AddCategory(name.to_string()), Self::Item { category } => ModelCommand::AddItem { category: category.clone(), item: name.to_string(), }, } } } /// `CommitBuffer::execute`: the status is optimistic and goes **first**, so /// the add's own failure message wins on the host (improvise-6t3). fn commit_buffer(state: &ClientState, kind: CommitKind) -> Option> { 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) } /// Every text-entry mode, built by its own constructor. Nothing checks /// this hand-written list against `AppMode` — see the evidence file. fn text_entry_modes() -> Vec { vec![ AppMode::editing(), AppMode::records_editing(), AppMode::formula_edit(), AppMode::command_mode(), AppMode::category_add(), AppMode::item_add("Region".into()), AppMode::export_prompt(), AppMode::save_prompt(), ] } /// A client in `mode` with **empty caches** — no grid, no panels, no /// tiles, and no announced window. fn in_mode(mode: AppMode) -> ClientState { ClientState { view: ViewState { mode, ..ViewState::default() }, ..ClientState::default() } } /// The user's ruling: the category panel is open at launch. It is the /// *shared* default, so both front ends open with it — clients.md. #[test] fn a_fresh_session_opens_with_the_category_panel() { let fresh = ViewState::default(); assert!( fresh.category_panel_open, "a client that has pressed nothing can already see its categories" ); assert!( !fresh.formula_panel_open && !fresh.view_panel_open, "and only that one — the ruling names the category panel alone" ); } // ── the pointer (improvise-7ybz) ──────────────────────────────── fn with_categories() -> ClientState { let mut state = ClientState::default(); // Explicitly shut: these tests are about a click *opening* the panel, // which the launch default would otherwise answer for them. state.view.category_panel_open = false; state.fold(&Command::Project(Projection::CategoryTree( crate::CategoryTreeUpdate::Categories(vec![ crate::CategoryRow { name: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }, crate::CategoryRow { name: "Product".into(), items: vec!["Shirts".into()], axis: Axis::Column, }, ]), ))); state } /// A click is three intents in one, and the third must be resolved /// against the row clicked — see `context/test-evidence/clients.md`. #[test] fn a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row() { let state = with_categories(); assert_eq!( state.view.cat_panel_cursor, 0, "the cursor starts elsewhere" ); let commands = click(&state, Click::CategoryRow(1)); assert_eq!( commands, vec![ Command::SetPanelOpen { panel: Panel::Category, open: true }, Command::ChangeMode(AppMode::CategoryPanel), Command::SetPanelCursor { panel: Panel::Category, cursor: 1 }, // `Product`, the row clicked — NOT `Region`, which is where // the cursor still is when this is resolved. Command::ToggleCatExpand("Product".into()), ] ); } /// The focus half emits only what is not already true, so a click inside /// the focused panel is not a mode change every time. #[test] fn a_click_in_an_already_focused_panel_only_moves_and_activates() { let mut state = with_categories(); state.view.category_panel_open = true; state.view.mode = AppMode::CategoryPanel; assert_eq!( click(&state, Click::CategoryRow(0)), vec![ Command::SetPanelCursor { panel: Panel::Category, cursor: 0 }, Command::ToggleCatExpand("Region".into()), ] ); } /// A click on the panel's empty space is not a click on a row, and must /// not drag the cursor onto one. #[test] fn a_click_past_the_last_row_resolves_to_nothing() { assert!(click(&with_categories(), Click::CategoryRow(9)).is_empty()); assert!(click(&ClientState::default(), Click::Tile(0)).is_empty()); assert!(click(&ClientState::default(), Click::ViewRow(0)).is_empty()); assert!(click(&ClientState::default(), Click::FormulaRow(0)).is_empty()); } /// Two tiles and a **published basis**, so a missing `Command::Model` is /// a decision rather than a client that could not compose one. fn with_tiles() -> ClientState { let mut state = ClientState::default(); state.bootstrap(Snapshot { view: ViewState::default(), seed: vec![Projection::Basis(published())], }); state.tiles.tiles = vec![ crate::TileEntry { category: "Region".into(), axis: Axis::Row, }, crate::TileEntry { category: "Product".into(), axis: Axis::Column, }, ]; state } /// The user's ruling: a click **selects**, it never mutates. The basis is /// published here, so nothing stops the cycle but the rule itself. #[test] fn a_first_tile_click_selects_without_touching_the_document() { let state = with_tiles(); let commands = click(&state, Click::Tile(1)); assert_eq!( commands, vec![ Command::SetTileCatIdx(1), Command::ChangeMode(AppMode::TileSelect), ], "select and enter TileSelect, and stop there" ); assert!( !commands.iter().any(|c| matches!(c, Command::Model { .. })), "a click must not change the document; the axis cycle needs a second one" ); } /// `tile_cat_idx` is 0 before anything has selected anything, so the /// already-selected test needs the **mode** as well as the index. #[test] fn the_first_click_on_tile_zero_selects_it_like_any_other() { let state = with_tiles(); assert_eq!(state.view.tile_cat_idx, 0, "the default index is the trap"); let commands = click(&state, Click::Tile(0)); assert_eq!( commands, vec![ Command::SetTileCatIdx(0), Command::ChangeMode(AppMode::TileSelect), ] ); assert!( !commands.iter().any(|c| matches!(c, Command::Model { .. })), "index alone would read tile 0 as already selected and cycle it" ); } /// Entering `TileSelect` by keyboard leaves `tile_cat_idx` at 0, so a click /// on the leftmost tile cycles on the FIRST gesture. See clients.md. #[test] fn t_then_a_click_on_the_leftmost_tile_cycles_it_immediately() { let mut state = with_tiles(); state.view.mode = AppMode::TileSelect; assert_eq!(state.view.tile_cat_idx, 0, "what `T` leaves behind"); let commands = click(&state, Click::Tile(0)); assert!( commands.iter().any(|c| matches!(c, Command::Model { .. })), "the tile really is selected, and a click does what Enter does there" ); assert!( !click(&state, Click::Tile(1)) .iter() .any(|c| matches!(c, Command::Model { .. })), "its neighbour answers the identical gesture by selecting — deliberate" ); } /// The second click is on the **already-selected** tile, not merely the /// second click of the session — otherwise the guarantee holds once. #[test] fn a_second_click_on_the_selected_tile_cycles_its_axis() { let mut state = with_tiles(); state.view.mode = AppMode::TileSelect; state.view.tile_cat_idx = 1; assert_eq!( click(&state, Click::Tile(1)), vec![ Command::Model { basis: state.basis.claim([]).unwrap(), cmd: ModelCommand::CycleAxis("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 { .. }))); } /// `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 { category: "Region".into(), axis: Axis::Page }, }, Command::Model { basis, cmd: ModelCommand::SetPageSelection { category: "Region".into(), item: "North".into() }, }, Command::SetStatus("Filter: Region = North".into()), ] ); } fn with_panels() -> ClientState { let mut state = ClientState::default(); state.fold(&Command::Project(Projection::ViewList(vec![ crate::ViewSummary { name: "Default".into(), axes: vec![], }, crate::ViewSummary { name: "Plan".into(), axes: vec![], }, ]))); state.fold(&Command::Project(Projection::Formulas(vec![ "Profit = Revenue - Cost".into(), "Margin = Profit / Revenue".into(), ]))); state } /// improvise-iioi: the same three intents the category panel gets, with /// `switch-view-at-cursor` as the activation. #[test] fn a_view_row_click_focuses_moves_and_switches_to_the_row_clicked() { let state = with_panels(); assert_eq!( click(&state, Click::ViewRow(1)), vec![ Command::SetPanelOpen { panel: Panel::View, open: true }, Command::ChangeMode(AppMode::ViewPanel), Command::SetPanelCursor { panel: Panel::View, cursor: 1 }, // `Plan`, the row clicked — not `Default`, where the cursor is. Command::SwitchView("Plan".into()), Command::ChangeMode(AppMode::Normal), ] ); } /// The formula panel binds no `Enter`, so the rule yields navigation and /// nothing else. A mode change here would be an invented activation. #[test] fn a_formula_row_click_navigates_and_activates_nothing() { let state = with_panels(); let commands = click(&state, Click::FormulaRow(1)); assert_eq!( commands, vec![ Command::SetPanelOpen { panel: Panel::Formula, open: true }, Command::ChangeMode(AppMode::FormulaPanel), Command::SetPanelCursor { panel: Panel::Formula, cursor: 1 }, ] ); } /// The asymmetry is a fact about the keymap, so it is asserted against /// the keymap — not against a reading of it. See `context/code/clients.md`. #[test] fn the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does() { let keymaps = Keymaps::shared(); assert!( keymaps .lookup(&ModeKey::FormulaPanel, KeyCode::Enter, KeyModifiers::NONE) .is_none(), "if FormulaPanel gains an Enter binding, click_formula_row must \ activate too — the click mirrors Enter, and this is the only \ thing tying the two together" ); assert!( keymaps .lookup(&ModeKey::ViewPanel, KeyCode::Enter, KeyModifiers::NONE) .is_some(), "and the view panel's click activates because its Enter does" ); } /// The pointer and the keyboard are one cursor: a click resolves to the /// same `SetSelected` a movement key would, offsets reconciled. #[test] fn a_cell_click_is_absolute_even_though_the_page_paints_a_window() { let mut state = ClientState::default(); state.fold(&Command::Project(Projection::Grid(GridUpdate::Extent( crate::GridExtent { rows: 40, cols: 9 }, )))); state.window = (10, 4); state.view.cursor_mut().row_offset = 12; state.view.cursor_mut().col_offset = 3; assert_eq!( click(&state, Click::Cell { row: 2, col: 1 }), vec![Command::SetSelected { row: 14, col: 4 }], "viewport (2,1) under offsets (12,3) is absolute (14,4)" ); assert!( click(&state, Click::Cell { row: 39, col: 0 }).is_empty(), "a click below the grid names no cell" ); } // ── text entry ────────────────────────────────────────────────── #[test] fn typing_a_character_in_any_text_entry_mode_resolves_locally() { for mode in text_entry_modes() { 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 text_entry_modes() { 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 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 { let mut state = client_on(record_cell()); state.view.mode = mode; state.grid.page_coords = vec![crate::PageAxis { cat: "Quarter".into(), selected: "Q1".into(), items: Some(vec!["Q1".into(), "Q2".into()]), }]; // THREE of everything, every panel cursor seated in the MIDDLE: an edge // makes `move-panel-cursor` answer `Ignored` for the wrong reason. crate::WidgetCache::apply( &mut state.categories, &crate::CategoryTreeUpdate::Categories( ["Region", "Product", "Quarter"] .into_iter() .map(|name| crate::CategoryRow { name: name.into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }) .collect(), ), ); state.formulas.formulas = [ "Profit = Revenue - Cost", "Tax = Revenue * 0.1", "Net = Profit", ] .into_iter() .map(str::to_string) .collect(); state.views.views = ["Default", "Second", "Third"] .into_iter() .map(|name| crate::ViewSummary { name: name.into(), axes: Vec::new(), }) .collect(); state.tiles.tiles = vec![crate::TileEntry { category: "Region".into(), axis: Axis::Row, }]; 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 } /// improvise-rmvx. An advertised key that resolves upstream is a black hole /// (gotcha 47). See `context/test-evidence/clients.md`. #[test] fn every_advertised_command_answers_in_its_own_mode() { let mut unanswered = Vec::new(); for mode in crate::help::all_modes() { 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()); for (command, args, caption) in crate::help::advertised(&mode) { for (code, mods) in keymap.keys_running(command, args.as_deref()) { let event = KeyEvent::new(code, mods); // BOTH silent variants. To a user `Ignored` is the same // black hole as `Upstream` — nothing is on the screen. let answered = !matches!( resolve(&state, event), KeyOutcome::Upstream(_) | KeyOutcome::Ignored ); if !answered { unanswered.push(format!( "{}: {:?} → `{command} {args:?}` ({caption})", crate::mode_label(&mode), code )); } } } } unanswered.sort(); unanswered.dedup(); // The five that remain, each with the reason it is not a black hole. // Closing one has to come here and say so; opening a sixth fails. let expected = [ // Reached only from a mode `declined` refuses at the door, so a // browser is never standing in front of this hint. "COMMAND: Enter → `execute-command None` (run)", "EXPORT: Enter → `commit-export None` (export)", "SAVE AS: Enter → `commit-save None` (save)", // 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: Enter → `commit-cell-edit None` (commit)", "RECORDS INSERT: Tab → `commit-and-advance-right None` (commit+right)", ]; let mut expected: Vec = expected.iter().map(|s| s.to_string()).collect(); expected.sort(); assert_eq!( unanswered, expected, "advertised and unanswerable: anything here that is not in the \ named list above reads as broken rather than as unavailable" ); } /// 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 crate::help::all_modes() { 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") ); } /// 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() { let mut state = in_mode(AppMode::Normal); state.grid.extent = GridExtent { rows: 4, cols: 4 }; state.window = (4, 4); for (key, name) in [ (ev(KeyCode::Char('g')), ""), (ev(KeyCode::Char('y')), ""), (ev(KeyCode::Char('Z')), ""), (ev(KeyCode::Char('H')), "hide-selected-row-item"), (ev(KeyCode::Char('z')), "toggle-group-under-cursor"), // `>`, `<`, `R`, `P`, Ctrl-W and Ctrl-E are NOT here any more: each // answers with a status of its own (improvise-gd28, improvise-rmvx). (ev(KeyCode::Char('/')), "search"), (ev(KeyCode::Char('n')), "search-navigate"), (ev(KeyCode::Char('N')), "search-or-category-add"), (ev(KeyCode::Enter), "enter-advance"), ( KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), "save", ), ( KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), "force-quit", ), ] { assert_eq!( resolve(&state, key), KeyOutcome::Upstream(key), "{name} ({:?}) was resolved locally; if that is deliberate, \ this list is where it stops being a gap", key.code ); } } /// The advertised key must say what is actually true. Two refusals, two /// subjects — see `context/test-evidence/clients.md`. #[test] fn the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons() { let mut state = in_mode(AppMode::Normal); state.grid.extent = GridExtent { rows: 2, cols: 2 }; state.window = (2, 2); let KeyOutcome::Local(drill) = resolve(&state, ev(KeyCode::Char('>'))) else { panic!("`>` is advertised in the hint line; it must answer"); }; let [Command::SetStatus(said)] = &drill[..] else { panic!("expected one status, got {drill:?}"); }; assert!( said.contains("drill") && said.contains("browser"), "the status must name drill and say it is the browser that lacks it: {said}" ); assert!( !said.contains("aggregate"), "a sentence about aggregates is TRUE-SOUNDING about the wrong thing — \ `>` is refused whatever the cell is: {said}" ); } /// 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, }]) ); } /// A mode this client was never taught answers nothing itself: the /// wizard's whole keymap is one `Any` binding into `HandleWizardKey`. #[test] fn an_untaught_mode_resolves_nothing() { for code in [KeyCode::Char('j'), KeyCode::Down, KeyCode::Enter] { assert_eq!( resolve(&in_mode(AppMode::ImportWizard), ev(code)), KeyOutcome::Upstream(ev(code)), "the wizard is the host's" ); } } /// A key nothing binds is `Ignored`, not `Upstream`: reading the same /// table makes "nothing is bound" a fact rather than a deferred guess. #[test] fn an_unbound_key_is_ignored_rather_than_sent_into_the_void() { let state = in_mode(AppMode::Normal); assert_eq!(resolve(&state, ev(KeyCode::F(9))), KeyOutcome::Ignored); assert_eq!( resolve(&state, KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)), KeyOutcome::Ignored ); } /// An empty widget is not an unanswerable key: the client knows perfectly /// well that there is nowhere to go. #[test] fn an_empty_widget_answers_ignored_rather_than_forwarding() { for (mode, code) in [ (AppMode::Normal, KeyCode::Char('j')), (AppMode::TileSelect, KeyCode::Char('l')), (AppMode::FormulaPanel, KeyCode::Char('j')), ] { assert_eq!( resolve(&in_mode(mode.clone()), ev(code)), KeyOutcome::Ignored, "{} / {code:?}", crate::help::mode_label(&mode) ); } } /// Which widgets say nothing about a move that goes nowhere is **not a /// rule of this module** — it is the `Cmd`'s, and the three differ. #[test] fn a_move_that_goes_nowhere_follows_the_command_it_mirrors() { let mut grid = in_mode(AppMode::Normal); grid.grid.extent = GridExtent { rows: 2, cols: 2 }; grid.window = (10, 10); assert_eq!( resolve(&grid, ev(KeyCode::Char('k'))), KeyOutcome::Local(vec![Command::SetSelected { row: 0, col: 0 }]), "Move emits even when it clamps, so this must too" ); assert_eq!( resolve(&grid, ev(KeyCode::Char('j'))), KeyOutcome::Local(vec![Command::SetSelected { row: 1, col: 0 }]), "and the fixture is not simply inert" ); let mut panel = in_mode(AppMode::FormulaPanel); panel.formulas.formulas = vec!["Profit = Revenue - Cost".into()]; assert_eq!( resolve(&panel, ev(KeyCode::Char('k'))), KeyOutcome::Ignored, "MovePanelCursor returns no effects when the cursor does not move" ); let mut tiles = in_mode(AppMode::TileSelect); tiles.tiles.tiles = vec![crate::TileEntry { category: "Region".into(), axis: improvise_core::view::Axis::Row, }]; assert_eq!( resolve(&tiles, ev(KeyCode::Char('h'))), KeyOutcome::Local(vec![Command::SetTileCatIdx(0)]), "MoveTileCursor emits regardless, like Move" ); } // ── the editing path ──────────────────────────────────────────── fn surrogate() -> crate::RecordHash { serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("a surrogate is hex") } fn record_cell() -> CellId { CellId::Record(RecordCell { record: surrogate(), measure: Some("Revenue".into()), }) } /// A one-by-two grid whose first cell carries `id`, with a published basis /// naming its record: the shape a client is in before the user types. fn client_on(id: CellId) -> ClientState { let mut first = CellDisplay::number("1200"); first.id = Some(id); let mut client = ClientState::default(); client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))), Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))), Projection::Grid(GridUpdate::Cells(vec![ ((0, 0), first), ((1, 0), CellDisplay::number("900")), ])), Projection::Grid(GridUpdate::Extent(GridExtent { rows: 2, cols: 1 })), Projection::Basis(published()), ], }); client.window = (2, 1); client } fn published() -> crate::Basis { crate::basis::published(7, 3, [(surrogate(), 4)].into_iter().collect()) } /// `i` on a `Record` cell seeds the edit buffer and enters `Editing`: /// `EditOrDrill`'s non-aggregated branch, in the order it emits. #[test] fn i_on_a_record_cell_enters_editing_seeded_from_the_cell() { let client = client_on(record_cell()); assert_eq!( resolve(&client, ev(KeyCode::Char('i'))), KeyOutcome::Local(vec![ Command::SetBuffer { name: "edit".into(), value: "1200".into(), }, Command::ChangeMode(AppMode::editing()), ]) ); } /// `a` is bound to the same command with the same argument, and behaves /// identically without a second arm here: the point of sharing the table. #[test] fn a_and_i_are_one_binding_with_two_keys() { let client = client_on(record_cell()); assert_eq!( resolve(&client, ev(KeyCode::Char('a'))), resolve(&client, ev(KeyCode::Char('i'))) ); } /// `i` on an aggregate says so; upstream would be a keypress the user /// sees do nothing (`doctrine(an_upstream_key_is_a_black_hole)`). #[test] fn i_on_an_aggregate_declines_out_loud() { let client = client_on(CellId::Aggregate( serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(), )); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else { panic!("an aggregate must not be a silent no-op"); }; assert!( matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("drill")), "expected a status naming drill, got {commands:?}" ); } /// `i` on a formula-owned cell names the formula, so the user learns where /// the value comes from rather than typing into a shadowed cell (ir7l). #[test] fn i_on_a_derived_cell_names_the_formula() { let client = client_on(CellId::Derived { target: "Profit".into(), }); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else { panic!("a derived cell must not be a silent no-op"); }; assert!( matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("Profit") && msg.contains("formula")), "expected a status naming the formula target, got {commands:?}" ); } /// Two page axes, so the odometer's carry is visible: `Quarter` is least /// significant and `Year` only moves when it wraps. fn paged() -> ClientState { let mut state = in_mode(AppMode::Normal); crate::WidgetCache::apply(&mut state.basis, &published()); state.grid.page_coords = vec![ crate::PageAxis { cat: "Year".into(), selected: "2024".into(), items: Some(vec!["2024".into(), "2025".into()]), }, crate::PageAxis { cat: "Quarter".into(), selected: "Q4".into(), items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]), }, ]; state } fn selections(outcome: KeyOutcome) -> Vec<(String, String)> { let KeyOutcome::Local(commands) = outcome else { panic!("paging must resolve here, got {outcome:?}"); }; commands .into_iter() .map(|c| match c { Command::Model { cmd: ModelCommand::SetPageSelection { category, item }, .. } => (category, item), other => panic!("expected a page selection, got {other:?}"), }) .collect() } /// improvise-p1ym: `]` had no item list to cycle, so it reached the host /// and did nothing. `context/test-evidence/clients.md`. #[test] fn the_page_keys_turn_the_odometer_as_the_command_does() { let state = paged(); assert_eq!( selections(resolve(&state, ev(KeyCode::Char(']')))), vec![ ("Year".to_string(), "2025".to_string()), ("Quarter".to_string(), "Q1".to_string()), ], "Q4 wraps to Q1 and carries into Year" ); assert_eq!( selections(resolve(&state, ev(KeyCode::Char('[')))), vec![ ("Year".to_string(), "2024".to_string()), ("Quarter".to_string(), "Q3".to_string()), ], "no borrow, so Year stands still — and is still restated" ); } /// A Filter axis constrains without being navigable (gotcha 27), so it /// carries no items and the odometer cannot reach it. #[test] fn a_filter_axis_is_projected_but_not_cycled() { let mut state = paged(); state.grid.page_coords.push(crate::PageAxis { cat: "Region".into(), selected: "East".into(), items: None, }); let moved = selections(resolve(&state, ev(KeyCode::Char(']')))); assert!( !moved.iter().any(|(cat, _)| cat == "Region"), "a Filter axis must not be paged, got {moved:?}" ); assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}"); } /// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]` /// and every part shows up here — including the odd-looking trailing clear. #[test] fn enter_commits_the_cell_advances_and_re_enters_editing() { let mut client = client_on(record_cell()); client.view.mode = AppMode::editing(); client.view.buffers.insert("edit".into(), "42".into()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { panic!("Enter in Editing must resolve here"); }; // The write, composed through the same function click-to-edit uses. assert_eq!( commands[0], Command::Model { basis: published(), cmd: ModelCommand::SetCell { cell: RecordCell { record: surrogate(), measure: Some("Revenue".into()), }, value: CellValue::Number(42.0), }, } ); assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal)); assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 }); assert!( commands.contains(&Command::ChangeMode(AppMode::editing())), "the advance re-enters editing on the cell it landed on: {commands:?}" ); assert_eq!( commands.last(), Some(&Command::SetBuffer { name: "edit".into(), value: String::new(), }), "the keymap welds `clear-buffer` to every commit: {commands:?}" ); } /// An empty buffer is `ClearCell`, not `SetCell` of an empty string: the /// decision `typed()` makes, reached by keyboard and by mouse alike. #[test] fn committing_an_empty_buffer_clears_the_cell() { let mut client = client_on(record_cell()); client.view.mode = AppMode::editing(); client.view.buffers.insert("edit".into(), String::new()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { panic!("Enter in Editing must resolve here"); }; assert!(matches!( &commands[0], Command::Model { cmd: ModelCommand::ClearCell(_), .. } )); } /// 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:?}" ); } /// 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()); client.view.mode = AppMode::records_editing(); assert_eq!( resolve(&client, ev(KeyCode::Enter)), KeyOutcome::Upstream(ev(KeyCode::Enter)) ); } }