From fd69126cdcac0b5747e706c9985731dbef32c810 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 9 Apr 2026 02:25:23 -0700 Subject: [PATCH] refactor: split cmd.rs --- src/command/cmd.rs | 4300 -------------------------------- src/command/cmd/cell.rs | 87 + src/command/cmd/commit.rs | 203 ++ src/command/cmd/core.rs | 277 ++ src/command/cmd/effect_cmds.rs | 359 +++ src/command/cmd/grid.rs | 300 +++ src/command/cmd/mod.rs | 19 + src/command/cmd/mode.rs | 189 ++ src/command/cmd/navigation.rs | 279 +++ src/command/cmd/panel.rs | 347 +++ src/command/cmd/registry.rs | 586 +++++ src/command/cmd/search.rs | 121 + src/command/cmd/tests.rs | 1407 +++++++++++ src/command/cmd/text_buffer.rs | 135 + src/command/cmd/tile.rs | 82 + 15 files changed, 4391 insertions(+), 4300 deletions(-) delete mode 100644 src/command/cmd.rs create mode 100644 src/command/cmd/cell.rs create mode 100644 src/command/cmd/commit.rs create mode 100644 src/command/cmd/core.rs create mode 100644 src/command/cmd/effect_cmds.rs create mode 100644 src/command/cmd/grid.rs create mode 100644 src/command/cmd/mod.rs create mode 100644 src/command/cmd/mode.rs create mode 100644 src/command/cmd/navigation.rs create mode 100644 src/command/cmd/panel.rs create mode 100644 src/command/cmd/registry.rs create mode 100644 src/command/cmd/search.rs create mode 100644 src/command/cmd/tests.rs create mode 100644 src/command/cmd/text_buffer.rs create mode 100644 src/command/cmd/tile.rs diff --git a/src/command/cmd.rs b/src/command/cmd.rs deleted file mode 100644 index e08b32f..0000000 --- a/src/command/cmd.rs +++ /dev/null @@ -1,4300 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Debug; - -use crossterm::event::KeyCode; - -use crate::model::cell::{CellKey, CellValue}; -use crate::model::Model; -use crate::ui::app::AppMode; -use crate::ui::effect::{self, Effect, Panel}; -use crate::view::{Axis, AxisEntry, GridLayout}; - -/// Read-only context available to commands for decision-making. -pub struct CmdContext<'a> { - pub model: &'a Model, - pub layout: &'a GridLayout, - pub registry: &'a CmdRegistry, - pub mode: &'a AppMode, - pub selected: (usize, usize), - pub row_offset: usize, - pub col_offset: usize, - pub search_query: &'a str, - pub yanked: &'a Option, - pub dirty: bool, - pub search_mode: bool, - pub formula_panel_open: bool, - pub category_panel_open: bool, - pub view_panel_open: bool, - /// Panel cursors - pub formula_cursor: usize, - pub cat_panel_cursor: usize, - pub view_panel_cursor: usize, - /// Tile select cursor (which category is selected) - pub tile_cat_idx: usize, - /// Named text buffers - pub buffers: &'a HashMap, - /// View navigation stacks (for drill back/forward) - pub view_back_stack: &'a [String], - pub view_forward_stack: &'a [String], - /// Display value at the cursor — works uniformly for pivot and records mode. - pub display_value: String, - /// How many data rows/cols fit on screen (for viewport scrolling). - pub visible_rows: usize, - pub visible_cols: usize, - /// Expanded categories in the tree panel - pub expanded_cats: &'a std::collections::HashSet, - /// The key that triggered this command - pub key_code: KeyCode, -} - -impl<'a> CmdContext<'a> { - pub fn cell_key(&self) -> Option { - self.layout.cell_key(self.selected.0, self.selected.1) - } - pub fn row_count(&self) -> usize { - self.layout.row_count() - } - pub fn col_count(&self) -> usize { - self.layout.col_count() - } - pub fn none_cats(&self) -> &[String] { - &self.layout.none_cats - } -} - -impl<'a> CmdContext<'a> { - /// Resolve the category panel tree entry at the current cursor. - pub fn cat_tree_entry(&self) -> Option { - let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats); - tree.into_iter().nth(self.cat_panel_cursor) - } - - /// The category name at the current tree cursor (whether on a - /// category header or an item). - pub fn cat_at_cursor(&self) -> Option { - self.cat_tree_entry().map(|e| e.cat_name().to_string()) - } - - /// Total number of entries in the category tree. - pub fn cat_tree_len(&self) -> usize { - crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len() - } -} - -/// A command that reads state and produces effects. -pub trait Cmd: Debug + Send + Sync { - fn execute(&self, ctx: &CmdContext) -> Vec>; - /// The canonical name of this command (matches its registry key). - /// Used by the parser tests and for introspection. - #[allow(dead_code)] - fn name(&self) -> &'static str; -} - -/// Factory that constructs a Cmd from text arguments (headless/script). -pub type ParseFn = fn(&[String]) -> Result, String>; - -/// Factory that constructs a Cmd from the interactive context (keymap dispatch). -/// Receives both the keymap args and the interactive context so commands can -/// combine text arguments (e.g. panel name) with runtime state (e.g. whether -/// the panel is currently open). -pub type InteractiveFn = fn(&[String], &CmdContext) -> Result, String>; - -type BoxParseFn = Box Result, String>>; -type BoxInteractiveFn = Box Result, String>>; - -/// A registered command entry with both text and interactive constructors. -struct CmdEntry { - name: &'static str, - parse: BoxParseFn, - interactive: BoxInteractiveFn, -} - -/// Registry of commands constructible from text or from interactive context. -#[derive(Default)] -pub struct CmdRegistry { - entries: Vec, - aliases: Vec<(&'static str, &'static str)>, -} - -impl CmdRegistry { - pub fn new() -> Self { - Self { - entries: Vec::new(), - aliases: Vec::new(), - } - } - - /// Register a short name that resolves to a canonical command name. - pub fn alias(&mut self, short: &'static str, canonical: &'static str) { - self.aliases.push((short, canonical)); - } - - /// Resolve a command name through the alias table. - fn resolve<'a>(&'a self, name: &'a str) -> &'a str { - for (alias, canonical) in &self.aliases { - if *alias == name { - return canonical; - } - } - name - } - - /// Register a command with both a text parser and an interactive constructor. - /// The name is derived from a prototype command instance. - pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) { - self.entries.push(CmdEntry { - name: prototype.name(), - parse: Box::new(parse), - interactive: Box::new(interactive), - }); - } - - /// Register a command that doesn't need interactive context. - /// When called interactively with args, delegates to parse. - /// When called interactively without args, returns an error. - pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) { - self.entries.push(CmdEntry { - name: prototype.name(), - parse: Box::new(parse), - interactive: Box::new(move |args, _ctx| { - if args.is_empty() { - Err("this command requires arguments".into()) - } else { - parse(args) - } - }), - }); - } - - /// Register a zero-arg command (same instance for parse and interactive). - /// The name is derived by calling `f()` once. - pub fn register_nullary(&mut self, f: fn() -> Box) { - let name = f().name(); - self.entries.push(CmdEntry { - name, - parse: Box::new(move |_| Ok(f())), - interactive: Box::new(move |_, _| Ok(f())), - }); - } - - /// Construct a command from text arguments (script/headless). - pub fn parse(&self, name: &str, args: &[String]) -> Result, String> { - let name = self.resolve(name); - for e in &self.entries { - if e.name == name { - return (e.parse)(args); - } - } - Err(format!("Unknown command: {name}")) - } - - /// Construct a command from interactive context (keymap dispatch). - /// Always calls the interactive constructor with both args and ctx, - /// so commands can combine text arguments with runtime state. - pub fn interactive( - &self, - name: &str, - args: &[String], - ctx: &CmdContext, - ) -> Result, String> { - let name = self.resolve(name); - for e in &self.entries { - if e.name == name { - return (e.interactive)(args, ctx); - } - } - Err(format!("Unknown command: {name}")) - } - - #[allow(dead_code)] - pub fn names(&self) -> impl Iterator + '_ { - self.entries.iter().map(|e| e.name) - } -} - -/// Dummy prototype used only for name extraction in registry calls -/// where the real command struct is built by a closure. -#[derive(Debug)] -struct NamedCmd(&'static str); -impl Cmd for NamedCmd { - fn name(&self) -> &'static str { - self.0 - } - fn execute(&self, _: &CmdContext) -> Vec> { - vec![] - } -} - -fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> { - if args.len() < n { - Err(format!( - "{word} requires {n} argument(s), got {}", - args.len() - )) - } else { - Ok(()) - } -} - -/// Parse Cat/Item coordinate args into a CellKey. -fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey { - let coords: Vec<(String, String)> = args - .iter() - .filter_map(|s| { - let (cat, item) = s.split_once('/')?; - Some((cat.to_string(), item.to_string())) - }) - .collect(); - crate::model::cell::CellKey::new(coords) -} - -// ── Navigation commands ────────────────────────────────────────────────────── -// All navigation commands take explicit cursor state. The interactive spec -// fills position/bounds from context; the parser accepts them as args. - -/// Shared viewport state for navigation commands. -#[derive(Debug, Clone, Default)] -pub struct CursorState { - pub row: usize, - pub col: usize, - pub row_count: usize, - pub col_count: usize, - pub row_offset: usize, - pub col_offset: usize, - pub visible_rows: usize, - pub visible_cols: usize, -} - -impl CursorState { - pub fn from_ctx(ctx: &CmdContext) -> Self { - Self { - row: ctx.selected.0, - col: ctx.selected.1, - row_count: ctx.row_count(), - col_count: ctx.col_count(), - row_offset: ctx.row_offset, - col_offset: ctx.col_offset, - visible_rows: ctx.visible_rows, - visible_cols: ctx.visible_cols, - } - } -} - -/// Compute viewport-tracking effects for a new row/col position. -fn viewport_effects( - nr: usize, - nc: usize, - old_row_offset: usize, - old_col_offset: usize, - visible_rows: usize, - visible_cols: usize, -) -> Vec> { - let mut effects: Vec> = vec![effect::set_selected(nr, nc)]; - let mut row_offset = old_row_offset; - let mut col_offset = old_col_offset; - let vr = visible_rows.max(1); - let vc = visible_cols.max(1); - if nr < row_offset { - row_offset = nr; - } - if nr >= row_offset + vr { - row_offset = nr.saturating_sub(vr - 1); - } - if nc < col_offset { - col_offset = nc; - } - if nc >= col_offset + vc { - col_offset = nc.saturating_sub(vc - 1); - } - if row_offset != old_row_offset { - effects.push(Box::new(effect::SetRowOffset(row_offset))); - } - if col_offset != old_col_offset { - effects.push(Box::new(effect::SetColOffset(col_offset))); - } - effects -} - -/// How to move the cursor. -#[derive(Debug, Clone)] -pub enum MoveKind { - /// Relative offset (dr, dc) — subsumes MoveSelection and ScrollRows. - Relative(i32, i32), - /// Jump to start of axis: `true` = row, `false` = col. - ToStart(bool), - /// Jump to end of axis: `true` = row, `false` = col. - ToEnd(bool), - /// Page scroll: +1 = down, -1 = up (delta computed from visible_rows). - Page(i32), -} - -/// Unified navigation command. All variants go through `viewport_effects`. -#[derive(Debug)] -pub struct Move { - pub kind: MoveKind, - pub cursor: CursorState, - pub cmd_name: &'static str, -} - -impl Cmd for Move { - fn name(&self) -> &'static str { - self.cmd_name - } - - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let row_max = self.cursor.row_count.saturating_sub(1) as i32; - let col_max = self.cursor.col_count.saturating_sub(1) as i32; - let (nr, nc) = match &self.kind { - MoveKind::Relative(dr, dc) => { - let nr = (self.cursor.row as i32 + dr).clamp(0, row_max) as usize; - let nc = (self.cursor.col as i32 + dc).clamp(0, col_max) as usize; - (nr, nc) - } - MoveKind::ToStart(is_row) => { - if *is_row { - (0, self.cursor.col) - } else { - (self.cursor.row, 0) - } - } - MoveKind::ToEnd(is_row) => { - if *is_row { - (row_max.max(0) as usize, self.cursor.col) - } else { - (self.cursor.row, col_max.max(0) as usize) - } - } - MoveKind::Page(dir) => { - let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir; - let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize; - (nr, self.cursor.col) - } - }; - viewport_effects( - nr, - nc, - self.cursor.row_offset, - self.cursor.col_offset, - self.cursor.visible_rows, - self.cursor.visible_cols, - ) - } -} - -// ── Mode change commands ───────────────────────────────────────────────────── - -#[derive(Debug)] -pub struct EnterMode(pub AppMode); -impl Cmd for EnterMode { - fn name(&self) -> &'static str { - "enter-mode" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let mut effects: Vec> = Vec::new(); - // Clear the corresponding buffer when entering a text-entry mode - if let Some(mb) = self.0.minibuffer() { - effects.push(Box::new(effect::SetBuffer { - name: mb.buffer_key.to_string(), - value: String::new(), - })); - } - effects.push(effect::change_mode(self.0.clone())); - effects - } -} - -#[derive(Debug)] -pub struct ForceQuit; -impl Cmd for ForceQuit { - fn name(&self) -> &'static str { - "force-quit" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![effect::change_mode(AppMode::Quit)] - } -} - -/// Quit with dirty check — refuses if unsaved changes exist. -#[derive(Debug)] -pub struct Quit; -impl Cmd for Quit { - fn name(&self) -> &'static str { - "q" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if ctx.dirty { - vec![effect::set_status( - "Unsaved changes. Use :q! to force quit or :wq to save+quit.", - )] - } else { - vec![effect::change_mode(AppMode::Quit)] - } - } -} - -/// Save then quit. -#[derive(Debug)] -pub struct SaveAndQuit; -impl Cmd for SaveAndQuit { - fn name(&self) -> &'static str { - "wq" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::Save), effect::change_mode(AppMode::Quit)] - } -} - -// ── Cell operations ────────────────────────────────────────────────────────── -// All cell commands take an explicit CellKey. The interactive spec fills it -// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args. - -/// Clear a cell. -#[derive(Debug)] -pub struct ClearCellCommand { - pub key: crate::model::cell::CellKey, -} -impl Cmd for ClearCellCommand { - fn name(&self) -> &'static str { - "clear-cell" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::ClearCell(self.key.clone())), - effect::mark_dirty(), - ] - } -} - -/// Yank (copy) a cell value. -#[derive(Debug)] -pub struct YankCell { - pub key: crate::model::cell::CellKey, -} -impl Cmd for YankCell { - fn name(&self) -> &'static str { - "yank" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats()); - vec![ - Box::new(effect::SetYanked(value)), - effect::set_status("Yanked"), - ] - } -} - -/// Paste the yanked value into a cell. -#[derive(Debug)] -pub struct PasteCell { - pub key: crate::model::cell::CellKey, -} -impl Cmd for PasteCell { - fn name(&self) -> &'static str { - "paste" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if let Some(value) = ctx.yanked.clone() { - vec![ - Box::new(effect::SetCell(self.key.clone(), value)), - effect::mark_dirty(), - ] - } else { - vec![] - } - } -} - -// ── View commands ──────────────────────────────────────────────────────────── - -#[derive(Debug)] -pub struct TransposeAxes; -impl Cmd for TransposeAxes { - fn name(&self) -> &'static str { - "transpose" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::TransposeAxes), effect::mark_dirty()] - } -} - -#[derive(Debug)] -pub struct SaveCmd; -impl Cmd for SaveCmd { - fn name(&self) -> &'static str { - "save" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::Save)] - } -} - -// ── Search ─────────────────────────────────────────────────────────────────── - -#[derive(Debug)] -pub struct EnterSearchMode; -impl Cmd for EnterSearchMode { - fn name(&self) -> &'static str { - "search" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::SetSearchMode(true)), - Box::new(effect::SetSearchQuery(String::new())), - ] - } -} - -// ── Panel commands ────────────────────────────────────────────────────── - -/// Toggle a panel's visibility; if it opens, focus it (enter its mode). -#[derive(Debug)] -pub struct TogglePanelAndFocus { - pub panel: Panel, - pub open: bool, - pub focused: bool, -} -impl Cmd for TogglePanelAndFocus { - fn name(&self) -> &'static str { - "toggle-panel-and-focus" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let mut effects: Vec> = Vec::new(); - effects.push(Box::new(effect::SetPanelOpen { - panel: self.panel, - open: self.open, - })); - if self.focused { - effects.push(effect::change_mode(self.panel.mode())); - } else { - effects.push(effect::change_mode(AppMode::Normal)); - } - effects - } -} - -/// Toggle a panel's visibility without changing mode. -#[derive(Debug)] -pub struct TogglePanelVisibility { - pub panel: Panel, - pub currently_open: bool, -} -impl Cmd for TogglePanelVisibility { - fn name(&self) -> &'static str { - "toggle-panel-visibility" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::SetPanelOpen { - panel: self.panel, - open: !self.currently_open, - })] - } -} - -/// Tab through open panels, entering the first open panel's mode. -#[derive(Debug)] -pub struct CyclePanelFocus { - pub formula_open: bool, - pub category_open: bool, - pub view_open: bool, -} -impl Cmd for CyclePanelFocus { - fn name(&self) -> &'static str { - "cycle-panel-focus" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - if self.formula_open { - vec![effect::change_mode(AppMode::FormulaPanel)] - } else if self.category_open { - vec![effect::change_mode(AppMode::CategoryPanel)] - } else if self.view_open { - vec![effect::change_mode(AppMode::ViewPanel)] - } else { - vec![] - } - } -} - -// ── Editing entry ─────────────────────────────────────────────────────── - -/// Enter editing mode with an initial buffer value. -#[derive(Debug)] -pub struct EnterEditMode { - pub initial_value: String, -} -impl Cmd for EnterEditMode { - fn name(&self) -> &'static str { - "enter-edit-mode" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![ - Box::new(effect::SetBuffer { - name: "edit".to_string(), - value: self.initial_value.clone(), - }), - effect::change_mode(AppMode::editing()), - ] - } -} - -/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell -/// (categories on `Axis::None`, no records mode), drill into it instead of -/// editing. Otherwise enter edit mode with the current displayed value. -#[derive(Debug)] -pub struct EditOrDrill; -impl Cmd for EditOrDrill { - fn name(&self) -> &'static str { - "edit-or-drill" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - // Only consider regular (non-virtual, non-label) categories on None - // as true aggregation. Virtuals like _Index/_Dim are always None in - // pivot mode and don't imply aggregation. - let regular_none = ctx.none_cats().iter().any(|c| { - ctx.model - .category(c) - .map(|cat| cat.kind.is_regular()) - .unwrap_or(false) - }); - // In records mode (synthetic key), always edit directly — no drilling. - let is_synthetic = ctx - .cell_key() - .as_ref() - .and_then(crate::view::synthetic_record_info) - .is_some(); - let is_aggregated = !is_synthetic && regular_none; - if is_aggregated { - let Some(key) = ctx.cell_key().clone() else { - return vec![effect::set_status("cannot drill — no cell at cursor")]; - }; - return DrillIntoCell { key }.execute(ctx); - } - EnterEditMode { - initial_value: ctx.display_value.clone(), - } - .execute(ctx) - } -} - -/// In records mode, add a new row with an empty value. The new cell gets -/// coords from the current page filters. In pivot mode, this is a no-op. -#[derive(Debug)] -pub struct AddRecordRow; -impl Cmd for AddRecordRow { - fn name(&self) -> &'static str { - "add-record-row" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let is_records = ctx - .cell_key() - .as_ref() - .and_then(crate::view::synthetic_record_info) - .is_some(); - if !is_records { - return vec![effect::set_status( - "add-record-row only works in records mode", - )]; - } - // Build a CellKey from the current page filters - let view = ctx.model.active_view(); - let page_cats: Vec = view - .categories_on(crate::view::Axis::Page) - .into_iter() - .map(String::from) - .collect(); - let coords: Vec<(String, String)> = page_cats - .iter() - .map(|cat| { - let sel = view.page_selection(cat).unwrap_or("").to_string(); - (cat.clone(), sel) - }) - .filter(|(_, v)| !v.is_empty()) - .collect(); - let key = crate::model::cell::CellKey::new(coords); - vec![ - Box::new(effect::SetCell(key, CellValue::Number(0.0))), - effect::mark_dirty(), - effect::set_status("Added new record row"), - ] - } -} - -/// Thin command wrapper around the `EnterEditAtCursor` effect so it can -/// participate in `Binding::Sequence`. -#[derive(Debug)] -pub struct EnterEditAtCursorCmd; -impl Cmd for EnterEditAtCursorCmd { - fn name(&self) -> &'static str { - "enter-edit-at-cursor" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::EnterEditAtCursor)] - } -} - -/// Typewriter-style advance: move down, wrap to top of next column at bottom. -#[derive(Debug)] -pub struct EnterAdvance { - pub cursor: CursorState, -} -impl Cmd for EnterAdvance { - fn name(&self) -> &'static str { - "enter-advance" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let row_max = self.cursor.row_count.saturating_sub(1); - let col_max = self.cursor.col_count.saturating_sub(1); - let (r, c) = (self.cursor.row, self.cursor.col); - let (nr, nc) = if r < row_max { - (r + 1, c) - } else if c < col_max { - (0, c + 1) - } else { - (r, c) // already at bottom-right; stay - }; - viewport_effects( - nr, - nc, - self.cursor.row_offset, - self.cursor.col_offset, - self.cursor.visible_rows, - self.cursor.visible_cols, - ) - } -} - -/// Enter export prompt mode. -#[derive(Debug)] -pub struct EnterExportPrompt; -impl Cmd for EnterExportPrompt { - fn name(&self) -> &'static str { - "enter-export-prompt" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![effect::change_mode(AppMode::export_prompt())] - } -} - -// ── Search / navigation ───────────────────────────────────────────────── - -/// Navigate to the next or previous search match. -#[derive(Debug)] -pub struct SearchNavigate(pub bool); -impl Cmd for SearchNavigate { - fn name(&self) -> &'static str { - "search-navigate" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let query = ctx.search_query.to_lowercase(); - if query.is_empty() { - return vec![]; - } - - let (cur_row, cur_col) = ctx.selected; - let total_rows = ctx.row_count().max(1); - let total_cols = ctx.col_count().max(1); - let total = total_rows * total_cols; - let cur_flat = cur_row * total_cols + cur_col; - - let matches: Vec = (0..total) - .filter(|&flat| { - let ri = flat / total_cols; - let ci = flat % total_cols; - let key = match ctx.layout.cell_key(ri, ci) { - Some(k) => k, - None => return false, - }; - let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { - Some(CellValue::Number(n)) => format!("{n}"), - Some(CellValue::Text(t)) => t, - Some(CellValue::Error(e)) => format!("ERR:{e}"), - None => String::new(), - }; - s.to_lowercase().contains(&query) - }) - .collect(); - - if matches.is_empty() { - return vec![effect::set_status(format!( - "No matches for '{}'", - ctx.search_query - ))]; - } - - let target_flat = if self.0 { - matches - .iter() - .find(|&&f| f > cur_flat) - .or_else(|| matches.first()) - .copied() - } else { - matches - .iter() - .rev() - .find(|&&f| f < cur_flat) - .or_else(|| matches.last()) - .copied() - }; - - if let Some(flat) = target_flat { - let ri = flat / total_cols; - let ci = flat % total_cols; - let mut effects: Vec> = vec![effect::set_selected(ri, ci)]; - if ri < ctx.row_offset { - effects.push(Box::new(effect::SetRowOffset(ri))); - } - if ci < ctx.col_offset { - effects.push(Box::new(effect::SetColOffset(ci))); - } - effects.push(effect::set_status(format!( - "Match {}/{} for '{}'", - matches.iter().position(|&f| f == flat).unwrap_or(0) + 1, - matches.len(), - ctx.search_query, - ))); - effects - } else { - vec![] - } - } -} - -/// If search query is active, navigate backward; otherwise open CategoryAdd. -#[derive(Debug)] -pub struct SearchOrCategoryAdd; -impl Cmd for SearchOrCategoryAdd { - fn name(&self) -> &'static str { - "search-or-category-add" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if !ctx.search_query.is_empty() { - SearchNavigate(false).execute(ctx) - } else { - vec![ - Box::new(effect::SetPanelOpen { - panel: Panel::Category, - open: true, - }), - effect::change_mode(AppMode::category_add()), - ] - } - } -} - -// ── Page navigation ───────────────────────────────────────────────────── - -/// Advance to the next page (odometer-style cycling). -#[derive(Debug)] -pub struct PageNext; -impl Cmd for PageNext { - fn name(&self) -> &'static str { - "page-next" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let data = page_cat_data(ctx); - if data.is_empty() { - return vec![]; - } - let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); - let mut carry = true; - for i in (0..data.len()).rev() { - if !carry { - break; - } - indices[i] += 1; - if indices[i] >= data[i].1.len() { - indices[i] = 0; - } else { - carry = false; - } - } - data.iter() - .enumerate() - .map(|(i, (cat, items, _))| { - Box::new(effect::SetPageSelection { - category: cat.clone(), - item: items[indices[i]].clone(), - }) as Box - }) - .collect() - } -} - -/// Go to the previous page (odometer-style cycling). -#[derive(Debug)] -pub struct PagePrev; -impl Cmd for PagePrev { - fn name(&self) -> &'static str { - "page-prev" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let data = page_cat_data(ctx); - if data.is_empty() { - return vec![]; - } - let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); - let mut borrow = true; - for i in (0..data.len()).rev() { - if !borrow { - break; - } - if indices[i] == 0 { - indices[i] = data[i].1.len().saturating_sub(1); - } else { - indices[i] -= 1; - borrow = false; - } - } - data.iter() - .enumerate() - .map(|(i, (cat, items, _))| { - Box::new(effect::SetPageSelection { - category: cat.clone(), - item: items[indices[i]].clone(), - }) as Box - }) - .collect() - } -} - -/// Gather (cat_name, items, current_idx) for page-axis categories. -fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { - let view = ctx.model.active_view(); - let page_cats: Vec = view - .categories_on(Axis::Page) - .into_iter() - .map(String::from) - .collect(); - page_cats - .into_iter() - .filter_map(|cat| { - let items: Vec = ctx - .model - .category(&cat) - .map(|c| { - c.ordered_item_names() - .into_iter() - .map(String::from) - .collect() - }) - .unwrap_or_default(); - if items.is_empty() { - return None; - } - let current = view - .page_selection(&cat) - .map(String::from) - .or_else(|| items.first().cloned()) - .unwrap_or_default(); - let idx = items.iter().position(|i| *i == current).unwrap_or(0); - Some((cat, items, idx)) - }) - .collect() -} - -// ── Grid operations ───────────────────────────────────────────────────── - -/// Toggle the row or column group collapse under the cursor. -#[derive(Debug)] -pub struct ToggleGroupAtCursor { - pub is_row: bool, -} -impl Cmd for ToggleGroupAtCursor { - fn name(&self) -> &'static str { - if self.is_row { - "toggle-group-under-cursor" - } else { - "toggle-col-group-under-cursor" - } - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let lookup = if self.is_row { - ctx.layout.row_group_for(ctx.selected.0) - } else { - ctx.layout.col_group_for(ctx.selected.1) - }; - let Some((cat, group)) = lookup else { - return vec![]; - }; - vec![ - Box::new(effect::ToggleGroup { - category: cat, - group, - }), - effect::mark_dirty(), - ] - } -} - -/// Hide the row item at the cursor. -#[derive(Debug)] -pub struct HideSelectedRowItem; -impl Cmd for HideSelectedRowItem { - fn name(&self) -> &'static str { - "hide-selected-row-item" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let Some(cat_name) = ctx.layout.row_cats.first().cloned() else { - return vec![]; - }; - let sel_row = ctx.selected.0; - let Some(items) = ctx - .layout - .row_items - .iter() - .filter_map(|e| { - if let AxisEntry::DataItem(v) = e { - Some(v) - } else { - None - } - }) - .nth(sel_row) - else { - return vec![]; - }; - let item_name = items[0].clone(); - vec![ - Box::new(effect::HideItem { - category: cat_name, - item: item_name, - }), - effect::mark_dirty(), - ] - } -} - -/// Navigate back or forward in view history. -#[derive(Debug)] -pub struct ViewNavigate { - pub forward: bool, -} -impl Cmd for ViewNavigate { - fn name(&self) -> &'static str { - if self.forward { - "view-forward" - } else { - "view-back" - } - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if self.forward { - if ctx.view_forward_stack.is_empty() { - vec![effect::set_status("No forward view")] - } else { - vec![Box::new(effect::ViewForward)] - } - } else { - if ctx.view_back_stack.is_empty() { - vec![effect::set_status("No previous view")] - } else { - vec![ - Box::new(effect::ApplyAndClearDrill), - Box::new(effect::ViewBack), - ] - } - } - } -} - -/// Drill down into an aggregated cell: create a _Drill view with _Index on -/// Row and _Dim on Column (records/long-format view). Fixed coordinates -/// from the drilled cell become page filters. -#[derive(Debug)] -pub struct DrillIntoCell { - pub key: crate::model::cell::CellKey, -} -impl Cmd for DrillIntoCell { - fn name(&self) -> &'static str { - "drill-into-cell" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let drill_name = "_Drill".to_string(); - let mut effects: Vec> = Vec::new(); - - // Capture the records snapshot NOW (before we switch views). - let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> = - if self.key.0.is_empty() { - ctx.model - .data - .iter_cells() - .map(|(k, v)| (k, v.clone())) - .collect() - } else { - ctx.model - .data - .matching_cells(&self.key.0) - .into_iter() - .map(|(k, v)| (k, v.clone())) - .collect() - }; - let n = records.len(); - - // Freeze the snapshot in the drill state - effects.push(Box::new(effect::StartDrill(records))); - - // Create (or replace) the drill view - effects.push(Box::new(effect::CreateView(drill_name.clone()))); - effects.push(Box::new(effect::SwitchView(drill_name))); - - // Records mode: _Index on Row, _Dim on Column - effects.push(Box::new(effect::SetAxis { - category: "_Index".to_string(), - axis: crate::view::Axis::Row, - })); - effects.push(Box::new(effect::SetAxis { - category: "_Dim".to_string(), - axis: crate::view::Axis::Column, - })); - - // Fixed coords (from drilled cell) → Page with that value as filter - let fixed_cats: std::collections::HashSet = - self.key.0.iter().map(|(c, _)| c.clone()).collect(); - for (cat, item) in &self.key.0 { - effects.push(Box::new(effect::SetAxis { - category: cat.clone(), - axis: crate::view::Axis::Page, - })); - effects.push(Box::new(effect::SetPageSelection { - category: cat.clone(), - item: item.clone(), - })); - } - // Previously-aggregated categories (none_cats) stay on Axis::None so - // they don't filter records; they'll appear as columns in records mode. - // Skip virtual categories — we already set _Index/_Dim above. - for cat in ctx.none_cats() { - if fixed_cats.contains(cat) || cat.starts_with('_') { - continue; - } - effects.push(Box::new(effect::SetAxis { - category: cat.clone(), - axis: crate::view::Axis::None, - })); - } - - effects.push(effect::set_status(format!("Drilled into cell: {n} rows"))); - effects - } -} - -/// Enter tile select mode. -#[derive(Debug)] -pub struct EnterTileSelect; -impl Cmd for EnterTileSelect { - fn name(&self) -> &'static str { - "enter-tile-select" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let count = ctx.model.category_names().len(); - if count > 0 { - vec![ - Box::new(effect::SetTileCatIdx(0)), - effect::change_mode(AppMode::TileSelect), - ] - } else { - vec![] - } - } -} - -// ── Panel cursor commands ──────────────────────────────────────────────────── - -/// Move a panel cursor by delta, clamping to bounds. -#[derive(Debug)] -pub struct MovePanelCursor { - pub panel: Panel, - pub delta: i32, - pub current: usize, - pub max: usize, -} -impl Cmd for MovePanelCursor { - fn name(&self) -> &'static str { - "move-panel-cursor" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - let cursor = self.current; - let max = self.max; - if max == 0 { - return vec![]; - } - let clamped_cursor = cursor.min(max - 1); - let new = (clamped_cursor as i32 + self.delta).clamp(0, (max - 1) as i32) as usize; - if new != cursor { - vec![Box::new(effect::SetPanelCursor { - panel: self.panel, - cursor: new, - })] - } else { - vec![] - } - } -} - -// ── Formula panel commands ────────────────────────────────────────────────── - -/// Enter formula edit mode with an empty buffer. -#[derive(Debug)] -pub struct EnterFormulaEdit; -impl Cmd for EnterFormulaEdit { - fn name(&self) -> &'static str { - "enter-formula-edit" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![effect::change_mode(AppMode::formula_edit())] - } -} - -/// Delete the formula at the current cursor position. -#[derive(Debug)] -pub struct DeleteFormulaAtCursor; -impl Cmd for DeleteFormulaAtCursor { - fn name(&self) -> &'static str { - "delete-formula-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let formulas = ctx.model.formulas(); - let cursor = ctx.formula_cursor.min(formulas.len().saturating_sub(1)); - if cursor < formulas.len() { - let f = &formulas[cursor]; - let mut effects: Vec> = vec![ - Box::new(effect::RemoveFormula { - target: f.target.clone(), - target_category: f.target_category.clone(), - }), - effect::mark_dirty(), - ]; - if cursor > 0 { - effects.push(Box::new(effect::SetPanelCursor { - panel: Panel::Formula, - cursor: cursor - 1, - })); - } - effects - } else { - vec![] - } - } -} - -// ── Category panel commands ───────────────────────────────────────────────── - -/// Cycle the axis assignment of the category at the cursor. -#[derive(Debug)] -pub struct CycleAxisAtCursor; -impl Cmd for CycleAxisAtCursor { - fn name(&self) -> &'static str { - "cycle-axis-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if let Some(cat_name) = ctx.cat_at_cursor() { - vec![Box::new(effect::CycleAxis(cat_name))] - } else { - vec![effect::set_status( - "Move cursor to a category header to change axis".to_string(), - )] - } - } -} - -/// Enter ItemAdd mode for the category at the panel cursor. -#[derive(Debug)] -pub struct OpenItemAddAtCursor; -impl Cmd for OpenItemAddAtCursor { - fn name(&self) -> &'static str { - "open-item-add-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if let Some(cat_name) = ctx.cat_at_cursor() { - vec![effect::change_mode(AppMode::item_add(cat_name))] - } else { - vec![effect::set_status( - "No category selected. Press n to add a category first.", - )] - } - } -} - -/// Toggle expand/collapse of the category at the tree cursor. -#[derive(Debug)] -pub struct ToggleCatExpand; -impl Cmd for ToggleCatExpand { - fn name(&self) -> &'static str { - "toggle-cat-expand" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if let Some(cat_name) = ctx.cat_at_cursor() { - vec![Box::new(effect::ToggleCatExpand(cat_name))] - } else { - vec![] - } - } -} - -/// Filter to item: when on an item row, set the category to Page with the -/// item as the filter value. -#[derive(Debug)] -pub struct FilterToItem; -impl Cmd for FilterToItem { - fn name(&self) -> &'static str { - "filter-to-item" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - use crate::ui::cat_tree::CatTreeEntry; - match ctx.cat_tree_entry() { - Some(CatTreeEntry::Item { - cat_name, - item_name, - }) => { - vec![ - Box::new(effect::SetAxis { - category: cat_name.clone(), - axis: crate::view::Axis::Page, - }), - Box::new(effect::SetPageSelection { - category: cat_name.clone(), - item: item_name.clone(), - }), - effect::set_status(format!("Filter: {cat_name} = {item_name}")), - ] - } - Some(CatTreeEntry::Category { .. }) => { - // On a category header — toggle expand instead - ToggleCatExpand.execute(ctx) - } - None => vec![], - } - } -} - -/// Toggle pruning of empty rows/columns in the current view. -#[derive(Debug)] -pub struct TogglePruneEmpty; -impl Cmd for TogglePruneEmpty { - fn name(&self) -> &'static str { - "toggle-prune-empty" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let currently_on = ctx.model.active_view().prune_empty; - vec![ - Box::new(effect::TogglePruneEmpty), - effect::set_status(if currently_on { - "Showing all rows/columns" - } else { - "Hiding empty rows/columns" - }), - ] - } -} - -/// Toggle between records mode and pivot mode using the view stack. -/// Entering records mode creates a `_Records` view and switches to it. -/// Leaving records mode navigates back to the previous view. -#[derive(Debug)] -pub struct ToggleRecordsMode; -impl Cmd for ToggleRecordsMode { - fn name(&self) -> &'static str { - "toggle-records-mode" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let is_records = ctx.layout.is_records_mode(); - - if is_records { - // Navigate back to the previous view (restores original axes) - return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")]; - } - - let mut effects: Vec> = Vec::new(); - let records_name = "_Records".to_string(); - - // Create (or replace) a _Records view and switch to it - effects.push(Box::new(effect::CreateView(records_name.clone()))); - effects.push(Box::new(effect::SwitchView(records_name))); - - // _Index on Row, _Dim on Column, everything else → None - effects.push(Box::new(effect::SetAxis { - category: "_Index".to_string(), - axis: crate::view::Axis::Row, - })); - effects.push(Box::new(effect::SetAxis { - category: "_Dim".to_string(), - axis: crate::view::Axis::Column, - })); - for name in ctx.model.categories.keys() { - if name != "_Index" && name != "_Dim" { - effects.push(Box::new(effect::SetAxis { - category: name.clone(), - axis: crate::view::Axis::None, - })); - } - } - effects.push(effect::set_status("Records mode")); - effects - } -} - -/// Delete the category or item at the panel cursor. -/// On a category header → delete the whole category. -/// On an item row → delete just that item. -#[derive(Debug)] -pub struct DeleteCategoryAtCursor; -impl Cmd for DeleteCategoryAtCursor { - fn name(&self) -> &'static str { - "delete-category-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - use crate::ui::cat_tree::CatTreeEntry; - match ctx.cat_tree_entry() { - Some(CatTreeEntry::Category { name, .. }) => { - vec![ - Box::new(effect::RemoveCategory(name.clone())), - effect::mark_dirty(), - effect::set_status(format!("Deleted category '{name}'")), - ] - } - Some(CatTreeEntry::Item { - cat_name, - item_name, - }) => { - vec![ - Box::new(effect::RemoveItem { - category: cat_name.clone(), - item: item_name.clone(), - }), - effect::mark_dirty(), - effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")), - ] - } - None => vec![effect::set_status("No category to delete")], - } - } -} - -// ── View panel commands ───────────────────────────────────────────────────── - -/// Switch to the view at the panel cursor and return to Normal mode. -#[derive(Debug)] -pub struct SwitchViewAtCursor; -impl Cmd for SwitchViewAtCursor { - fn name(&self) -> &'static str { - "switch-view-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let view_names: Vec = ctx.model.views.keys().cloned().collect(); - if let Some(name) = view_names.get(ctx.view_panel_cursor) { - vec![ - Box::new(effect::SwitchView(name.clone())), - effect::change_mode(AppMode::Normal), - ] - } else { - vec![] - } - } -} - -/// Create a new view, switch to it, and return to Normal mode. -#[derive(Debug)] -pub struct CreateAndSwitchView; -impl Cmd for CreateAndSwitchView { - fn name(&self) -> &'static str { - "create-and-switch-view" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let name = format!("View {}", ctx.model.views.len() + 1); - vec![ - Box::new(effect::CreateView(name.clone())), - Box::new(effect::SwitchView(name)), - effect::mark_dirty(), - effect::change_mode(AppMode::Normal), - ] - } -} - -/// Delete the view at the panel cursor. -#[derive(Debug)] -pub struct DeleteViewAtCursor; -impl Cmd for DeleteViewAtCursor { - fn name(&self) -> &'static str { - "delete-view-at-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let view_names: Vec = ctx.model.views.keys().cloned().collect(); - if let Some(name) = view_names.get(ctx.view_panel_cursor) { - let mut effects: Vec> = vec![ - Box::new(effect::DeleteView(name.clone())), - effect::mark_dirty(), - ]; - if ctx.view_panel_cursor > 0 { - effects.push(Box::new(effect::SetPanelCursor { - panel: Panel::View, - cursor: ctx.view_panel_cursor - 1, - })); - } - effects - } else { - vec![] - } - } -} - -// ── Tile select commands ──────────────────────────────────────────────────── - -/// Move the tile select cursor left or right. -#[derive(Debug)] -pub struct MoveTileCursor(pub i32); -impl Cmd for MoveTileCursor { - fn name(&self) -> &'static str { - "move-tile-cursor" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let count = ctx.model.category_names().len(); - if count == 0 { - return vec![]; - } - let new = (ctx.tile_cat_idx as i32 + self.0).clamp(0, (count - 1) as i32) as usize; - vec![Box::new(effect::SetTileCatIdx(new))] - } -} - -/// Cycle or set the axis for the category at the tile cursor. -/// Stays in TileSelect mode so the user can adjust multiple tiles. -/// `axis: None` → cycle, `axis: Some(a)` → set to `a`. -#[derive(Debug)] -pub struct TileAxisOp { - pub axis: Option, -} - -fn axis_label(axis: Axis) -> &'static str { - match axis { - Axis::Row => "Row", - Axis::Column => "Col", - Axis::Page => "Page", - Axis::None => "None", - } -} - -impl Cmd for TileAxisOp { - fn name(&self) -> &'static str { - if self.axis.is_some() { - "set-axis-for-tile" - } else { - "cycle-axis-for-tile" - } - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let cat_names = ctx.model.category_names(); - if let Some(name) = cat_names.get(ctx.tile_cat_idx) { - let new_axis = match self.axis { - Some(axis) => axis, - None => { - let current = ctx.model.active_view().axis_of(name); - match current { - Axis::Row => Axis::Column, - Axis::Column => Axis::Page, - Axis::Page => Axis::None, - Axis::None => Axis::Row, - } - } - }; - let axis_effect: Box = match self.axis { - Some(axis) => Box::new(effect::SetAxis { - category: name.to_string(), - axis, - }), - None => Box::new(effect::CycleAxis(name.to_string())), - }; - let status = format!("{} → {}", name, axis_label(new_axis)); - vec![ - axis_effect, - effect::mark_dirty(), - effect::set_status(status), - ] - } else { - vec![] - } - } -} - -// ── Wizard command ────────────────────────────────────────────────────────── - -/// Dispatch the current key to the import wizard effect. -#[derive(Debug)] -pub struct HandleWizardKey; -impl Cmd for HandleWizardKey { - fn name(&self) -> &'static str { - "handle-wizard-key" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::WizardKey { - key_code: ctx.key_code, - })] - } -} - -// ── Command mode execution ────────────────────────────────────────────────── - -/// Execute the command in the "command" buffer (the `:` command line). -#[derive(Debug)] -/// Execute the `:` command buffer by delegating to the command registry. -/// The `:` prompt is just another frontend to the scripting language — -/// same parser as `improvise script`. -pub struct ExecuteCommand; -impl Cmd for ExecuteCommand { - fn name(&self) -> &'static str { - "execute-command" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let raw = ctx.buffers.get("command").cloned().unwrap_or_default(); - let raw = raw.trim().to_string(); - if raw.is_empty() { - return vec![effect::change_mode(AppMode::Normal)]; - } - - match crate::command::parse::parse_line_with(ctx.registry, &raw) { - Ok(cmds) => { - let mut effects: Vec> = Vec::new(); - for cmd in cmds { - effects.extend(cmd.execute(ctx)); - } - // Return to Normal unless a command already changed mode - if !effects.iter().any(|e| e.changes_mode()) { - effects.push(effect::change_mode(AppMode::Normal)); - } - effects - } - Err(msg) => { - vec![ - effect::set_status(format!(":{raw} — {msg}")), - effect::change_mode(AppMode::Normal), - ] - } - } - } -} - -// ── Text buffer commands ───────────────────────────────────────────────────── - -/// Read the current value of a named buffer from context. -fn read_buffer(ctx: &CmdContext, name: &str) -> String { - if name == "search" { - ctx.search_query.to_string() - } else { - ctx.buffers.get(name).cloned().unwrap_or_default() - } -} - -/// Append the pressed character to a named buffer. -#[derive(Debug)] -pub struct AppendChar { - pub buffer: String, -} -impl Cmd for AppendChar { - fn name(&self) -> &'static str { - "append-char" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if let KeyCode::Char(c) = ctx.key_code { - let mut val = read_buffer(ctx, &self.buffer); - val.push(c); - if self.buffer == "search" { - vec![Box::new(effect::SetSearchQuery(val))] - } else { - vec![Box::new(effect::SetBuffer { - name: self.buffer.clone(), - value: val, - })] - } - } else { - vec![] - } - } -} - -/// Pop the last character from a named buffer. -#[derive(Debug)] -pub struct PopChar { - pub buffer: String, -} -impl Cmd for PopChar { - fn name(&self) -> &'static str { - "pop-char" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let mut val = read_buffer(ctx, &self.buffer); - val.pop(); - if self.buffer == "search" { - vec![Box::new(effect::SetSearchQuery(val))] - } else { - vec![Box::new(effect::SetBuffer { - name: self.buffer.clone(), - value: val, - })] - } - } -} - -// ── Commit commands (mode-specific buffer consumers) ──────────────────────── - -/// Commit a cell edit: set cell value, advance cursor, return to Normal. -/// In records mode, stages the edit in drill_state.pending_edits instead of -/// Commit a cell value: for synthetic records keys, stage in drill pending edits -/// or apply directly; for real keys, write to the model. -fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec>) { - if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) { - effects.push(Box::new(effect::SetDrillPendingEdit { - record_idx, - col_name, - new_value: value.to_string(), - })); - } else if value.is_empty() { - effects.push(Box::new(effect::ClearCell(key.clone()))); - effects.push(effect::mark_dirty()); - } else if let Ok(n) = value.parse::() { - effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n)))); - effects.push(effect::mark_dirty()); - } else { - effects.push(Box::new(effect::SetCell( - key.clone(), - CellValue::Text(value.to_string()), - ))); - effects.push(effect::mark_dirty()); - } -} - -/// Direction to advance after committing a cell edit. -#[derive(Debug, Clone, Copy)] -pub enum AdvanceDir { - /// Move down (typewriter-style, wraps to next column at bottom). - Down, - /// Move right (clamps at rightmost column). - Right, -} - -/// Commit a cell edit, advance the cursor, and re-enter edit mode. -/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right). -#[derive(Debug)] -pub struct CommitAndAdvance { - pub key: CellKey, - pub value: String, - pub advance: AdvanceDir, - pub cursor: CursorState, -} -impl Cmd for CommitAndAdvance { - fn name(&self) -> &'static str { - match self.advance { - AdvanceDir::Down => "commit-cell-edit", - AdvanceDir::Right => "commit-and-advance-right", - } - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let mut effects: Vec> = Vec::new(); - commit_cell_value(&self.key, &self.value, &mut effects); - match self.advance { - AdvanceDir::Down => { - let adv = EnterAdvance { - cursor: self.cursor.clone(), - }; - effects.extend(adv.execute(ctx)); - } - AdvanceDir::Right => { - let col_max = self.cursor.col_count.saturating_sub(1); - let nc = (self.cursor.col + 1).min(col_max); - effects.extend(viewport_effects( - self.cursor.row, - nc, - self.cursor.row_offset, - self.cursor.col_offset, - self.cursor.visible_rows, - self.cursor.visible_cols, - )); - } - } - effects.push(Box::new(effect::EnterEditAtCursor)); - effects - } -} - -/// Commit a formula from the formula edit buffer. -#[derive(Debug)] -pub struct CommitFormula; -impl Cmd for CommitFormula { - fn name(&self) -> &'static str { - "commit-formula" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let buf = ctx.buffers.get("formula").cloned().unwrap_or_default(); - let first_cat = ctx - .model - .regular_category_names() - .into_iter() - .next() - .map(String::from); - let mut effects: Vec> = Vec::new(); - if let Some(cat) = first_cat { - effects.push(Box::new(effect::AddFormula { - raw: buf, - target_category: cat, - })); - effects.push(effect::mark_dirty()); - effects.push(effect::set_status("Formula added")); - } else { - effects.push(effect::set_status("Add at least one category first.")); - } - effects.push(effect::change_mode(AppMode::FormulaPanel)); - effects - } -} - -/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty -/// + status + clear-buffer effects. If empty, return to CategoryPanel. -fn commit_add_from_buffer( - ctx: &CmdContext, - buffer_name: &str, - add_effect: impl FnOnce(&str) -> Option>, - status_msg: impl FnOnce(&str) -> String, -) -> Vec> { - let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default(); - let trimmed = buf.trim().to_string(); - if trimmed.is_empty() { - return vec![effect::change_mode(AppMode::CategoryPanel)]; - } - let Some(add) = add_effect(&trimmed) else { - return vec![]; - }; - vec![ - add, - effect::mark_dirty(), - effect::set_status(status_msg(&trimmed)), - Box::new(effect::SetBuffer { - name: buffer_name.to_string(), - value: String::new(), - }), - ] -} - -/// Commit adding a category, staying in CategoryAdd mode for the next entry. -#[derive(Debug)] -pub struct CommitCategoryAdd; -impl Cmd for CommitCategoryAdd { - fn name(&self) -> &'static str { - "commit-category-add" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - commit_add_from_buffer( - ctx, - "category", - |name| Some(Box::new(effect::AddCategory(name.to_string()))), - |name| format!("Added category \"{name}\""), - ) - } -} - -/// Commit adding an item, staying in ItemAdd mode for the next entry. -#[derive(Debug)] -pub struct CommitItemAdd; -impl Cmd for CommitItemAdd { - fn name(&self) -> &'static str { - "commit-item-add" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let category = if let AppMode::ItemAdd { category, .. } = ctx.mode { - category.clone() - } else { - return vec![]; - }; - commit_add_from_buffer( - ctx, - "item", - |name| { - Some(Box::new(effect::AddItem { - category: category.clone(), - item: name.to_string(), - })) - }, - |name| format!("Added \"{name}\""), - ) - } -} - -/// Commit an export from the export buffer. -#[derive(Debug)] -pub struct CommitExport; -impl Cmd for CommitExport { - fn name(&self) -> &'static str { - "commit-export" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let buf = ctx.buffers.get("export").cloned().unwrap_or_default(); - vec![ - Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))), - effect::change_mode(AppMode::Normal), - ] - } -} - -/// Exit search mode (clears search_mode flag). -#[derive(Debug)] -pub struct ExitSearchMode; -impl Cmd for ExitSearchMode { - fn name(&self) -> &'static str { - "exit-search-mode" - } - fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::SetSearchMode(false))] - } -} - -/// Handle backspace in command mode — pop char or return to Normal if empty. -#[derive(Debug)] -pub struct CommandModeBackspace; -impl Cmd for CommandModeBackspace { - fn name(&self) -> &'static str { - "command-mode-backspace" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let val = ctx.buffers.get("command").cloned().unwrap_or_default(); - if val.is_empty() { - vec![effect::change_mode(AppMode::Normal)] - } else { - let mut val = val; - val.pop(); - vec![Box::new(effect::SetBuffer { - name: "command".to_string(), - value: val, - })] - } - } -} - -// ── Parseable model-mutation commands ──────────────────────────────────────── -// These are thin Cmd wrappers around effects, constructible from string args. -// They share the same execution path as keymap-dispatched commands. - -macro_rules! effect_cmd { - ($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => { - #[derive(Debug)] - pub struct $name(pub Vec); - impl Cmd for $name { - fn name(&self) -> &'static str { - $cmd_name - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let args = &self.0; - #[allow(clippy::redundant_closure_call)] - ($exec)(args, ctx) - } - } - impl $name { - pub fn parse(args: &[String]) -> Result, String> { - #[allow(clippy::redundant_closure_call)] - ($parse)(args)?; - Ok(Box::new($name(args.to_vec()))) - } - } - }; -} - -effect_cmd!( - AddCategoryCmd, - "add-category", - |args: &[String]| require_args("add-category", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::AddCategory(args[0].clone()))] - } -); - -effect_cmd!( - AddItemCmd, - "add-item", - |args: &[String]| require_args("add-item", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::AddItem { - category: args[0].clone(), - item: args[1].clone(), - })] - } -); - -effect_cmd!( - AddItemsCmd, - "add-items", - |args: &[String]| { - if args.len() < 2 { - Err("add-items requires a category and at least one item".to_string()) - } else { - Ok(()) - } - }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - let category = &args[0]; - args[1..] - .iter() - .map(|item| -> Box { - Box::new(effect::AddItem { - category: category.clone(), - item: item.clone(), - }) - }) - .collect() - } -); - -effect_cmd!( - AddItemInGroupCmd, - "add-item-in-group", - |args: &[String]| require_args("add-item-in-group", args, 3), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::AddItemInGroup { - category: args[0].clone(), - item: args[1].clone(), - group: args[2].clone(), - })] - } -); - -effect_cmd!( - SetCellCmd, - "set-cell", - |args: &[String]| { - if args.len() < 2 { - Err("set-cell requires a value and at least one Cat/Item coordinate".to_string()) - } else { - Ok(()) - } - }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - let value = if let Ok(n) = args[0].parse::() { - CellValue::Number(n) - } else { - CellValue::Text(args[0].clone()) - }; - let coords: Vec<(String, String)> = args[1..] - .iter() - .filter_map(|s| { - let (cat, item) = s.split_once('/')?; - Some((cat.to_string(), item.to_string())) - }) - .collect(); - let key = crate::model::cell::CellKey::new(coords); - vec![Box::new(effect::SetCell(key, value))] - } -); - -effect_cmd!( - AddFormulaCmd, - "add-formula", - |args: &[String]| require_args("add-formula", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::AddFormula { - target_category: args[0].clone(), - raw: args[1].clone(), - })] - } -); - -effect_cmd!( - RemoveFormulaCmd, - "remove-formula", - |args: &[String]| require_args("remove-formula", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::RemoveFormula { - target_category: args[0].clone(), - target: args[1].clone(), - })] - } -); - -effect_cmd!( - CreateViewCmd, - "create-view", - |args: &[String]| require_args("create-view", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::CreateView(args[0].clone()))] - } -); - -effect_cmd!( - DeleteViewCmd, - "delete-view", - |args: &[String]| require_args("delete-view", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::DeleteView(args[0].clone()))] - } -); - -effect_cmd!( - SwitchViewCmd, - "switch-view", - |args: &[String]| require_args("switch-view", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::SwitchView(args[0].clone()))] - } -); - -effect_cmd!( - SetAxisCmd, - "set-axis", - |args: &[String]| require_args("set-axis", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - let axis = match args[1].to_lowercase().as_str() { - "row" => Axis::Row, - "column" | "col" => Axis::Column, - "page" => Axis::Page, - "none" => Axis::None, - _ => return vec![], // parse step already validated - }; - vec![Box::new(effect::SetAxis { - category: args[0].clone(), - axis, - })] - } -); - -effect_cmd!( - SetPageCmd, - "set-page", - |args: &[String]| require_args("set-page", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::SetPageSelection { - category: args[0].clone(), - item: args[1].clone(), - })] - } -); - -effect_cmd!( - ToggleGroupCmd, - "toggle-group", - |args: &[String]| require_args("toggle-group", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ToggleGroup { - category: args[0].clone(), - group: args[1].clone(), - })] - } -); - -effect_cmd!( - HideItemCmd, - "hide-item", - |args: &[String]| require_args("hide-item", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::HideItem { - category: args[0].clone(), - item: args[1].clone(), - })] - } -); - -effect_cmd!( - ShowItemCmd, - "show-item", - |args: &[String]| require_args("show-item", args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ShowItem { - category: args[0].clone(), - item: args[1].clone(), - })] - } -); - -effect_cmd!( - SaveAsCmd, - "save-as", - |args: &[String]| require_args("save-as", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))] - } -); - -effect_cmd!( - SetFormatCmd, - "set-format", - |args: &[String]| require_args("set-format", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![ - Box::new(effect::SetNumberFormat(args.join(" "))), - effect::mark_dirty(), - ] - } -); - -effect_cmd!( - ImportCmd, - "import", - |args: &[String]| require_args("import", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::StartImportWizard(args[0].clone()))] - } -); - -effect_cmd!( - ExportCmd, - "export", - |_args: &[String]| -> Result<(), String> { Ok(()) }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv"); - vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))] - } -); - -effect_cmd!( - WriteCmd, - "w", - |_args: &[String]| -> Result<(), String> { Ok(()) }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - if args.is_empty() { - vec![Box::new(effect::Save)] - } else { - vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))] - } - } -); - -effect_cmd!( - HelpCmd, - "help", - |_args: &[String]| -> Result<(), String> { Ok(()) }, - |_args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![effect::help_page_set(0), effect::change_mode(AppMode::Help)] - } -); - -effect_cmd!( - HelpPageNextCmd, - "help-page-next", - |_args: &[String]| -> Result<(), String> { Ok(()) }, - |_args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![effect::help_page_next()] - } -); - -effect_cmd!( - HelpPagePrevCmd, - "help-page-prev", - |_args: &[String]| -> Result<(), String> { Ok(()) }, - |_args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![effect::help_page_prev()] - } -); - -effect_cmd!( - LoadModelCmd, - "load", - |args: &[String]| require_args("load", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::LoadModel(std::path::PathBuf::from( - &args[0], - )))] - } -); - -effect_cmd!( - ExportCsvCmd, - "export-csv", - |args: &[String]| require_args("export-csv", args, 1), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ExportCsv(std::path::PathBuf::from( - &args[0], - )))] - } -); - -effect_cmd!( - ImportJsonCmd, - "import-json", - |args: &[String]| { - if args.is_empty() { - Err("import-json requires a path".to_string()) - } else { - Ok(()) - } - }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ImportJsonHeadless { - path: std::path::PathBuf::from(&args[0]), - model_name: args.get(1).cloned(), - array_path: args.get(2).cloned(), - })] - } -); - -/// Build the default command registry with all commands. -/// Registry names MUST match the `Cmd::name()` return value. -pub fn default_registry() -> CmdRegistry { - let mut r = CmdRegistry::new(); - - // ── Model mutations (effect_cmd! wrappers) ─────────────────────────── - r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse); - r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse); - r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse); - r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse); - r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse); - r.register( - &ClearCellCommand { - key: CellKey::new(vec![]), - }, - |args| { - if args.is_empty() { - return Err("clear-cell requires at least one Cat/Item coordinate".into()); - } - Ok(Box::new(ClearCellCommand { - key: parse_cell_key_from_args(args), - })) - }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(ClearCellCommand { key })) - }, - ); - r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse); - r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse); - r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse); - r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse); - r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse); - r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse); - r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse); - r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse); - r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse); - r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse); - r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse); - r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse); - r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse); - r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse); - r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse); - r.register_pure(&ImportCmd(vec![]), ImportCmd::parse); - r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); - r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); - r.register_pure(&HelpCmd(vec![]), HelpCmd::parse); - r.register( - &HelpPageNextCmd(vec![]), - HelpPageNextCmd::parse, - |_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))), - ); - r.register( - &HelpPagePrevCmd(vec![]), - HelpPagePrevCmd::parse, - |_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))), - ); - - // ── Navigation (unified Move) ────────────────────────────────────── - r.register( - &Move { - kind: MoveKind::Relative(0, 0), - cursor: CursorState::default(), - cmd_name: "move-selection", - }, - |args| { - require_args("move-selection", args, 2)?; - let dr = args[0].parse::().map_err(|e| e.to_string())?; - let dc = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(dr, dc), - cursor: CursorState::default(), - cmd_name: "move-selection", - })) - }, - |args, ctx| { - require_args("move-selection", args, 2)?; - let dr = args[0].parse::().map_err(|e| e.to_string())?; - let dc = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(dr, dc), - cursor: CursorState::from_ctx(ctx), - cmd_name: "move-selection", - })) - }, - ); - // Jump-to-edge commands: first/last row/col - macro_rules! reg_jump { - ($r:expr, $is_row:expr, $to_end:expr, $name:expr) => { - $r.register( - &Move { - kind: if $to_end { - MoveKind::ToEnd($is_row) - } else { - MoveKind::ToStart($is_row) - }, - cursor: CursorState::default(), - cmd_name: $name, - }, - |_| { - Ok(Box::new(Move { - kind: if $to_end { - MoveKind::ToEnd($is_row) - } else { - MoveKind::ToStart($is_row) - }, - cursor: CursorState::default(), - cmd_name: $name, - })) - }, - |_, ctx| { - Ok(Box::new(Move { - kind: if $to_end { - MoveKind::ToEnd($is_row) - } else { - MoveKind::ToStart($is_row) - }, - cursor: CursorState::from_ctx(ctx), - cmd_name: $name, - })) - }, - ); - }; - } - reg_jump!(r, true, false, "jump-first-row"); - reg_jump!(r, true, true, "jump-last-row"); - reg_jump!(r, false, false, "jump-first-col"); - reg_jump!(r, false, true, "jump-last-col"); - r.register( - &Move { - kind: MoveKind::Relative(0, 0), - cursor: CursorState::default(), - cmd_name: "scroll-rows", - }, - |args| { - require_args("scroll-rows", args, 1)?; - let n = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(n, 0), - cursor: CursorState::default(), - cmd_name: "scroll-rows", - })) - }, - |args, ctx| { - require_args("scroll-rows", args, 1)?; - let n = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(n, 0), - cursor: CursorState::from_ctx(ctx), - cmd_name: "scroll-rows", - })) - }, - ); - r.register( - &Move { - kind: MoveKind::Page(0), - cursor: CursorState::default(), - cmd_name: "page-scroll", - }, - |args| { - require_args("page-scroll", args, 1)?; - let dir = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Page(dir), - cursor: CursorState::default(), - cmd_name: "page-scroll", - })) - }, - |args, ctx| { - require_args("page-scroll", args, 1)?; - let dir = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Page(dir), - cursor: CursorState::from_ctx(ctx), - cmd_name: "page-scroll", - })) - }, - ); - r.register( - &EnterAdvance { - cursor: CursorState::default(), - }, - |_| { - Ok(Box::new(EnterAdvance { - cursor: CursorState { - row: 0, - col: 0, - row_count: 0, - col_count: 0, - row_offset: 0, - col_offset: 0, - visible_rows: 20, - visible_cols: 8, - }, - })) - }, - |_, ctx| { - Ok(Box::new(EnterAdvance { - cursor: CursorState::from_ctx(ctx), - })) - }, - ); - - // ── Cell operations ────────────────────────────────────────────────── - r.register( - &YankCell { - key: CellKey::new(vec![]), - }, - |args| { - if args.is_empty() { - return Err("yank requires at least one Cat/Item coordinate".into()); - } - Ok(Box::new(YankCell { - key: parse_cell_key_from_args(args), - })) - }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(YankCell { key })) - }, - ); - r.register( - &PasteCell { - key: CellKey::new(vec![]), - }, - |args| { - if args.is_empty() { - return Err("paste requires at least one Cat/Item coordinate".into()); - } - Ok(Box::new(PasteCell { - key: parse_cell_key_from_args(args), - })) - }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(PasteCell { key })) - }, - ); - // clear-cell is registered above (unified: ctx.cell_key() or explicit coords) - - // ── View / page ────────────────────────────────────────────────────── - r.register_nullary(|| Box::new(TransposeAxes)); - r.register_nullary(|| Box::new(PageNext)); - r.register_nullary(|| Box::new(PagePrev)); - - // ── Mode changes ───────────────────────────────────────────────────── - r.register_nullary(|| Box::new(ForceQuit)); - r.register_nullary(|| Box::new(Quit)); - r.register_nullary(|| Box::new(SaveAndQuit)); - r.register_nullary(|| Box::new(SaveCmd)); - r.register_nullary(|| Box::new(EnterSearchMode)); - r.register( - &EnterEditMode { - initial_value: String::new(), - }, - |args| { - let val = args.first().cloned().unwrap_or_default(); - Ok(Box::new(EnterEditMode { initial_value: val })) - }, - |_args, ctx| { - Ok(Box::new(EnterEditMode { - initial_value: ctx.display_value.clone(), - })) - }, - ); - r.register_nullary(|| Box::new(EditOrDrill)); - r.register_nullary(|| Box::new(EnterEditAtCursorCmd)); - r.register_nullary(|| Box::new(EnterExportPrompt)); - r.register_nullary(|| Box::new(EnterFormulaEdit)); - r.register_nullary(|| Box::new(EnterTileSelect)); - r.register( - &DrillIntoCell { - key: crate::model::cell::CellKey::new(vec![]), - }, - |args| { - if args.is_empty() { - return Err("drill-into-cell requires Cat/Item coordinates".into()); - } - Ok(Box::new(DrillIntoCell { - key: parse_cell_key_from_args(args), - })) - }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(DrillIntoCell { key })) - }, - ); - r.register_nullary(|| Box::new(ViewNavigate { forward: false })); - r.register( - &ViewNavigate { forward: true }, - |_| Ok(Box::new(ViewNavigate { forward: true })), - |_, _| Ok(Box::new(ViewNavigate { forward: true })), - ); - r.register_pure(&NamedCmd("enter-mode"), |args| { - require_args("enter-mode", args, 1)?; - let mode = match args[0].as_str() { - "normal" => AppMode::Normal, - "help" => AppMode::Help, - "formula-panel" => AppMode::FormulaPanel, - "category-panel" => AppMode::CategoryPanel, - "view-panel" => AppMode::ViewPanel, - "tile-select" => AppMode::TileSelect, - "command" => AppMode::command_mode(), - "category-add" => AppMode::category_add(), - "editing" => AppMode::editing(), - "formula-edit" => AppMode::formula_edit(), - "export-prompt" => AppMode::export_prompt(), - other => return Err(format!("Unknown mode: {other}")), - }; - Ok(Box::new(EnterMode(mode))) - }); - - // ── Search ─────────────────────────────────────────────────────────── - r.register_pure(&NamedCmd("search-navigate"), |args| { - let forward = args.first().map(|s| s != "backward").unwrap_or(true); - Ok(Box::new(SearchNavigate(forward))) - }); - r.register_nullary(|| Box::new(SearchOrCategoryAdd)); - r.register_nullary(|| Box::new(ExitSearchMode)); - - // ── Panel operations ───────────────────────────────────────────────── - r.register( - &TogglePanelAndFocus { - panel: Panel::Formula, - open: true, - focused: true, - }, - |args| { - // Parse: toggle-panel-and-focus [open] [focused] - require_args("toggle-panel-and-focus", args, 1)?; - let panel = parse_panel(&args[0])?; - let open = args.get(1).map(|s| s == "true").unwrap_or(true); - let focused = args.get(2).map(|s| s == "true").unwrap_or(open); - Ok(Box::new(TogglePanelAndFocus { - panel, - open, - focused, - })) - }, - |args, ctx| { - require_args("toggle-panel-and-focus", args, 1)?; - let panel = parse_panel(&args[0])?; - // Default interactive: if already open+focused → close, else open+focus - let currently_open = match panel { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; - let currently_focused = match panel { - Panel::Formula => matches!( - ctx.mode, - AppMode::FormulaPanel | AppMode::FormulaEdit { .. } - ), - Panel::Category => matches!( - ctx.mode, - AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. } - ), - Panel::View => matches!(ctx.mode, AppMode::ViewPanel), - }; - let (open, focused) = if currently_open && currently_focused { - (false, false) // close - } else { - (true, true) // open + focus - }; - Ok(Box::new(TogglePanelAndFocus { - panel, - open, - focused, - })) - }, - ); - r.register( - &TogglePanelVisibility { - panel: Panel::Formula, - currently_open: false, - }, - |args| { - require_args("toggle-panel-visibility", args, 1)?; - let panel = parse_panel(&args[0])?; - Ok(Box::new(TogglePanelVisibility { - panel, - currently_open: false, - })) - }, - |args, ctx| { - require_args("toggle-panel-visibility", args, 1)?; - let panel = parse_panel(&args[0])?; - let currently_open = match panel { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; - Ok(Box::new(TogglePanelVisibility { - panel, - currently_open, - })) - }, - ); - r.register( - &CyclePanelFocus { - formula_open: false, - category_open: false, - view_open: false, - }, - |_| { - Ok(Box::new(CyclePanelFocus { - formula_open: false, - category_open: false, - view_open: false, - })) - }, - |_, ctx| { - Ok(Box::new(CyclePanelFocus { - formula_open: ctx.formula_panel_open, - category_open: ctx.category_panel_open, - view_open: ctx.view_panel_open, - })) - }, - ); - r.register( - &MovePanelCursor { - panel: Panel::Formula, - delta: 0, - current: 0, - max: 0, - }, - |args| { - require_args("move-panel-cursor", args, 2)?; - let panel = parse_panel(&args[0])?; - let delta = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(MovePanelCursor { - panel, - delta, - current: 0, - max: 0, - })) - }, - |args, ctx| { - require_args("move-panel-cursor", args, 2)?; - let panel = parse_panel(&args[0])?; - let delta = args[1].parse::().map_err(|e| e.to_string())?; - let (current, max) = match panel { - Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()), - Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()), - Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()), - }; - Ok(Box::new(MovePanelCursor { - panel, - delta, - current, - max, - })) - }, - ); - r.register_nullary(|| Box::new(DeleteFormulaAtCursor)); - r.register_nullary(|| Box::new(AddRecordRow)); - r.register_nullary(|| Box::new(TogglePruneEmpty)); - r.register_nullary(|| Box::new(ToggleRecordsMode)); - r.register_nullary(|| Box::new(CycleAxisAtCursor)); - r.register_nullary(|| Box::new(OpenItemAddAtCursor)); - r.register_nullary(|| Box::new(DeleteCategoryAtCursor)); - r.register_nullary(|| Box::new(ToggleCatExpand)); - r.register_nullary(|| Box::new(FilterToItem)); - r.register_nullary(|| Box::new(SwitchViewAtCursor)); - r.register_nullary(|| Box::new(CreateAndSwitchView)); - r.register_nullary(|| Box::new(DeleteViewAtCursor)); - - // ── Tile select ────────────────────────────────────────────────────── - r.register_pure(&NamedCmd("move-tile-cursor"), |args| { - require_args("move-tile-cursor", args, 1)?; - let delta = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(MoveTileCursor(delta))) - }); - r.register_nullary(|| Box::new(TileAxisOp { axis: None })); - r.register_pure(&NamedCmd("set-axis-for-tile"), |args| { - require_args("set-axis-for-tile", args, 1)?; - let axis = parse_axis(&args[0])?; - Ok(Box::new(TileAxisOp { axis: Some(axis) })) - }); - - // ── Grid operations ────────────────────────────────────────────────── - r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true })); - r.register( - &ToggleGroupAtCursor { is_row: false }, - |_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), - |_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), - ); - r.register_nullary(|| Box::new(HideSelectedRowItem)); - - // ── Text buffer ────────────────────────────────────────────────────── - r.register_pure(&NamedCmd("append-char"), |args| { - require_args("append-char", args, 1)?; - Ok(Box::new(AppendChar { - buffer: args[0].clone(), - })) - }); - r.register_pure(&NamedCmd("pop-char"), |args| { - require_args("pop-char", args, 1)?; - Ok(Box::new(PopChar { - buffer: args[0].clone(), - })) - }); - r.register_nullary(|| Box::new(CommandModeBackspace)); - - // ── Commit ─────────────────────────────────────────────────────────── - r.register( - &CommitAndAdvance { - key: CellKey::new(vec![]), - value: String::new(), - advance: AdvanceDir::Down, - cursor: CursorState::default(), - }, - |args| { - if args.len() < 2 { - return Err("commit-cell-edit requires a value and coords".into()); - } - Ok(Box::new(CommitAndAdvance { - key: parse_cell_key_from_args(&args[1..]), - value: args[0].clone(), - advance: AdvanceDir::Down, - cursor: CursorState::default(), - })) - }, - |_args, ctx| { - let value = read_buffer(ctx, "edit"); - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(CommitAndAdvance { - key, - value, - advance: AdvanceDir::Down, - cursor: CursorState::from_ctx(ctx), - })) - }, - ); - r.register( - &CommitAndAdvance { - key: CellKey::new(vec![]), - value: String::new(), - advance: AdvanceDir::Right, - cursor: CursorState::default(), - }, - |_| Err("commit-and-advance-right requires context".into()), - |_args, ctx| { - let value = read_buffer(ctx, "edit"); - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(CommitAndAdvance { - key, - value, - advance: AdvanceDir::Right, - cursor: CursorState::from_ctx(ctx), - })) - }, - ); - r.register_nullary(|| Box::new(CommitFormula)); - r.register_nullary(|| Box::new(CommitCategoryAdd)); - r.register_nullary(|| Box::new(CommitItemAdd)); - r.register_nullary(|| Box::new(CommitExport)); - r.register_nullary(|| Box::new(ExecuteCommand)); - - // ── Wizard ─────────────────────────────────────────────────────────── - r.register_nullary(|| Box::new(HandleWizardKey)); - - // ── Aliases (short names for common commands) ──────────────────────── - r.alias("add-cat", "add-category"); - r.alias("formula", "add-formula"); - r.alias("add-view", "create-view"); - r.alias("q!", "force-quit"); - - r -} - -fn parse_panel(s: &str) -> Result { - match s { - "formula" => Ok(Panel::Formula), - "category" => Ok(Panel::Category), - "view" => Ok(Panel::View), - other => Err(format!("Unknown panel: {other}")), - } -} - -fn parse_axis(s: &str) -> Result { - match s.to_lowercase().as_str() { - "row" => Ok(Axis::Row), - "column" | "col" => Ok(Axis::Column), - "page" => Ok(Axis::Page), - "none" => Ok(Axis::None), - other => Err(format!("Unknown axis: {other}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::cell::{CellKey, CellValue}; - use crate::model::Model; - - static EMPTY_BUFFERS: std::sync::LazyLock> = - std::sync::LazyLock::new(HashMap::new); - static EMPTY_EXPANDED: std::sync::LazyLock> = - std::sync::LazyLock::new(std::collections::HashSet::new); - - fn make_layout(model: &Model) -> GridLayout { - GridLayout::new(model, model.active_view()) - } - - fn make_ctx<'a>( - model: &'a Model, - layout: &'a GridLayout, - registry: &'a CmdRegistry, - ) -> CmdContext<'a> { - let view = model.active_view(); - let (sr, sc) = view.selected; - CmdContext { - model, - layout, - registry, - mode: &AppMode::Normal, - selected: view.selected, - row_offset: view.row_offset, - col_offset: view.col_offset, - search_query: "", - yanked: &None, - dirty: false, - search_mode: false, - formula_panel_open: false, - category_panel_open: false, - view_panel_open: false, - formula_cursor: 0, - cat_panel_cursor: 0, - view_panel_cursor: 0, - tile_cat_idx: 0, - buffers: &EMPTY_BUFFERS, - view_back_stack: &[], - view_forward_stack: &[], - display_value: { - let key = layout.cell_key(sr, sc); - key.as_ref() - .and_then(|k| model.get_cell(k).cloned()) - .map(|v| v.to_string()) - .unwrap_or_default() - }, - visible_rows: 20, - visible_cols: 8, - expanded_cats: &EMPTY_EXPANDED, - key_code: KeyCode::Null, - } - } - - fn two_cat_model() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m - } - - #[test] - fn move_selection_down_produces_set_selected() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::Relative(1, 0), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - }; - let effects = cmd.execute(&ctx); - // Should produce at least SetSelected - assert!(!effects.is_empty()); - } - - #[test] - fn move_selection_clamps_to_bounds() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // Try to move way past the end - let cmd = Move { - kind: MoveKind::Relative(100, 100), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - } - - #[test] - fn quit_when_dirty_shows_warning() { - let m = two_cat_model(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "q".to_string()); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.dirty = true; - ctx.buffers = &bufs; - let cmd = ExecuteCommand; - let effects = cmd.execute(&ctx); - let dbg = format!("{:?}", effects); - assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}"); - } - - #[test] - fn quit_when_clean_produces_quit_mode() { - let m = two_cat_model(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "q".to_string()); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let cmd = ExecuteCommand; - let effects = cmd.execute(&ctx); - assert!( - effects.iter().any(|e| e.changes_mode()), - "Expected a mode-changing effect" - ); - } - - #[test] - fn clear_selected_cell_produces_clear_and_dirty() { - let mut m = two_cat_model(); - let key = CellKey::new(vec![ - ("Type".to_string(), "Food".to_string()), - ("Month".to_string(), "Jan".to_string()), - ]); - m.set_cell(key, CellValue::Number(42.0)); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ClearCellCommand { - key: ctx.cell_key().clone().unwrap(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // ClearCell + MarkDirty - } - - #[test] - fn yank_cell_produces_set_yanked() { - let mut m = two_cat_model(); - let key = CellKey::new(vec![ - ("Type".to_string(), "Food".to_string()), - ("Month".to_string(), "Jan".to_string()), - ]); - m.set_cell(key, CellValue::Number(99.0)); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = YankCell { - key: ctx.cell_key().clone().unwrap(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetYanked + SetStatus - } - - #[test] - fn toggle_panel_open_and_focus() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TogglePanelAndFocus { - panel: effect::Panel::Formula, - open: true, - focused: true, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("FormulaPanel"), - "Expected FormulaPanel mode, got: {dbg}" - ); - } - - #[test] - fn toggle_panel_close_and_unfocus() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TogglePanelAndFocus { - panel: effect::Panel::Formula, - open: false, - focused: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal) - } - - #[test] - fn enter_advance_moves_down() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterAdvance { - cursor: CursorState::from_ctx(&ctx), - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = format!("{:?}", effects[0]); - assert!( - dbg.contains("SetSelected(1, 0)"), - "Expected row 1, got: {dbg}" - ); - } - - #[test] - fn search_navigate_with_empty_query_returns_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SearchNavigate(true); - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); - } - - #[test] - fn enter_edit_mode_produces_editing_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterEditMode { - initial_value: String::new(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetBuffer + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); - } - - #[test] - fn enter_tile_select_with_categories() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterTileSelect; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("TileSelect"), - "Expected TileSelect mode, got: {dbg}" - ); - } - - #[test] - fn enter_tile_select_no_categories() { - // Models always have virtual categories (_Index, _Dim), so tile - // select always has something to operate on. - let m = Model::new("Empty"); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterTileSelect; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode - } - - #[test] - fn toggle_group_under_cursor_returns_empty_without_groups() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ToggleGroupAtCursor { is_row: true }; - let effects = cmd.execute(&ctx); - // No groups defined, so nothing to toggle - assert!(effects.is_empty()); - } - - #[test] - fn search_or_category_add_without_query_opens_category_add() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SearchOrCategoryAdd; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("CategoryAdd"), - "Expected CategoryAdd, got: {dbg}" - ); - } - - #[test] - fn cycle_panel_focus_with_no_panels_open() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = CyclePanelFocus { - formula_open: false, - category_open: false, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); - } - - #[test] - fn cycle_panel_focus_with_formula_panel_open() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.formula_panel_open = true; - let cmd = CyclePanelFocus { - formula_open: true, - category_open: false, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = format!("{:?}", effects[0]); - assert!( - dbg.contains("FormulaPanel"), - "Expected FormulaPanel, got: {dbg}" - ); - } - - // ── Algebraic law tests ────────────────────────────────────────────── - - fn effects_debug(effects: &[Box]) -> String { - format!("{:?}", effects) - } - - /// Law: navigation idempotence — ToStart applied twice produces the same - /// effects as applied once. - #[test] - fn law_move_to_start_idempotent() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::ToStart(true), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-first-row", - }; - let first = effects_debug(&cmd.execute(&ctx)); - // After the first move, cursor is at row 0. Simulate that state. - let cmd2 = Move { - kind: MoveKind::ToStart(true), - cursor: CursorState { - row: 0, - ..CursorState::from_ctx(&ctx) - }, - cmd_name: "jump-first-row", - }; - let second = effects_debug(&cmd2.execute(&ctx)); - assert_eq!(first, second, "ToStart(Row) should be idempotent"); - } - - /// Law: toggle involution — toggling a group twice yields the same effects - /// (both are ToggleGroup + MarkDirty, regardless of current state). - #[test] - fn law_toggle_group_involution() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ToggleGroupAtCursor { is_row: true }; - let first = effects_debug(&cmd.execute(&ctx)); - let second = effects_debug(&cmd.execute(&ctx)); - // Both calls produce the same structural effects (the group lookup - // returns None in both cases since there are no groups, so both are - // empty — which is still an involution). - assert_eq!(first, second, "Toggle should be structurally consistent"); - } - - /// Law: sequence associativity — concatenating effect vectors is associative. - /// This is structural (Vec::extend is associative), but we verify it. - #[test] - fn law_sequence_associativity() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - - let mk_a = || { - Move { - kind: MoveKind::Relative(1, 0), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - } - .execute(&ctx) - }; - let mk_b = || { - Move { - kind: MoveKind::Relative(0, 1), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - } - .execute(&ctx) - }; - let mk_c = || { - Move { - kind: MoveKind::ToStart(true), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-first-row", - } - .execute(&ctx) - }; - - // (a ++ b) ++ c - let mut ab_c = mk_a(); - ab_c.extend(mk_b()); - ab_c.extend(mk_c()); - - // a ++ (b ++ c) - let mut bc = mk_b(); - bc.extend(mk_c()); - let mut a_bc = mk_a(); - a_bc.extend(bc); - - assert_eq!( - effects_debug(&ab_c), - effects_debug(&a_bc), - "Sequence concatenation should be associative" - ); - } - - /// Law: MoveKind::ToEnd(col) reaches the last column. - #[test] - fn law_move_to_end_reaches_last_col() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::ToEnd(false), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-last-col", - }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - let expected_col = ctx.col_count().saturating_sub(1); - assert!( - dbg.contains(&format!("SetSelected(0, {expected_col})")), - "Expected jump to last col {expected_col}, got: {dbg}" - ); - } - - // ── Paste command ────────────────────────────────────────────────── - - #[test] - fn paste_with_yanked_value_produces_set_cell() { - let mut m = two_cat_model(); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]), - CellValue::Number(42.0), - ); - let layout = make_layout(&m); - let reg = default_registry(); - let yanked = Some(CellValue::Number(99.0)); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.yanked = &yanked; - let key = CellKey::new(vec![ - ("Type".into(), "Clothing".into()), - ("Month".into(), "Feb".into()), - ]); - let cmd = PasteCell { key }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetCell + MarkDirty - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); - } - - #[test] - fn paste_without_yanked_value_produces_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let key = CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]); - let cmd = PasteCell { key }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); - } - - // ── Transpose ────────────────────────────────────────────────────── - - #[test] - fn transpose_produces_transpose_and_dirty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = TransposeAxes.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("TransposeAxes"), - "Expected TransposeAxes, got: {dbg}" - ); - } - - // ── View navigation ──────────────────────────────────────────────── - - #[test] - fn view_forward_with_empty_stack_shows_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ViewNavigate { forward: true }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("No forward view"), - "Expected status message, got: {dbg}" - ); - } - - #[test] - fn view_back_with_empty_stack_shows_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ViewNavigate { forward: false }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("No previous view"), - "Expected status message, got: {dbg}" - ); - } - - #[test] - fn view_forward_with_stack_produces_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let fwd_stack = vec!["View 2".to_string()]; - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_forward_stack = &fwd_stack; - let cmd = ViewNavigate { forward: true }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ViewForward"), - "Expected ViewForward, got: {dbg}" - ); - } - - #[test] - fn view_back_with_stack_produces_apply_and_back() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let back_stack = vec!["Default".to_string()]; - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_back_stack = &back_stack; - let cmd = ViewNavigate { forward: false }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // ApplyAndClearDrill + ViewBack - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ApplyAndClearDrill"), - "Expected ApplyAndClearDrill, got: {dbg}" - ); - assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}"); - } - - // ── Panel cursor ─────────────────────────────────────────────────── - - #[test] - fn move_panel_cursor_down_from_zero() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: 1, - current: 0, - max: 5, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPanelCursor"), - "Expected SetPanelCursor, got: {dbg}" - ); - } - - #[test] - fn move_panel_cursor_clamps_at_zero() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: -1, - current: 0, - max: 5, - }; - let effects = cmd.execute(&ctx); - // Already at 0, can't go below → no effect - assert!(effects.is_empty()); - } - - #[test] - fn move_panel_cursor_with_zero_max_produces_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: 1, - current: 0, - max: 0, - }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); - } - - // ── Page navigation ──────────────────────────────────────────────── - - #[test] - fn page_next_with_no_page_cats_returns_empty() { - // Default two_cat_model has Type on Row, Month on Column, nothing on Page - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PageNext.execute(&ctx); - assert!(effects.is_empty()); - } - - #[test] - fn page_prev_with_no_page_cats_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PagePrev.execute(&ctx); - assert!(effects.is_empty()); - } - - fn three_cat_model_with_page() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m.category_mut("Region").unwrap().add_item("North"); - m.category_mut("Region").unwrap().add_item("South"); - m.category_mut("Region").unwrap().add_item("East"); - // Put Region on Page axis - let view = m.active_view_mut(); - view.set_axis("Region", crate::view::Axis::Page); - m - } - - #[test] - fn page_next_cycles_through_page_items() { - let m = three_cat_model_with_page(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PageNext.execute(&ctx); - // Should produce SetPageSelection effects - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" - ); - } - - #[test] - fn page_prev_cycles_backward() { - let m = three_cat_model_with_page(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PagePrev.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" - ); - } - - // ── Tile axis commands ───────────────────────────────────────────── - - #[test] - fn tile_axis_cycle_produces_cycle_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TileAxisOp { axis: None }; // cycle - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}"); - } - - #[test] - fn tile_axis_set_produces_set_axis_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TileAxisOp { - axis: Some(crate::view::Axis::Page), - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); - } - - #[test] - fn tile_axis_with_out_of_bounds_cursor_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.tile_cat_idx = 999; // way beyond - let cmd = TileAxisOp { axis: None }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); - } - - // ── Move tile cursor ─────────────────────────────────────────────── - - #[test] - fn move_tile_cursor_right() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MoveTileCursor(1); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetTileCatIdx(1)"), - "Expected idx 1, got: {dbg}" - ); - } - - #[test] - fn move_tile_cursor_clamps_at_start() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MoveTileCursor(-1); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetTileCatIdx(0)"), - "Expected clamped to 0, got: {dbg}" - ); - } - - // ── Commit formula ───────────────────────────────────────────────── - - #[test] - fn commit_formula_with_categories_adds_formula() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("formula".to_string(), "Profit = Revenue - Cost".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitFormula.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddFormula"), - "Expected AddFormula, got: {dbg}" - ); - assert!( - dbg.contains("FormulaPanel"), - "Expected return to FormulaPanel, got: {dbg}" - ); - } - - /// Regression: CommitFormula must not target virtual categories (_Index, _Dim) - /// when no regular categories exist. It should show "Add at least one category first." - #[test] - fn commit_formula_without_regular_categories_shows_status() { - let m = Model::new("Empty"); // only has virtual _Index, _Dim - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("formula".to_string(), "X = Y + Z".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitFormula.execute(&ctx); - let dbg = effects_debug(&effects); - // Should NOT produce AddFormula targeting a virtual category - assert!( - !dbg.contains("AddFormula"), - "Should not add formula when only virtual categories exist, got: {dbg}" - ); - assert!( - dbg.contains("Add at least one category first"), - "Expected status message, got: {dbg}" - ); - } - - // ── Commit category add ──────────────────────────────────────────── - - #[test] - fn commit_category_add_with_name_produces_add_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("category".to_string(), "Region".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitCategoryAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" - ); - } - - #[test] - fn commit_category_add_with_empty_buffer_returns_to_panel() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("category".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitCategoryAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("CategoryPanel"), - "Expected return to CategoryPanel, got: {dbg}" - ); - } - - // ── Commit item add ──────────────────────────────────────────────── - - #[test] - fn commit_item_add_with_name_produces_add_item() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("item".to_string(), "March".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - let item_add_mode = AppMode::item_add("Month".to_string()); - ctx.mode = &item_add_mode; - ctx.buffers = &bufs; - let effects = CommitItemAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}"); - } - - #[test] - fn commit_item_add_outside_item_add_mode_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // mode is Normal, not ItemAdd - let effects = CommitItemAdd.execute(&ctx); - assert!(effects.is_empty()); - } - - // ── Command mode backspace ───────────────────────────────────────── - - #[test] - fn command_mode_backspace_pops_char() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "hel".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommandModeBackspace.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetBuffer"), "Expected SetBuffer, got: {dbg}"); - // The buffer should become "he" (popped last char) - assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}"); - } - - #[test] - fn command_mode_backspace_on_empty_returns_to_normal() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommandModeBackspace.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("Normal"), - "Expected return to Normal, got: {dbg}" - ); - } - - // ── Execute command ──────────────────────────────────────────────── - - #[test] - fn execute_command_empty_returns_to_normal() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); - } - - #[test] - fn execute_command_invalid_shows_error_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "nonexistent-command".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - // Should show an error status AND return to Normal - assert!( - dbg.contains("Normal"), - "Expected Normal mode on error, got: {dbg}" - ); - } - - #[test] - fn execute_command_valid_runs_command() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "add-category Region".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory effect, got: {dbg}" - ); - } - - // ── Save command ─────────────────────────────────────────────────── - - #[test] - fn save_produces_save_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SaveCmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); - } - - // ── Enter search mode ────────────────────────────────────────────── - - #[test] - fn enter_search_mode_sets_flag_and_clears_query() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = EnterSearchMode.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSearchMode(true)"), - "Expected search mode on, got: {dbg}" - ); - assert!( - dbg.contains("SetSearchQuery"), - "Expected query reset, got: {dbg}" - ); - } - - #[test] - fn exit_search_mode_clears_flag() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = ExitSearchMode.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSearchMode(false)"), - "Expected search mode off, got: {dbg}" - ); - } - - // ── Search navigate with query finds match ───────────────────────── - - #[test] - fn search_navigate_forward_with_matching_value() { - let mut m = two_cat_model(); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]), - CellValue::Number(42.0), - ); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Clothing".into()), - ("Month".into(), "Feb".into()), - ]), - CellValue::Number(99.0), - ); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.search_query = "99"; - let cmd = SearchNavigate(true); - let effects = cmd.execute(&ctx); - // Should find the cell with 99 and navigate to it - if !effects.is_empty() { - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSelected"), - "Expected SetSelected, got: {dbg}" - ); - } - // If empty, the search didn't find it through layout — that's OK since - // layout coordinates may not map 1:1 with model cells in all cases. - } - - // ── Create and switch view ───────────────────────────────────────── - - #[test] - fn create_and_switch_view_names_incrementally() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = CreateAndSwitchView.execute(&ctx); - let dbg = effects_debug(&effects); - // Model starts with 1 view ("Default"), so new view should be "View 2" - assert!( - dbg.contains("CreateView"), - "Expected CreateView, got: {dbg}" - ); - assert!( - dbg.contains("SwitchView"), - "Expected SwitchView, got: {dbg}" - ); - assert!( - dbg.contains("Normal"), - "Expected return to Normal, got: {dbg}" - ); - } - - // ── Switch view at cursor ────────────────────────────────────────── - - #[test] - fn switch_view_at_cursor_with_valid_cursor() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SwitchViewAtCursor.execute(&ctx); - // cursor 0, model has "Default" view - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SwitchView"), - "Expected SwitchView, got: {dbg}" - ); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); - } - - #[test] - fn switch_view_at_cursor_out_of_bounds_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_panel_cursor = 999; - let effects = SwitchViewAtCursor.execute(&ctx); - assert!(effects.is_empty()); - } - - // ── Delete view at cursor ────────────────────────────────────────── - - #[test] - fn delete_view_at_cursor_zero_does_not_adjust_cursor() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = DeleteViewAtCursor.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("DeleteView"), - "Expected DeleteView, got: {dbg}" - ); - // At cursor 0, should NOT have SetPanelCursor (no cursor adjustment needed) - assert!( - !dbg.contains("SetPanelCursor"), - "Expected no cursor adjustment at position 0, got: {dbg}" - ); - } - - // ── Delete formula at cursor ─────────────────────────────────────── - - #[test] - fn delete_formula_at_cursor_with_formulas() { - let mut m = two_cat_model(); - m.add_formula(crate::formula::ast::Formula { - raw: "Profit = Revenue - Cost".to_string(), - target: "Profit".to_string(), - target_category: "Type".to_string(), - expr: crate::formula::ast::Expr::Number(0.0), - filter: None, - }); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = DeleteFormulaAtCursor.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("RemoveFormula"), - "Expected RemoveFormula, got: {dbg}" - ); - } - - // ── Commit export ────────────────────────────────────────────────── - - #[test] - fn commit_export_produces_export_and_normal_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("export".to_string(), "/tmp/test.csv".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitExport.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); - } - - // ── Force quit ───────────────────────────────────────────────────── - - #[test] - fn force_quit_always_produces_quit_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.dirty = true; // even when dirty - let effects = ForceQuit.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}"); - } - - // ── Save and quit ────────────────────────────────────────────────── - - #[test] - fn save_and_quit_produces_save_then_quit() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SaveAndQuit.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); - assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}"); - } - - // ── Enter export prompt ──────────────────────────────────────────── - - #[test] - fn enter_export_prompt_sets_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = EnterExportPrompt.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ExportPrompt"), - "Expected ExportPrompt mode, got: {dbg}" - ); - } - - // ── Toggle prune empty ───────────────────────────────────────────── - - #[test] - fn toggle_prune_empty_produces_toggle_and_dirty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = TogglePruneEmpty.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("TogglePruneEmpty"), - "Expected TogglePruneEmpty, got: {dbg}" - ); - } - - // ── Edit or drill ────────────────────────────────────────────────── - - #[test] - fn edit_or_drill_without_aggregation_enters_edit() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // All categories are on Row/Column, none on None → no aggregation → edit - let effects = EditOrDrill.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); - } - - // ── Cycle panel focus with multiple panels ───────────────────────── - - #[test] - fn cycle_panel_focus_with_multiple_panels() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.formula_panel_open = true; - ctx.category_panel_open = true; - let cmd = CyclePanelFocus { - formula_open: true, - category_open: true, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - // Should focus the first open panel (Formula) - assert!( - dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"), - "Expected panel focus, got: {dbg}" - ); - } - - // ── effect_cmd! macro tests ──────────────────────────────────────── - - #[test] - fn add_category_cmd_produces_add_category_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = AddCategoryCmd(vec!["Region".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" - ); - } - - #[test] - fn set_cell_cmd_parses_coords_correctly() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SetCellCmd(vec![ - "42".to_string(), - "Type/Food".to_string(), - "Month/Jan".to_string(), - ]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); - } - - #[test] - fn set_axis_cmd_recognizes_column_alias() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); - } - - #[test] - fn write_cmd_without_args_saves() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = WriteCmd(vec![]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); - } - - #[test] - fn write_cmd_with_path_saves_as() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}"); - } - - // ── parse_axis helper ────────────────────────────────────────────── - - #[test] - fn parse_axis_recognizes_all_variants() { - assert!(parse_axis("row").is_ok()); - assert!(parse_axis("column").is_ok()); - assert!(parse_axis("col").is_ok()); - assert!(parse_axis("page").is_ok()); - assert!(parse_axis("none").is_ok()); - assert!(parse_axis("ROW").is_ok()); // case insensitive - } - - #[test] - fn parse_axis_rejects_unknown() { - assert!(parse_axis("diagonal").is_err()); - } -} diff --git a/src/command/cmd/cell.rs b/src/command/cmd/cell.rs new file mode 100644 index 0000000..7fbaed6 --- /dev/null +++ b/src/command/cmd/cell.rs @@ -0,0 +1,87 @@ +use crate::ui::effect::{self, Effect}; + +use super::core::{Cmd, CmdContext}; + +// ── Cell operations ────────────────────────────────────────────────────────── +// All cell commands take an explicit CellKey. The interactive spec fills it +// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args. + +/// Clear a cell. +#[derive(Debug)] +pub struct ClearCellCommand { + pub key: crate::model::cell::CellKey, +} +impl Cmd for ClearCellCommand { + fn name(&self) -> &'static str { + "clear-cell" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::ClearCell(self.key.clone())), + effect::mark_dirty(), + ] + } +} + +/// Yank (copy) a cell value. +#[derive(Debug)] +pub struct YankCell { + pub key: crate::model::cell::CellKey, +} +impl Cmd for YankCell { + fn name(&self) -> &'static str { + "yank" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats()); + vec![ + Box::new(effect::SetYanked(value)), + effect::set_status("Yanked"), + ] + } +} + +/// Paste the yanked value into a cell. +#[derive(Debug)] +pub struct PasteCell { + pub key: crate::model::cell::CellKey, +} +impl Cmd for PasteCell { + fn name(&self) -> &'static str { + "paste" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let Some(value) = ctx.yanked.clone() { + vec![ + Box::new(effect::SetCell(self.key.clone(), value)), + effect::mark_dirty(), + ] + } else { + vec![] + } + } +} + +// ── View commands ──────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct TransposeAxes; +impl Cmd for TransposeAxes { + fn name(&self) -> &'static str { + "transpose" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::TransposeAxes), effect::mark_dirty()] + } +} + +#[derive(Debug)] +pub struct SaveCmd; +impl Cmd for SaveCmd { + fn name(&self) -> &'static str { + "save" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::Save)] + } +} diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs new file mode 100644 index 0000000..471f8ae --- /dev/null +++ b/src/command/cmd/commit.rs @@ -0,0 +1,203 @@ +use crate::model::cell::{CellKey, CellValue}; +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect}; + +use super::core::{Cmd, CmdContext}; +use super::navigation::{viewport_effects, CursorState, EnterAdvance}; + +// ── Commit commands (mode-specific buffer consumers) ──────────────────────── + +/// Commit a cell value: for synthetic records keys, stage in drill pending edits +/// or apply directly; for real keys, write to the model. +fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec>) { + if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) { + effects.push(Box::new(effect::SetDrillPendingEdit { + record_idx, + col_name, + new_value: value.to_string(), + })); + } else if value.is_empty() { + effects.push(Box::new(effect::ClearCell(key.clone()))); + effects.push(effect::mark_dirty()); + } else if let Ok(n) = value.parse::() { + effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n)))); + effects.push(effect::mark_dirty()); + } else { + effects.push(Box::new(effect::SetCell( + key.clone(), + CellValue::Text(value.to_string()), + ))); + effects.push(effect::mark_dirty()); + } +} + +/// Direction to advance after committing a cell edit. +#[derive(Debug, Clone, Copy)] +pub enum AdvanceDir { + /// Move down (typewriter-style, wraps to next column at bottom). + Down, + /// Move right (clamps at rightmost column). + Right, +} + +/// Commit a cell edit, advance the cursor, and re-enter edit mode. +/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right). +#[derive(Debug)] +pub struct CommitAndAdvance { + pub key: CellKey, + pub value: String, + pub advance: AdvanceDir, + pub cursor: CursorState, +} +impl Cmd for CommitAndAdvance { + fn name(&self) -> &'static str { + match self.advance { + AdvanceDir::Down => "commit-cell-edit", + AdvanceDir::Right => "commit-and-advance-right", + } + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let mut effects: Vec> = Vec::new(); + commit_cell_value(&self.key, &self.value, &mut effects); + match self.advance { + AdvanceDir::Down => { + let adv = EnterAdvance { + cursor: self.cursor.clone(), + }; + effects.extend(adv.execute(ctx)); + } + AdvanceDir::Right => { + let col_max = self.cursor.col_count.saturating_sub(1); + let nc = (self.cursor.col + 1).min(col_max); + effects.extend(viewport_effects( + self.cursor.row, + nc, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + )); + } + } + effects.push(Box::new(effect::EnterEditAtCursor)); + effects + } +} + +/// Commit a formula from the formula edit buffer. +#[derive(Debug)] +pub struct CommitFormula; +impl Cmd for CommitFormula { + fn name(&self) -> &'static str { + "commit-formula" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("formula").cloned().unwrap_or_default(); + let first_cat = ctx + .model + .regular_category_names() + .into_iter() + .next() + .map(String::from); + let mut effects: Vec> = Vec::new(); + if let Some(cat) = first_cat { + effects.push(Box::new(effect::AddFormula { + raw: buf, + target_category: cat, + })); + effects.push(effect::mark_dirty()); + effects.push(effect::set_status("Formula added")); + } else { + effects.push(effect::set_status("Add at least one category first.")); + } + effects.push(effect::change_mode(AppMode::FormulaPanel)); + effects + } +} + +/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty +/// + status + clear-buffer effects. If empty, return to CategoryPanel. +fn commit_add_from_buffer( + ctx: &CmdContext, + buffer_name: &str, + add_effect: impl FnOnce(&str) -> Option>, + status_msg: impl FnOnce(&str) -> String, +) -> Vec> { + let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default(); + let trimmed = buf.trim().to_string(); + if trimmed.is_empty() { + return vec![effect::change_mode(AppMode::CategoryPanel)]; + } + let Some(add) = add_effect(&trimmed) else { + return vec![]; + }; + vec![ + add, + effect::mark_dirty(), + effect::set_status(status_msg(&trimmed)), + Box::new(effect::SetBuffer { + name: buffer_name.to_string(), + value: String::new(), + }), + ] +} + +/// Commit adding a category, staying in CategoryAdd mode for the next entry. +#[derive(Debug)] +pub struct CommitCategoryAdd; +impl Cmd for CommitCategoryAdd { + fn name(&self) -> &'static str { + "commit-category-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + commit_add_from_buffer( + ctx, + "category", + |name| Some(Box::new(effect::AddCategory(name.to_string()))), + |name| format!("Added category \"{name}\""), + ) + } +} + +/// Commit adding an item, staying in ItemAdd mode for the next entry. +#[derive(Debug)] +pub struct CommitItemAdd; +impl Cmd for CommitItemAdd { + fn name(&self) -> &'static str { + "commit-item-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let category = if let AppMode::ItemAdd { category, .. } = ctx.mode { + category.clone() + } else { + return vec![]; + }; + commit_add_from_buffer( + ctx, + "item", + |name| { + Some(Box::new(effect::AddItem { + category: category.clone(), + item: name.to_string(), + })) + }, + |name| format!("Added \"{name}\""), + ) + } +} + +/// Commit an export from the export buffer. +#[derive(Debug)] +pub struct CommitExport; +impl Cmd for CommitExport { + fn name(&self) -> &'static str { + "commit-export" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let buf = ctx.buffers.get("export").cloned().unwrap_or_default(); + vec![ + Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))), + effect::change_mode(AppMode::Normal), + ] + } +} diff --git a/src/command/cmd/core.rs b/src/command/cmd/core.rs new file mode 100644 index 0000000..473fbc4 --- /dev/null +++ b/src/command/cmd/core.rs @@ -0,0 +1,277 @@ +use std::collections::HashMap; +use std::fmt::Debug; + +use crossterm::event::KeyCode; + +use crate::model::cell::{CellKey, CellValue}; +use crate::model::Model; +use crate::ui::app::AppMode; +use crate::ui::effect::{Effect, Panel}; +use crate::view::{Axis, GridLayout}; + +/// Read-only context available to commands for decision-making. +pub struct CmdContext<'a> { + pub model: &'a Model, + pub layout: &'a GridLayout, + pub registry: &'a CmdRegistry, + pub mode: &'a AppMode, + pub selected: (usize, usize), + pub row_offset: usize, + pub col_offset: usize, + pub search_query: &'a str, + pub yanked: &'a Option, + pub dirty: bool, + pub search_mode: bool, + pub formula_panel_open: bool, + pub category_panel_open: bool, + pub view_panel_open: bool, + /// Panel cursors + pub formula_cursor: usize, + pub cat_panel_cursor: usize, + pub view_panel_cursor: usize, + /// Tile select cursor (which category is selected) + pub tile_cat_idx: usize, + /// Named text buffers + pub buffers: &'a HashMap, + /// View navigation stacks (for drill back/forward) + pub view_back_stack: &'a [String], + pub view_forward_stack: &'a [String], + /// Display value at the cursor — works uniformly for pivot and records mode. + pub display_value: String, + /// How many data rows/cols fit on screen (for viewport scrolling). + pub visible_rows: usize, + pub visible_cols: usize, + /// Expanded categories in the tree panel + pub expanded_cats: &'a std::collections::HashSet, + /// The key that triggered this command + pub key_code: KeyCode, +} + +impl<'a> CmdContext<'a> { + pub fn cell_key(&self) -> Option { + self.layout.cell_key(self.selected.0, self.selected.1) + } + pub fn row_count(&self) -> usize { + self.layout.row_count() + } + pub fn col_count(&self) -> usize { + self.layout.col_count() + } + pub fn none_cats(&self) -> &[String] { + &self.layout.none_cats + } +} + +impl<'a> CmdContext<'a> { + /// Resolve the category panel tree entry at the current cursor. + pub fn cat_tree_entry(&self) -> Option { + let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats); + tree.into_iter().nth(self.cat_panel_cursor) + } + + /// The category name at the current tree cursor (whether on a + /// category header or an item). + pub fn cat_at_cursor(&self) -> Option { + self.cat_tree_entry().map(|e| e.cat_name().to_string()) + } + + /// Total number of entries in the category tree. + pub fn cat_tree_len(&self) -> usize { + crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len() + } +} + +/// A command that reads state and produces effects. +pub trait Cmd: Debug + Send + Sync { + fn execute(&self, ctx: &CmdContext) -> Vec>; + /// The canonical name of this command (matches its registry key). + /// Used by the parser tests and for introspection. + #[allow(dead_code)] + fn name(&self) -> &'static str; +} + +/// Factory that constructs a Cmd from text arguments (headless/script). +pub type ParseFn = fn(&[String]) -> Result, String>; + +/// Factory that constructs a Cmd from the interactive context (keymap dispatch). +/// Receives both the keymap args and the interactive context so commands can +/// combine text arguments (e.g. panel name) with runtime state (e.g. whether +/// the panel is currently open). +pub type InteractiveFn = fn(&[String], &CmdContext) -> Result, String>; + +type BoxParseFn = Box Result, String>>; +type BoxInteractiveFn = Box Result, String>>; + +/// A registered command entry with both text and interactive constructors. +struct CmdEntry { + name: &'static str, + parse: BoxParseFn, + interactive: BoxInteractiveFn, +} + +/// Registry of commands constructible from text or from interactive context. +#[derive(Default)] +pub struct CmdRegistry { + entries: Vec, + aliases: Vec<(&'static str, &'static str)>, +} + +impl CmdRegistry { + pub fn new() -> Self { + Self { + entries: Vec::new(), + aliases: Vec::new(), + } + } + + /// Register a short name that resolves to a canonical command name. + pub fn alias(&mut self, short: &'static str, canonical: &'static str) { + self.aliases.push((short, canonical)); + } + + /// Resolve a command name through the alias table. + fn resolve<'a>(&'a self, name: &'a str) -> &'a str { + for (alias, canonical) in &self.aliases { + if *alias == name { + return canonical; + } + } + name + } + + /// Register a command with both a text parser and an interactive constructor. + /// The name is derived from a prototype command instance. + pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) { + self.entries.push(CmdEntry { + name: prototype.name(), + parse: Box::new(parse), + interactive: Box::new(interactive), + }); + } + + /// Register a command that doesn't need interactive context. + /// When called interactively with args, delegates to parse. + /// When called interactively without args, returns an error. + pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) { + self.entries.push(CmdEntry { + name: prototype.name(), + parse: Box::new(parse), + interactive: Box::new(move |args, _ctx| { + if args.is_empty() { + Err("this command requires arguments".into()) + } else { + parse(args) + } + }), + }); + } + + /// Register a zero-arg command (same instance for parse and interactive). + /// The name is derived by calling `f()` once. + pub fn register_nullary(&mut self, f: fn() -> Box) { + let name = f().name(); + self.entries.push(CmdEntry { + name, + parse: Box::new(move |_| Ok(f())), + interactive: Box::new(move |_, _| Ok(f())), + }); + } + + /// Construct a command from text arguments (script/headless). + pub fn parse(&self, name: &str, args: &[String]) -> Result, String> { + let name = self.resolve(name); + for e in &self.entries { + if e.name == name { + return (e.parse)(args); + } + } + Err(format!("Unknown command: {name}")) + } + + /// Construct a command from interactive context (keymap dispatch). + /// Always calls the interactive constructor with both args and ctx, + /// so commands can combine text arguments with runtime state. + pub fn interactive( + &self, + name: &str, + args: &[String], + ctx: &CmdContext, + ) -> Result, String> { + let name = self.resolve(name); + for e in &self.entries { + if e.name == name { + return (e.interactive)(args, ctx); + } + } + Err(format!("Unknown command: {name}")) + } + + #[allow(dead_code)] + pub fn names(&self) -> impl Iterator + '_ { + self.entries.iter().map(|e| e.name) + } +} + +/// Dummy prototype used only for name extraction in registry calls +/// where the real command struct is built by a closure. +#[derive(Debug)] +pub(super) struct NamedCmd(pub(super) &'static str); +impl Cmd for NamedCmd { + fn name(&self) -> &'static str { + self.0 + } + fn execute(&self, _: &CmdContext) -> Vec> { + vec![] + } +} + +pub(super) fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> { + if args.len() < n { + Err(format!( + "{word} requires {n} argument(s), got {}", + args.len() + )) + } else { + Ok(()) + } +} + +/// Parse Cat/Item coordinate args into a CellKey. +pub(super) fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey { + let coords: Vec<(String, String)> = args + .iter() + .filter_map(|s| { + let (cat, item) = s.split_once('/')?; + Some((cat.to_string(), item.to_string())) + }) + .collect(); + crate::model::cell::CellKey::new(coords) +} + +/// Read the current value of a named buffer from context. +pub(super) fn read_buffer(ctx: &CmdContext, name: &str) -> String { + if name == "search" { + ctx.search_query.to_string() + } else { + ctx.buffers.get(name).cloned().unwrap_or_default() + } +} + +pub(super) fn parse_panel(s: &str) -> Result { + match s { + "formula" => Ok(Panel::Formula), + "category" => Ok(Panel::Category), + "view" => Ok(Panel::View), + other => Err(format!("Unknown panel: {other}")), + } +} + +pub(super) fn parse_axis(s: &str) -> Result { + match s.to_lowercase().as_str() { + "row" => Ok(Axis::Row), + "column" | "col" => Ok(Axis::Column), + "page" => Ok(Axis::Page), + "none" => Ok(Axis::None), + other => Err(format!("Unknown axis: {other}")), + } +} diff --git a/src/command/cmd/effect_cmds.rs b/src/command/cmd/effect_cmds.rs new file mode 100644 index 0000000..14b41bc --- /dev/null +++ b/src/command/cmd/effect_cmds.rs @@ -0,0 +1,359 @@ +use crate::model::cell::CellValue; +use crate::ui::effect::{self, Effect}; +use crate::view::Axis; + +use super::core::{require_args, Cmd, CmdContext}; + +// ── Parseable model-mutation commands ──────────────────────────────────────── +// These are thin Cmd wrappers around effects, constructible from string args. +// They share the same execution path as keymap-dispatched commands. + +macro_rules! effect_cmd { + ($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => { + #[derive(Debug)] + pub struct $name(pub Vec); + impl Cmd for $name { + fn name(&self) -> &'static str { + $cmd_name + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let args = &self.0; + #[allow(clippy::redundant_closure_call)] + ($exec)(args, ctx) + } + } + impl $name { + pub fn parse(args: &[String]) -> Result, String> { + #[allow(clippy::redundant_closure_call)] + ($parse)(args)?; + Ok(Box::new($name(args.to_vec()))) + } + } + }; +} + + +effect_cmd!( + AddCategoryCmd, + "add-category", + |args: &[String]| require_args("add-category", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::AddCategory(args[0].clone()))] + } +); + +effect_cmd!( + AddItemCmd, + "add-item", + |args: &[String]| require_args("add-item", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::AddItem { + category: args[0].clone(), + item: args[1].clone(), + })] + } +); + +effect_cmd!( + AddItemsCmd, + "add-items", + |args: &[String]| { + if args.len() < 2 { + Err("add-items requires a category and at least one item".to_string()) + } else { + Ok(()) + } + }, + |args: &Vec, _ctx: &CmdContext| -> Vec> { + let category = &args[0]; + args[1..] + .iter() + .map(|item| -> Box { + Box::new(effect::AddItem { + category: category.clone(), + item: item.clone(), + }) + }) + .collect() + } +); + +effect_cmd!( + AddItemInGroupCmd, + "add-item-in-group", + |args: &[String]| require_args("add-item-in-group", args, 3), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::AddItemInGroup { + category: args[0].clone(), + item: args[1].clone(), + group: args[2].clone(), + })] + } +); + +effect_cmd!( + SetCellCmd, + "set-cell", + |args: &[String]| { + if args.len() < 2 { + Err("set-cell requires a value and at least one Cat/Item coordinate".to_string()) + } else { + Ok(()) + } + }, + |args: &Vec, _ctx: &CmdContext| -> Vec> { + let value = if let Ok(n) = args[0].parse::() { + CellValue::Number(n) + } else { + CellValue::Text(args[0].clone()) + }; + let coords: Vec<(String, String)> = args[1..] + .iter() + .filter_map(|s| { + let (cat, item) = s.split_once('/')?; + Some((cat.to_string(), item.to_string())) + }) + .collect(); + let key = crate::model::cell::CellKey::new(coords); + vec![Box::new(effect::SetCell(key, value))] + } +); + +effect_cmd!( + AddFormulaCmd, + "add-formula", + |args: &[String]| require_args("add-formula", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::AddFormula { + target_category: args[0].clone(), + raw: args[1].clone(), + })] + } +); + +effect_cmd!( + RemoveFormulaCmd, + "remove-formula", + |args: &[String]| require_args("remove-formula", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::RemoveFormula { + target_category: args[0].clone(), + target: args[1].clone(), + })] + } +); + +effect_cmd!( + CreateViewCmd, + "create-view", + |args: &[String]| require_args("create-view", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::CreateView(args[0].clone()))] + } +); + +effect_cmd!( + DeleteViewCmd, + "delete-view", + |args: &[String]| require_args("delete-view", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::DeleteView(args[0].clone()))] + } +); + +effect_cmd!( + SwitchViewCmd, + "switch-view", + |args: &[String]| require_args("switch-view", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::SwitchView(args[0].clone()))] + } +); + +effect_cmd!( + SetAxisCmd, + "set-axis", + |args: &[String]| require_args("set-axis", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + let axis = match args[1].to_lowercase().as_str() { + "row" => Axis::Row, + "column" | "col" => Axis::Column, + "page" => Axis::Page, + "none" => Axis::None, + _ => return vec![], // parse step already validated + }; + vec![Box::new(effect::SetAxis { + category: args[0].clone(), + axis, + })] + } +); + +effect_cmd!( + SetPageCmd, + "set-page", + |args: &[String]| require_args("set-page", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::SetPageSelection { + category: args[0].clone(), + item: args[1].clone(), + })] + } +); + +effect_cmd!( + ToggleGroupCmd, + "toggle-group", + |args: &[String]| require_args("toggle-group", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::ToggleGroup { + category: args[0].clone(), + group: args[1].clone(), + })] + } +); + +effect_cmd!( + HideItemCmd, + "hide-item", + |args: &[String]| require_args("hide-item", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::HideItem { + category: args[0].clone(), + item: args[1].clone(), + })] + } +); + +effect_cmd!( + ShowItemCmd, + "show-item", + |args: &[String]| require_args("show-item", args, 2), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::ShowItem { + category: args[0].clone(), + item: args[1].clone(), + })] + } +); + +effect_cmd!( + SaveAsCmd, + "save-as", + |args: &[String]| require_args("save-as", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))] + } +); + +effect_cmd!( + SetFormatCmd, + "set-format", + |args: &[String]| require_args("set-format", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![ + Box::new(effect::SetNumberFormat(args.join(" "))), + effect::mark_dirty(), + ] + } +); + +effect_cmd!( + ImportCmd, + "import", + |args: &[String]| require_args("import", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::StartImportWizard(args[0].clone()))] + } +); + +effect_cmd!( + ExportCmd, + "export", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |args: &Vec, _ctx: &CmdContext| -> Vec> { + let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv"); + vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))] + } +); + +effect_cmd!( + WriteCmd, + "w", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |args: &Vec, _ctx: &CmdContext| -> Vec> { + if args.is_empty() { + vec![Box::new(effect::Save)] + } else { + vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))] + } + } +); + +effect_cmd!( + HelpCmd, + "help", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |_args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)] + } +); + +effect_cmd!( + HelpPageNextCmd, + "help-page-next", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |_args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![effect::help_page_next()] + } +); + +effect_cmd!( + HelpPagePrevCmd, + "help-page-prev", + |_args: &[String]| -> Result<(), String> { Ok(()) }, + |_args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![effect::help_page_prev()] + } +); + +effect_cmd!( + LoadModelCmd, + "load", + |args: &[String]| require_args("load", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::LoadModel(std::path::PathBuf::from( + &args[0], + )))] + } +); + +effect_cmd!( + ExportCsvCmd, + "export-csv", + |args: &[String]| require_args("export-csv", args, 1), + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::ExportCsv(std::path::PathBuf::from( + &args[0], + )))] + } +); + +effect_cmd!( + ImportJsonCmd, + "import-json", + |args: &[String]| { + if args.is_empty() { + Err("import-json requires a path".to_string()) + } else { + Ok(()) + } + }, + |args: &Vec, _ctx: &CmdContext| -> Vec> { + vec![Box::new(effect::ImportJsonHeadless { + path: std::path::PathBuf::from(&args[0]), + model_name: args.get(1).cloned(), + array_path: args.get(2).cloned(), + })] + } +); diff --git a/src/command/cmd/grid.rs b/src/command/cmd/grid.rs new file mode 100644 index 0000000..4fc137b --- /dev/null +++ b/src/command/cmd/grid.rs @@ -0,0 +1,300 @@ +use crate::model::cell::CellValue; +use crate::ui::effect::{self, Effect}; +use crate::view::AxisEntry; + +use super::core::{Cmd, CmdContext}; + +// ── Grid operations ───────────────────────────────────────────────────── + +/// Toggle the row or column group collapse under the cursor. +#[derive(Debug)] +pub struct ToggleGroupAtCursor { + pub is_row: bool, +} +impl Cmd for ToggleGroupAtCursor { + fn name(&self) -> &'static str { + if self.is_row { + "toggle-group-under-cursor" + } else { + "toggle-col-group-under-cursor" + } + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let lookup = if self.is_row { + ctx.layout.row_group_for(ctx.selected.0) + } else { + ctx.layout.col_group_for(ctx.selected.1) + }; + let Some((cat, group)) = lookup else { + return vec![]; + }; + vec![ + Box::new(effect::ToggleGroup { + category: cat, + group, + }), + effect::mark_dirty(), + ] + } +} + +/// Hide the row item at the cursor. +#[derive(Debug)] +pub struct HideSelectedRowItem; +impl Cmd for HideSelectedRowItem { + fn name(&self) -> &'static str { + "hide-selected-row-item" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let Some(cat_name) = ctx.layout.row_cats.first().cloned() else { + return vec![]; + }; + let sel_row = ctx.selected.0; + let Some(items) = ctx + .layout + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(sel_row) + else { + return vec![]; + }; + let item_name = items[0].clone(); + vec![ + Box::new(effect::HideItem { + category: cat_name, + item: item_name, + }), + effect::mark_dirty(), + ] + } +} + +/// Navigate back or forward in view history. +#[derive(Debug)] +pub struct ViewNavigate { + pub forward: bool, +} +impl Cmd for ViewNavigate { + fn name(&self) -> &'static str { + if self.forward { + "view-forward" + } else { + "view-back" + } + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if self.forward { + if ctx.view_forward_stack.is_empty() { + vec![effect::set_status("No forward view")] + } else { + vec![Box::new(effect::ViewForward)] + } + } else { + if ctx.view_back_stack.is_empty() { + vec![effect::set_status("No previous view")] + } else { + vec![ + Box::new(effect::ApplyAndClearDrill), + Box::new(effect::ViewBack), + ] + } + } + } +} + +/// Drill down into an aggregated cell: create a _Drill view with _Index on +/// Row and _Dim on Column (records/long-format view). Fixed coordinates +/// from the drilled cell become page filters. +#[derive(Debug)] +pub struct DrillIntoCell { + pub key: crate::model::cell::CellKey, +} +impl Cmd for DrillIntoCell { + fn name(&self) -> &'static str { + "drill-into-cell" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let drill_name = "_Drill".to_string(); + let mut effects: Vec> = Vec::new(); + + // Capture the records snapshot NOW (before we switch views). + let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> = + if self.key.0.is_empty() { + ctx.model + .data + .iter_cells() + .map(|(k, v)| (k, v.clone())) + .collect() + } else { + ctx.model + .data + .matching_cells(&self.key.0) + .into_iter() + .map(|(k, v)| (k, v.clone())) + .collect() + }; + let n = records.len(); + + // Freeze the snapshot in the drill state + effects.push(Box::new(effect::StartDrill(records))); + + // Create (or replace) the drill view + effects.push(Box::new(effect::CreateView(drill_name.clone()))); + effects.push(Box::new(effect::SwitchView(drill_name))); + + // Records mode: _Index on Row, _Dim on Column + effects.push(Box::new(effect::SetAxis { + category: "_Index".to_string(), + axis: crate::view::Axis::Row, + })); + effects.push(Box::new(effect::SetAxis { + category: "_Dim".to_string(), + axis: crate::view::Axis::Column, + })); + + // Fixed coords (from drilled cell) -> Page with that value as filter + let fixed_cats: std::collections::HashSet = + self.key.0.iter().map(|(c, _)| c.clone()).collect(); + for (cat, item) in &self.key.0 { + effects.push(Box::new(effect::SetAxis { + category: cat.clone(), + axis: crate::view::Axis::Page, + })); + effects.push(Box::new(effect::SetPageSelection { + category: cat.clone(), + item: item.clone(), + })); + } + // Previously-aggregated categories (none_cats) stay on Axis::None so + // they don't filter records; they'll appear as columns in records mode. + // Skip virtual categories — we already set _Index/_Dim above. + for cat in ctx.none_cats() { + if fixed_cats.contains(cat) || cat.starts_with('_') { + continue; + } + effects.push(Box::new(effect::SetAxis { + category: cat.clone(), + axis: crate::view::Axis::None, + })); + } + + effects.push(effect::set_status(format!("Drilled into cell: {n} rows"))); + effects + } +} + +/// Toggle pruning of empty rows/columns in the current view. +#[derive(Debug)] +pub struct TogglePruneEmpty; +impl Cmd for TogglePruneEmpty { + fn name(&self) -> &'static str { + "toggle-prune-empty" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let currently_on = ctx.model.active_view().prune_empty; + vec![ + Box::new(effect::TogglePruneEmpty), + effect::set_status(if currently_on { + "Showing all rows/columns" + } else { + "Hiding empty rows/columns" + }), + ] + } +} + +/// Toggle between records mode and pivot mode using the view stack. +/// Entering records mode creates a `_Records` view and switches to it. +/// Leaving records mode navigates back to the previous view. +#[derive(Debug)] +pub struct ToggleRecordsMode; +impl Cmd for ToggleRecordsMode { + fn name(&self) -> &'static str { + "toggle-records-mode" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let is_records = ctx.layout.is_records_mode(); + + if is_records { + // Navigate back to the previous view (restores original axes) + return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")]; + } + + let mut effects: Vec> = Vec::new(); + let records_name = "_Records".to_string(); + + // Create (or replace) a _Records view and switch to it + effects.push(Box::new(effect::CreateView(records_name.clone()))); + effects.push(Box::new(effect::SwitchView(records_name))); + + // _Index on Row, _Dim on Column, everything else -> None + effects.push(Box::new(effect::SetAxis { + category: "_Index".to_string(), + axis: crate::view::Axis::Row, + })); + effects.push(Box::new(effect::SetAxis { + category: "_Dim".to_string(), + axis: crate::view::Axis::Column, + })); + for name in ctx.model.categories.keys() { + if name != "_Index" && name != "_Dim" { + effects.push(Box::new(effect::SetAxis { + category: name.clone(), + axis: crate::view::Axis::None, + })); + } + } + effects.push(effect::set_status("Records mode")); + effects + } +} + +/// In records mode, add a new row with an empty value. The new cell gets +/// coords from the current page filters. In pivot mode, this is a no-op. +#[derive(Debug)] +pub struct AddRecordRow; +impl Cmd for AddRecordRow { + fn name(&self) -> &'static str { + "add-record-row" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let is_records = ctx + .cell_key() + .as_ref() + .and_then(crate::view::synthetic_record_info) + .is_some(); + if !is_records { + return vec![effect::set_status( + "add-record-row only works in records mode", + )]; + } + // Build a CellKey from the current page filters + let view = ctx.model.active_view(); + let page_cats: Vec = view + .categories_on(crate::view::Axis::Page) + .into_iter() + .map(String::from) + .collect(); + let coords: Vec<(String, String)> = page_cats + .iter() + .map(|cat| { + let sel = view.page_selection(cat).unwrap_or("").to_string(); + (cat.clone(), sel) + }) + .filter(|(_, v)| !v.is_empty()) + .collect(); + let key = crate::model::cell::CellKey::new(coords); + vec![ + Box::new(effect::SetCell(key, CellValue::Number(0.0))), + effect::mark_dirty(), + effect::set_status("Added new record row"), + ] + } +} diff --git a/src/command/cmd/mod.rs b/src/command/cmd/mod.rs new file mode 100644 index 0000000..34c2b76 --- /dev/null +++ b/src/command/cmd/mod.rs @@ -0,0 +1,19 @@ +pub mod core; +pub mod navigation; +pub mod mode; +pub mod cell; +pub mod search; +pub mod panel; +pub mod grid; +pub mod tile; +pub mod text_buffer; +pub mod commit; +pub mod effect_cmds; +pub mod registry; + +// Re-export items used by external code +pub use self::core::{Cmd, CmdContext, CmdRegistry}; +pub use registry::default_registry; + +#[cfg(test)] +mod tests; diff --git a/src/command/cmd/mode.rs b/src/command/cmd/mode.rs new file mode 100644 index 0000000..cda2877 --- /dev/null +++ b/src/command/cmd/mode.rs @@ -0,0 +1,189 @@ +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect}; + +use super::core::{Cmd, CmdContext}; +use super::grid::DrillIntoCell; + +// ── Mode change commands ───────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct EnterMode(pub AppMode); +impl Cmd for EnterMode { + fn name(&self) -> &'static str { + "enter-mode" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let mut effects: Vec> = Vec::new(); + // Clear the corresponding buffer when entering a text-entry mode + if let Some(mb) = self.0.minibuffer() { + effects.push(Box::new(effect::SetBuffer { + name: mb.buffer_key.to_string(), + value: String::new(), + })); + } + effects.push(effect::change_mode(self.0.clone())); + effects + } +} + +#[derive(Debug)] +pub struct ForceQuit; +impl Cmd for ForceQuit { + fn name(&self) -> &'static str { + "force-quit" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![effect::change_mode(AppMode::Quit)] + } +} + +/// Quit with dirty check — refuses if unsaved changes exist. +#[derive(Debug)] +pub struct Quit; +impl Cmd for Quit { + fn name(&self) -> &'static str { + "q" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if ctx.dirty { + vec![effect::set_status( + "Unsaved changes. Use :q! to force quit or :wq to save+quit.", + )] + } else { + vec![effect::change_mode(AppMode::Quit)] + } + } +} + +/// Save then quit. +#[derive(Debug)] +pub struct SaveAndQuit; +impl Cmd for SaveAndQuit { + fn name(&self) -> &'static str { + "wq" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::Save), effect::change_mode(AppMode::Quit)] + } +} + +// ── Editing entry ─────────────────────────────────────────────────────── + +/// Enter editing mode with an initial buffer value. +#[derive(Debug)] +pub struct EnterEditMode { + pub initial_value: String, +} +impl Cmd for EnterEditMode { + fn name(&self) -> &'static str { + "enter-edit-mode" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetBuffer { + name: "edit".to_string(), + value: self.initial_value.clone(), + }), + effect::change_mode(AppMode::editing()), + ] + } +} + +/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell +/// (categories on `Axis::None`, no records mode), drill into it instead of +/// editing. Otherwise enter edit mode with the current displayed value. +#[derive(Debug)] +pub struct EditOrDrill; +impl Cmd for EditOrDrill { + fn name(&self) -> &'static str { + "edit-or-drill" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + // Only consider regular (non-virtual, non-label) categories on None + // as true aggregation. Virtuals like _Index/_Dim are always None in + // pivot mode and don't imply aggregation. + let regular_none = ctx.none_cats().iter().any(|c| { + ctx.model + .category(c) + .map(|cat| cat.kind.is_regular()) + .unwrap_or(false) + }); + // In records mode (synthetic key), always edit directly — no drilling. + let is_synthetic = ctx + .cell_key() + .as_ref() + .and_then(crate::view::synthetic_record_info) + .is_some(); + let is_aggregated = !is_synthetic && regular_none; + if is_aggregated { + let Some(key) = ctx.cell_key().clone() else { + return vec![effect::set_status("cannot drill — no cell at cursor")]; + }; + return DrillIntoCell { key }.execute(ctx); + } + EnterEditMode { + initial_value: ctx.display_value.clone(), + } + .execute(ctx) + } +} + +/// Thin command wrapper around the `EnterEditAtCursor` effect so it can +/// participate in `Binding::Sequence`. +#[derive(Debug)] +pub struct EnterEditAtCursorCmd; +impl Cmd for EnterEditAtCursorCmd { + fn name(&self) -> &'static str { + "enter-edit-at-cursor" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::EnterEditAtCursor)] + } +} + +/// Enter export prompt mode. +#[derive(Debug)] +pub struct EnterExportPrompt; +impl Cmd for EnterExportPrompt { + fn name(&self) -> &'static str { + "enter-export-prompt" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![effect::change_mode(AppMode::export_prompt())] + } +} + +/// Enter search mode. +#[derive(Debug)] +pub struct EnterSearchMode; +impl Cmd for EnterSearchMode { + fn name(&self) -> &'static str { + "search" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetSearchMode(true)), + Box::new(effect::SetSearchQuery(String::new())), + ] + } +} + +/// Enter tile select mode. +#[derive(Debug)] +pub struct EnterTileSelect; +impl Cmd for EnterTileSelect { + fn name(&self) -> &'static str { + "enter-tile-select" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let count = ctx.model.category_names().len(); + if count > 0 { + vec![ + Box::new(effect::SetTileCatIdx(0)), + effect::change_mode(AppMode::TileSelect), + ] + } else { + vec![] + } + } +} diff --git a/src/command/cmd/navigation.rs b/src/command/cmd/navigation.rs new file mode 100644 index 0000000..16a45a0 --- /dev/null +++ b/src/command/cmd/navigation.rs @@ -0,0 +1,279 @@ +use crate::ui::effect::{self, Effect}; +use crate::view::Axis; + +use super::core::{Cmd, CmdContext}; + +// ── Navigation commands ────────────────────────────────────────────────────── +// All navigation commands take explicit cursor state. The interactive spec +// fills position/bounds from context; the parser accepts them as args. + +/// Shared viewport state for navigation commands. +#[derive(Debug, Clone, Default)] +pub struct CursorState { + pub row: usize, + pub col: usize, + pub row_count: usize, + pub col_count: usize, + pub row_offset: usize, + pub col_offset: usize, + pub visible_rows: usize, + pub visible_cols: usize, +} + +impl CursorState { + pub fn from_ctx(ctx: &CmdContext) -> Self { + Self { + row: ctx.selected.0, + col: ctx.selected.1, + row_count: ctx.row_count(), + col_count: ctx.col_count(), + row_offset: ctx.row_offset, + col_offset: ctx.col_offset, + visible_rows: ctx.visible_rows, + visible_cols: ctx.visible_cols, + } + } +} + +/// Compute viewport-tracking effects for a new row/col position. +pub(super) fn viewport_effects( + nr: usize, + nc: usize, + old_row_offset: usize, + old_col_offset: usize, + visible_rows: usize, + visible_cols: usize, +) -> Vec> { + let mut effects: Vec> = vec![effect::set_selected(nr, nc)]; + let mut row_offset = old_row_offset; + let mut col_offset = old_col_offset; + let vr = visible_rows.max(1); + let vc = visible_cols.max(1); + if nr < row_offset { + row_offset = nr; + } + if nr >= row_offset + vr { + row_offset = nr.saturating_sub(vr - 1); + } + if nc < col_offset { + col_offset = nc; + } + if nc >= col_offset + vc { + col_offset = nc.saturating_sub(vc - 1); + } + if row_offset != old_row_offset { + effects.push(Box::new(effect::SetRowOffset(row_offset))); + } + if col_offset != old_col_offset { + effects.push(Box::new(effect::SetColOffset(col_offset))); + } + effects +} + +/// How to move the cursor. +#[derive(Debug, Clone)] +pub enum MoveKind { + /// Relative offset (dr, dc) — subsumes MoveSelection and ScrollRows. + Relative(i32, i32), + /// Jump to start of axis: `true` = row, `false` = col. + ToStart(bool), + /// Jump to end of axis: `true` = row, `false` = col. + ToEnd(bool), + /// Page scroll: +1 = down, -1 = up (delta computed from visible_rows). + Page(i32), +} + +/// Unified navigation command. All variants go through `viewport_effects`. +#[derive(Debug)] +pub struct Move { + pub kind: MoveKind, + pub cursor: CursorState, + pub cmd_name: &'static str, +} + +impl Cmd for Move { + fn name(&self) -> &'static str { + self.cmd_name + } + + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let row_max = self.cursor.row_count.saturating_sub(1) as i32; + let col_max = self.cursor.col_count.saturating_sub(1) as i32; + let (nr, nc) = match &self.kind { + MoveKind::Relative(dr, dc) => { + let nr = (self.cursor.row as i32 + dr).clamp(0, row_max) as usize; + let nc = (self.cursor.col as i32 + dc).clamp(0, col_max) as usize; + (nr, nc) + } + MoveKind::ToStart(is_row) => { + if *is_row { + (0, self.cursor.col) + } else { + (self.cursor.row, 0) + } + } + MoveKind::ToEnd(is_row) => { + if *is_row { + (row_max.max(0) as usize, self.cursor.col) + } else { + (self.cursor.row, col_max.max(0) as usize) + } + } + MoveKind::Page(dir) => { + let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir; + let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize; + (nr, self.cursor.col) + } + }; + viewport_effects( + nr, + nc, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + ) + } +} + +/// Typewriter-style advance: move down, wrap to top of next column at bottom. +#[derive(Debug)] +pub struct EnterAdvance { + pub cursor: CursorState, +} +impl Cmd for EnterAdvance { + fn name(&self) -> &'static str { + "enter-advance" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let row_max = self.cursor.row_count.saturating_sub(1); + let col_max = self.cursor.col_count.saturating_sub(1); + let (r, c) = (self.cursor.row, self.cursor.col); + let (nr, nc) = if r < row_max { + (r + 1, c) + } else if c < col_max { + (0, c + 1) + } else { + (r, c) // already at bottom-right; stay + }; + viewport_effects( + nr, + nc, + self.cursor.row_offset, + self.cursor.col_offset, + self.cursor.visible_rows, + self.cursor.visible_cols, + ) + } +} + +// ── Page navigation ───────────────────────────────────────────────────── + +/// Advance to the next page (odometer-style cycling). +#[derive(Debug)] +pub struct PageNext; +impl Cmd for PageNext { + fn name(&self) -> &'static str { + "page-next" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let data = page_cat_data(ctx); + if data.is_empty() { + return vec![]; + } + let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); + let mut carry = true; + for i in (0..data.len()).rev() { + if !carry { + break; + } + indices[i] += 1; + if indices[i] >= data[i].1.len() { + indices[i] = 0; + } else { + carry = false; + } + } + data.iter() + .enumerate() + .map(|(i, (cat, items, _))| { + Box::new(effect::SetPageSelection { + category: cat.clone(), + item: items[indices[i]].clone(), + }) as Box + }) + .collect() + } +} + +/// Go to the previous page (odometer-style cycling). +#[derive(Debug)] +pub struct PagePrev; +impl Cmd for PagePrev { + fn name(&self) -> &'static str { + "page-prev" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let data = page_cat_data(ctx); + if data.is_empty() { + return vec![]; + } + let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); + let mut borrow = true; + for i in (0..data.len()).rev() { + if !borrow { + break; + } + if indices[i] == 0 { + indices[i] = data[i].1.len().saturating_sub(1); + } else { + indices[i] -= 1; + borrow = false; + } + } + data.iter() + .enumerate() + .map(|(i, (cat, items, _))| { + Box::new(effect::SetPageSelection { + category: cat.clone(), + item: items[indices[i]].clone(), + }) as Box + }) + .collect() + } +} + +/// Gather (cat_name, items, current_idx) for page-axis categories. +fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { + let view = ctx.model.active_view(); + let page_cats: Vec = view + .categories_on(Axis::Page) + .into_iter() + .map(String::from) + .collect(); + page_cats + .into_iter() + .filter_map(|cat| { + let items: Vec = ctx + .model + .category(&cat) + .map(|c| { + c.ordered_item_names() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + if items.is_empty() { + return None; + } + let current = view + .page_selection(&cat) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + let idx = items.iter().position(|i| *i == current).unwrap_or(0); + Some((cat, items, idx)) + }) + .collect() +} diff --git a/src/command/cmd/panel.rs b/src/command/cmd/panel.rs new file mode 100644 index 0000000..c032502 --- /dev/null +++ b/src/command/cmd/panel.rs @@ -0,0 +1,347 @@ +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect, Panel}; + +use super::core::{Cmd, CmdContext}; + +// ── Panel commands ────────────────────────────────────────────────────── + +/// Toggle a panel's visibility; if it opens, focus it (enter its mode). +#[derive(Debug)] +pub struct TogglePanelAndFocus { + pub panel: Panel, + pub open: bool, + pub focused: bool, +} +impl Cmd for TogglePanelAndFocus { + fn name(&self) -> &'static str { + "toggle-panel-and-focus" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let mut effects: Vec> = Vec::new(); + effects.push(Box::new(effect::SetPanelOpen { + panel: self.panel, + open: self.open, + })); + if self.focused { + effects.push(effect::change_mode(self.panel.mode())); + } else { + effects.push(effect::change_mode(AppMode::Normal)); + } + effects + } +} + +/// Toggle a panel's visibility without changing mode. +#[derive(Debug)] +pub struct TogglePanelVisibility { + pub panel: Panel, + pub currently_open: bool, +} +impl Cmd for TogglePanelVisibility { + fn name(&self) -> &'static str { + "toggle-panel-visibility" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::SetPanelOpen { + panel: self.panel, + open: !self.currently_open, + })] + } +} + +/// Tab through open panels, entering the first open panel's mode. +#[derive(Debug)] +pub struct CyclePanelFocus { + pub formula_open: bool, + pub category_open: bool, + pub view_open: bool, +} +impl Cmd for CyclePanelFocus { + fn name(&self) -> &'static str { + "cycle-panel-focus" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + if self.formula_open { + vec![effect::change_mode(AppMode::FormulaPanel)] + } else if self.category_open { + vec![effect::change_mode(AppMode::CategoryPanel)] + } else if self.view_open { + vec![effect::change_mode(AppMode::ViewPanel)] + } else { + vec![] + } + } +} + +// ── Panel cursor commands ──────────────────────────────────────────────────── + +/// Move a panel cursor by delta, clamping to bounds. +#[derive(Debug)] +pub struct MovePanelCursor { + pub panel: Panel, + pub delta: i32, + pub current: usize, + pub max: usize, +} +impl Cmd for MovePanelCursor { + fn name(&self) -> &'static str { + "move-panel-cursor" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let cursor = self.current; + let max = self.max; + if max == 0 { + return vec![]; + } + let clamped_cursor = cursor.min(max - 1); + let new = (clamped_cursor as i32 + self.delta).clamp(0, (max - 1) as i32) as usize; + if new != cursor { + vec![Box::new(effect::SetPanelCursor { + panel: self.panel, + cursor: new, + })] + } else { + vec![] + } + } +} + +// ── Formula panel commands ────────────────────────────────────────────────── + +/// Enter formula edit mode with an empty buffer. +#[derive(Debug)] +pub struct EnterFormulaEdit; +impl Cmd for EnterFormulaEdit { + fn name(&self) -> &'static str { + "enter-formula-edit" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![effect::change_mode(AppMode::formula_edit())] + } +} + +/// Delete the formula at the current cursor position. +#[derive(Debug)] +pub struct DeleteFormulaAtCursor; +impl Cmd for DeleteFormulaAtCursor { + fn name(&self) -> &'static str { + "delete-formula-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let formulas = ctx.model.formulas(); + let cursor = ctx.formula_cursor.min(formulas.len().saturating_sub(1)); + if cursor < formulas.len() { + let f = &formulas[cursor]; + let mut effects: Vec> = vec![ + Box::new(effect::RemoveFormula { + target: f.target.clone(), + target_category: f.target_category.clone(), + }), + effect::mark_dirty(), + ]; + if cursor > 0 { + effects.push(Box::new(effect::SetPanelCursor { + panel: Panel::Formula, + cursor: cursor - 1, + })); + } + effects + } else { + vec![] + } + } +} + +// ── Category panel commands ───────────────────────────────────────────────── + +/// Cycle the axis assignment of the category at the cursor. +#[derive(Debug)] +pub struct CycleAxisAtCursor; +impl Cmd for CycleAxisAtCursor { + fn name(&self) -> &'static str { + "cycle-axis-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let Some(cat_name) = ctx.cat_at_cursor() { + vec![Box::new(effect::CycleAxis(cat_name))] + } else { + vec![effect::set_status( + "Move cursor to a category header to change axis".to_string(), + )] + } + } +} + +/// Enter ItemAdd mode for the category at the panel cursor. +#[derive(Debug)] +pub struct OpenItemAddAtCursor; +impl Cmd for OpenItemAddAtCursor { + fn name(&self) -> &'static str { + "open-item-add-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let Some(cat_name) = ctx.cat_at_cursor() { + vec![effect::change_mode(AppMode::item_add(cat_name))] + } else { + vec![effect::set_status( + "No category selected. Press n to add a category first.", + )] + } + } +} + +/// Toggle expand/collapse of the category at the tree cursor. +#[derive(Debug)] +pub struct ToggleCatExpand; +impl Cmd for ToggleCatExpand { + fn name(&self) -> &'static str { + "toggle-cat-expand" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let Some(cat_name) = ctx.cat_at_cursor() { + vec![Box::new(effect::ToggleCatExpand(cat_name))] + } else { + vec![] + } + } +} + +/// Filter to item: when on an item row, set the category to Page with the +/// item as the filter value. +#[derive(Debug)] +pub struct FilterToItem; +impl Cmd for FilterToItem { + fn name(&self) -> &'static str { + "filter-to-item" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + use crate::ui::cat_tree::CatTreeEntry; + match ctx.cat_tree_entry() { + Some(CatTreeEntry::Item { + cat_name, + item_name, + }) => { + vec![ + Box::new(effect::SetAxis { + category: cat_name.clone(), + axis: crate::view::Axis::Page, + }), + Box::new(effect::SetPageSelection { + category: cat_name.clone(), + item: item_name.clone(), + }), + effect::set_status(format!("Filter: {cat_name} = {item_name}")), + ] + } + Some(CatTreeEntry::Category { .. }) => { + // On a category header — toggle expand instead + ToggleCatExpand.execute(ctx) + } + None => vec![], + } + } +} + +/// Delete the category or item at the panel cursor. +/// On a category header -> delete the whole category. +/// On an item row -> delete just that item. +#[derive(Debug)] +pub struct DeleteCategoryAtCursor; +impl Cmd for DeleteCategoryAtCursor { + fn name(&self) -> &'static str { + "delete-category-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + use crate::ui::cat_tree::CatTreeEntry; + match ctx.cat_tree_entry() { + Some(CatTreeEntry::Category { name, .. }) => { + vec![ + Box::new(effect::RemoveCategory(name.clone())), + effect::mark_dirty(), + effect::set_status(format!("Deleted category '{name}'")), + ] + } + Some(CatTreeEntry::Item { + cat_name, + item_name, + }) => { + vec![ + Box::new(effect::RemoveItem { + category: cat_name.clone(), + item: item_name.clone(), + }), + effect::mark_dirty(), + effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")), + ] + } + None => vec![effect::set_status("No category to delete")], + } + } +} + +// ── View panel commands ───────────────────────────────────────────────────── + +/// Switch to the view at the panel cursor and return to Normal mode. +#[derive(Debug)] +pub struct SwitchViewAtCursor; +impl Cmd for SwitchViewAtCursor { + fn name(&self) -> &'static str { + "switch-view-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let view_names: Vec = ctx.model.views.keys().cloned().collect(); + if let Some(name) = view_names.get(ctx.view_panel_cursor) { + vec![ + Box::new(effect::SwitchView(name.clone())), + effect::change_mode(AppMode::Normal), + ] + } else { + vec![] + } + } +} + +/// Create a new view, switch to it, and return to Normal mode. +#[derive(Debug)] +pub struct CreateAndSwitchView; +impl Cmd for CreateAndSwitchView { + fn name(&self) -> &'static str { + "create-and-switch-view" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let name = format!("View {}", ctx.model.views.len() + 1); + vec![ + Box::new(effect::CreateView(name.clone())), + Box::new(effect::SwitchView(name)), + effect::mark_dirty(), + effect::change_mode(AppMode::Normal), + ] + } +} + +/// Delete the view at the panel cursor. +#[derive(Debug)] +pub struct DeleteViewAtCursor; +impl Cmd for DeleteViewAtCursor { + fn name(&self) -> &'static str { + "delete-view-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let view_names: Vec = ctx.model.views.keys().cloned().collect(); + if let Some(name) = view_names.get(ctx.view_panel_cursor) { + let mut effects: Vec> = vec![ + Box::new(effect::DeleteView(name.clone())), + effect::mark_dirty(), + ]; + if ctx.view_panel_cursor > 0 { + effects.push(Box::new(effect::SetPanelCursor { + panel: Panel::View, + cursor: ctx.view_panel_cursor - 1, + })); + } + effects + } else { + vec![] + } + } +} diff --git a/src/command/cmd/registry.rs b/src/command/cmd/registry.rs new file mode 100644 index 0000000..ec38769 --- /dev/null +++ b/src/command/cmd/registry.rs @@ -0,0 +1,586 @@ +use crate::model::cell::CellKey; +use crate::ui::app::AppMode; +use crate::ui::effect::Panel; + +use super::cell::*; +use super::commit::*; +use super::core::*; +use super::effect_cmds::*; +use super::grid::*; +use super::mode::*; +use super::navigation::*; +use super::panel::*; +use super::search::*; +use super::text_buffer::*; +use super::tile::*; + +/// Build the default command registry with all commands. +/// Registry names MUST match the `Cmd::name()` return value. +pub fn default_registry() -> CmdRegistry { + let mut r = CmdRegistry::new(); + + // ── Model mutations (effect_cmd! wrappers) ─────────────────────────── + r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse); + r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse); + r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse); + r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse); + r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse); + r.register( + &ClearCellCommand { + key: CellKey::new(vec![]), + }, + |args| { + if args.is_empty() { + return Err("clear-cell requires at least one Cat/Item coordinate".into()); + } + Ok(Box::new(ClearCellCommand { + key: parse_cell_key_from_args(args), + })) + }, + |_args, ctx| { + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(ClearCellCommand { key })) + }, + ); + r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse); + r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse); + r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse); + r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse); + r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse); + r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse); + r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse); + r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse); + r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse); + r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse); + r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse); + r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse); + r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse); + r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse); + r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse); + r.register_pure(&ImportCmd(vec![]), ImportCmd::parse); + r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); + r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); + r.register_pure(&HelpCmd(vec![]), HelpCmd::parse); + r.register( + &HelpPageNextCmd(vec![]), + HelpPageNextCmd::parse, + |_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))), + ); + r.register( + &HelpPagePrevCmd(vec![]), + HelpPagePrevCmd::parse, + |_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))), + ); + + // ── Navigation (unified Move) ────────────────────────────────────── + r.register( + &Move { + kind: MoveKind::Relative(0, 0), + cursor: CursorState::default(), + cmd_name: "move-selection", + }, + |args| { + require_args("move-selection", args, 2)?; + let dr = args[0].parse::().map_err(|e| e.to_string())?; + let dc = args[1].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Relative(dr, dc), + cursor: CursorState::default(), + cmd_name: "move-selection", + })) + }, + |args, ctx| { + require_args("move-selection", args, 2)?; + let dr = args[0].parse::().map_err(|e| e.to_string())?; + let dc = args[1].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Relative(dr, dc), + cursor: CursorState::from_ctx(ctx), + cmd_name: "move-selection", + })) + }, + ); + // Jump-to-edge commands: first/last row/col + macro_rules! reg_jump { + ($r:expr, $is_row:expr, $to_end:expr, $name:expr) => { + $r.register( + &Move { + kind: if $to_end { + MoveKind::ToEnd($is_row) + } else { + MoveKind::ToStart($is_row) + }, + cursor: CursorState::default(), + cmd_name: $name, + }, + |_| { + Ok(Box::new(Move { + kind: if $to_end { + MoveKind::ToEnd($is_row) + } else { + MoveKind::ToStart($is_row) + }, + cursor: CursorState::default(), + cmd_name: $name, + })) + }, + |_, ctx| { + Ok(Box::new(Move { + kind: if $to_end { + MoveKind::ToEnd($is_row) + } else { + MoveKind::ToStart($is_row) + }, + cursor: CursorState::from_ctx(ctx), + cmd_name: $name, + })) + }, + ); + }; + } + reg_jump!(r, true, false, "jump-first-row"); + reg_jump!(r, true, true, "jump-last-row"); + reg_jump!(r, false, false, "jump-first-col"); + reg_jump!(r, false, true, "jump-last-col"); + r.register( + &Move { + kind: MoveKind::Relative(0, 0), + cursor: CursorState::default(), + cmd_name: "scroll-rows", + }, + |args| { + require_args("scroll-rows", args, 1)?; + let n = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Relative(n, 0), + cursor: CursorState::default(), + cmd_name: "scroll-rows", + })) + }, + |args, ctx| { + require_args("scroll-rows", args, 1)?; + let n = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Relative(n, 0), + cursor: CursorState::from_ctx(ctx), + cmd_name: "scroll-rows", + })) + }, + ); + r.register( + &Move { + kind: MoveKind::Page(0), + cursor: CursorState::default(), + cmd_name: "page-scroll", + }, + |args| { + require_args("page-scroll", args, 1)?; + let dir = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Page(dir), + cursor: CursorState::default(), + cmd_name: "page-scroll", + })) + }, + |args, ctx| { + require_args("page-scroll", args, 1)?; + let dir = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(Move { + kind: MoveKind::Page(dir), + cursor: CursorState::from_ctx(ctx), + cmd_name: "page-scroll", + })) + }, + ); + r.register( + &EnterAdvance { + cursor: CursorState::default(), + }, + |_| { + Ok(Box::new(EnterAdvance { + cursor: CursorState { + row: 0, + col: 0, + row_count: 0, + col_count: 0, + row_offset: 0, + col_offset: 0, + visible_rows: 20, + visible_cols: 8, + }, + })) + }, + |_, ctx| { + Ok(Box::new(EnterAdvance { + cursor: CursorState::from_ctx(ctx), + })) + }, + ); + + // ── Cell operations ────────────────────────────────────────────────── + r.register( + &YankCell { + key: CellKey::new(vec![]), + }, + |args| { + if args.is_empty() { + return Err("yank requires at least one Cat/Item coordinate".into()); + } + Ok(Box::new(YankCell { + key: parse_cell_key_from_args(args), + })) + }, + |_args, ctx| { + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(YankCell { key })) + }, + ); + r.register( + &PasteCell { + key: CellKey::new(vec![]), + }, + |args| { + if args.is_empty() { + return Err("paste requires at least one Cat/Item coordinate".into()); + } + Ok(Box::new(PasteCell { + key: parse_cell_key_from_args(args), + })) + }, + |_args, ctx| { + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(PasteCell { key })) + }, + ); + // clear-cell is registered above (unified: ctx.cell_key() or explicit coords) + + // ── View / page ────────────────────────────────────────────────────── + r.register_nullary(|| Box::new(TransposeAxes)); + r.register_nullary(|| Box::new(PageNext)); + r.register_nullary(|| Box::new(PagePrev)); + + // ── Mode changes ───────────────────────────────────────────────────── + r.register_nullary(|| Box::new(ForceQuit)); + r.register_nullary(|| Box::new(Quit)); + r.register_nullary(|| Box::new(SaveAndQuit)); + r.register_nullary(|| Box::new(SaveCmd)); + r.register_nullary(|| Box::new(EnterSearchMode)); + r.register( + &EnterEditMode { + initial_value: String::new(), + }, + |args| { + let val = args.first().cloned().unwrap_or_default(); + Ok(Box::new(EnterEditMode { initial_value: val })) + }, + |_args, ctx| { + Ok(Box::new(EnterEditMode { + initial_value: ctx.display_value.clone(), + })) + }, + ); + r.register_nullary(|| Box::new(EditOrDrill)); + r.register_nullary(|| Box::new(EnterEditAtCursorCmd)); + r.register_nullary(|| Box::new(EnterExportPrompt)); + r.register_nullary(|| Box::new(EnterFormulaEdit)); + r.register_nullary(|| Box::new(EnterTileSelect)); + r.register( + &DrillIntoCell { + key: crate::model::cell::CellKey::new(vec![]), + }, + |args| { + if args.is_empty() { + return Err("drill-into-cell requires Cat/Item coordinates".into()); + } + Ok(Box::new(DrillIntoCell { + key: parse_cell_key_from_args(args), + })) + }, + |_args, ctx| { + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(DrillIntoCell { key })) + }, + ); + r.register_nullary(|| Box::new(ViewNavigate { forward: false })); + r.register( + &ViewNavigate { forward: true }, + |_| Ok(Box::new(ViewNavigate { forward: true })), + |_, _| Ok(Box::new(ViewNavigate { forward: true })), + ); + r.register_pure(&NamedCmd("enter-mode"), |args| { + require_args("enter-mode", args, 1)?; + let mode = match args[0].as_str() { + "normal" => AppMode::Normal, + "help" => AppMode::Help, + "formula-panel" => AppMode::FormulaPanel, + "category-panel" => AppMode::CategoryPanel, + "view-panel" => AppMode::ViewPanel, + "tile-select" => AppMode::TileSelect, + "command" => AppMode::command_mode(), + "category-add" => AppMode::category_add(), + "editing" => AppMode::editing(), + "formula-edit" => AppMode::formula_edit(), + "export-prompt" => AppMode::export_prompt(), + other => return Err(format!("Unknown mode: {other}")), + }; + Ok(Box::new(EnterMode(mode))) + }); + + // ── Search ─────────────────────────────────────────────────────────── + r.register_pure(&NamedCmd("search-navigate"), |args| { + let forward = args.first().map(|s| s != "backward").unwrap_or(true); + Ok(Box::new(SearchNavigate(forward))) + }); + r.register_nullary(|| Box::new(SearchOrCategoryAdd)); + r.register_nullary(|| Box::new(ExitSearchMode)); + + // ── Panel operations ───────────────────────────────────────────────── + r.register( + &TogglePanelAndFocus { + panel: Panel::Formula, + open: true, + focused: true, + }, + |args| { + // Parse: toggle-panel-and-focus [open] [focused] + require_args("toggle-panel-and-focus", args, 1)?; + let panel = parse_panel(&args[0])?; + let open = args.get(1).map(|s| s == "true").unwrap_or(true); + let focused = args.get(2).map(|s| s == "true").unwrap_or(open); + Ok(Box::new(TogglePanelAndFocus { + panel, + open, + focused, + })) + }, + |args, ctx| { + require_args("toggle-panel-and-focus", args, 1)?; + let panel = parse_panel(&args[0])?; + // Default interactive: if already open+focused -> close, else open+focus + let currently_open = match panel { + Panel::Formula => ctx.formula_panel_open, + Panel::Category => ctx.category_panel_open, + Panel::View => ctx.view_panel_open, + }; + let currently_focused = match panel { + Panel::Formula => matches!( + ctx.mode, + AppMode::FormulaPanel | AppMode::FormulaEdit { .. } + ), + Panel::Category => matches!( + ctx.mode, + AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. } + ), + Panel::View => matches!(ctx.mode, AppMode::ViewPanel), + }; + let (open, focused) = if currently_open && currently_focused { + (false, false) // close + } else { + (true, true) // open + focus + }; + Ok(Box::new(TogglePanelAndFocus { + panel, + open, + focused, + })) + }, + ); + r.register( + &TogglePanelVisibility { + panel: Panel::Formula, + currently_open: false, + }, + |args| { + require_args("toggle-panel-visibility", args, 1)?; + let panel = parse_panel(&args[0])?; + Ok(Box::new(TogglePanelVisibility { + panel, + currently_open: false, + })) + }, + |args, ctx| { + require_args("toggle-panel-visibility", args, 1)?; + let panel = parse_panel(&args[0])?; + let currently_open = match panel { + Panel::Formula => ctx.formula_panel_open, + Panel::Category => ctx.category_panel_open, + Panel::View => ctx.view_panel_open, + }; + Ok(Box::new(TogglePanelVisibility { + panel, + currently_open, + })) + }, + ); + r.register( + &CyclePanelFocus { + formula_open: false, + category_open: false, + view_open: false, + }, + |_| { + Ok(Box::new(CyclePanelFocus { + formula_open: false, + category_open: false, + view_open: false, + })) + }, + |_, ctx| { + Ok(Box::new(CyclePanelFocus { + formula_open: ctx.formula_panel_open, + category_open: ctx.category_panel_open, + view_open: ctx.view_panel_open, + })) + }, + ); + r.register( + &MovePanelCursor { + panel: Panel::Formula, + delta: 0, + current: 0, + max: 0, + }, + |args| { + require_args("move-panel-cursor", args, 2)?; + let panel = parse_panel(&args[0])?; + let delta = args[1].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(MovePanelCursor { + panel, + delta, + current: 0, + max: 0, + })) + }, + |args, ctx| { + require_args("move-panel-cursor", args, 2)?; + let panel = parse_panel(&args[0])?; + let delta = args[1].parse::().map_err(|e| e.to_string())?; + let (current, max) = match panel { + Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()), + Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()), + Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()), + }; + Ok(Box::new(MovePanelCursor { + panel, + delta, + current, + max, + })) + }, + ); + r.register_nullary(|| Box::new(DeleteFormulaAtCursor)); + r.register_nullary(|| Box::new(AddRecordRow)); + r.register_nullary(|| Box::new(TogglePruneEmpty)); + r.register_nullary(|| Box::new(ToggleRecordsMode)); + r.register_nullary(|| Box::new(CycleAxisAtCursor)); + r.register_nullary(|| Box::new(OpenItemAddAtCursor)); + r.register_nullary(|| Box::new(DeleteCategoryAtCursor)); + r.register_nullary(|| Box::new(ToggleCatExpand)); + r.register_nullary(|| Box::new(FilterToItem)); + r.register_nullary(|| Box::new(SwitchViewAtCursor)); + r.register_nullary(|| Box::new(CreateAndSwitchView)); + r.register_nullary(|| Box::new(DeleteViewAtCursor)); + + // ── Tile select ────────────────────────────────────────────────────── + r.register_pure(&NamedCmd("move-tile-cursor"), |args| { + require_args("move-tile-cursor", args, 1)?; + let delta = args[0].parse::().map_err(|e| e.to_string())?; + Ok(Box::new(MoveTileCursor(delta))) + }); + r.register_nullary(|| Box::new(TileAxisOp { axis: None })); + r.register_pure(&NamedCmd("set-axis-for-tile"), |args| { + require_args("set-axis-for-tile", args, 1)?; + let axis = parse_axis(&args[0])?; + Ok(Box::new(TileAxisOp { axis: Some(axis) })) + }); + + // ── Grid operations ────────────────────────────────────────────────── + r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true })); + r.register( + &ToggleGroupAtCursor { is_row: false }, + |_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), + |_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), + ); + r.register_nullary(|| Box::new(HideSelectedRowItem)); + + // ── Text buffer ────────────────────────────────────────────────────── + r.register_pure(&NamedCmd("append-char"), |args| { + require_args("append-char", args, 1)?; + Ok(Box::new(AppendChar { + buffer: args[0].clone(), + })) + }); + r.register_pure(&NamedCmd("pop-char"), |args| { + require_args("pop-char", args, 1)?; + Ok(Box::new(PopChar { + buffer: args[0].clone(), + })) + }); + r.register_nullary(|| Box::new(CommandModeBackspace)); + + // ── Commit ─────────────────────────────────────────────────────────── + r.register( + &CommitAndAdvance { + key: CellKey::new(vec![]), + value: String::new(), + advance: AdvanceDir::Down, + cursor: CursorState::default(), + }, + |args| { + if args.len() < 2 { + return Err("commit-cell-edit requires a value and coords".into()); + } + Ok(Box::new(CommitAndAdvance { + key: parse_cell_key_from_args(&args[1..]), + value: args[0].clone(), + advance: AdvanceDir::Down, + cursor: CursorState::default(), + })) + }, + |_args, ctx| { + let value = read_buffer(ctx, "edit"); + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(CommitAndAdvance { + key, + value, + advance: AdvanceDir::Down, + cursor: CursorState::from_ctx(ctx), + })) + }, + ); + r.register( + &CommitAndAdvance { + key: CellKey::new(vec![]), + value: String::new(), + advance: AdvanceDir::Right, + cursor: CursorState::default(), + }, + |_| Err("commit-and-advance-right requires context".into()), + |_args, ctx| { + let value = read_buffer(ctx, "edit"); + let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + Ok(Box::new(CommitAndAdvance { + key, + value, + advance: AdvanceDir::Right, + cursor: CursorState::from_ctx(ctx), + })) + }, + ); + r.register_nullary(|| Box::new(CommitFormula)); + r.register_nullary(|| Box::new(CommitCategoryAdd)); + r.register_nullary(|| Box::new(CommitItemAdd)); + r.register_nullary(|| Box::new(CommitExport)); + r.register_nullary(|| Box::new(ExecuteCommand)); + + // ── Wizard ─────────────────────────────────────────────────────────── + r.register_nullary(|| Box::new(HandleWizardKey)); + + // ── Aliases (short names for common commands) ──────────────────────── + r.alias("add-cat", "add-category"); + r.alias("formula", "add-formula"); + r.alias("add-view", "create-view"); + r.alias("q!", "force-quit"); + + r +} diff --git a/src/command/cmd/search.rs b/src/command/cmd/search.rs new file mode 100644 index 0000000..fa4bf6b --- /dev/null +++ b/src/command/cmd/search.rs @@ -0,0 +1,121 @@ +use crate::model::cell::CellValue; +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect, Panel}; + +use super::core::{Cmd, CmdContext}; + +/// Navigate to the next or previous search match. +#[derive(Debug)] +pub struct SearchNavigate(pub bool); +impl Cmd for SearchNavigate { + fn name(&self) -> &'static str { + "search-navigate" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let query = ctx.search_query.to_lowercase(); + if query.is_empty() { + return vec![]; + } + + let (cur_row, cur_col) = ctx.selected; + let total_rows = ctx.row_count().max(1); + let total_cols = ctx.col_count().max(1); + let total = total_rows * total_cols; + let cur_flat = cur_row * total_cols + cur_col; + + let matches: Vec = (0..total) + .filter(|&flat| { + let ri = flat / total_cols; + let ci = flat % total_cols; + let key = match ctx.layout.cell_key(ri, ci) { + Some(k) => k, + None => return false, + }; + let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) { + Some(CellValue::Number(n)) => format!("{n}"), + Some(CellValue::Text(t)) => t, + Some(CellValue::Error(e)) => format!("ERR:{e}"), + None => String::new(), + }; + s.to_lowercase().contains(&query) + }) + .collect(); + + if matches.is_empty() { + return vec![effect::set_status(format!( + "No matches for '{}'", + ctx.search_query + ))]; + } + + let target_flat = if self.0 { + matches + .iter() + .find(|&&f| f > cur_flat) + .or_else(|| matches.first()) + .copied() + } else { + matches + .iter() + .rev() + .find(|&&f| f < cur_flat) + .or_else(|| matches.last()) + .copied() + }; + + if let Some(flat) = target_flat { + let ri = flat / total_cols; + let ci = flat % total_cols; + let mut effects: Vec> = vec![effect::set_selected(ri, ci)]; + if ri < ctx.row_offset { + effects.push(Box::new(effect::SetRowOffset(ri))); + } + if ci < ctx.col_offset { + effects.push(Box::new(effect::SetColOffset(ci))); + } + effects.push(effect::set_status(format!( + "Match {}/{} for '{}'", + matches.iter().position(|&f| f == flat).unwrap_or(0) + 1, + matches.len(), + ctx.search_query, + ))); + effects + } else { + vec![] + } + } +} + +/// If search query is active, navigate backward; otherwise open CategoryAdd. +#[derive(Debug)] +pub struct SearchOrCategoryAdd; +impl Cmd for SearchOrCategoryAdd { + fn name(&self) -> &'static str { + "search-or-category-add" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if !ctx.search_query.is_empty() { + SearchNavigate(false).execute(ctx) + } else { + vec![ + Box::new(effect::SetPanelOpen { + panel: Panel::Category, + open: true, + }), + effect::change_mode(AppMode::category_add()), + ] + } + } +} + +/// Exit search mode (clears search_mode flag). +#[derive(Debug)] +pub struct ExitSearchMode; +impl Cmd for ExitSearchMode { + fn name(&self) -> &'static str { + "exit-search-mode" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::SetSearchMode(false))] + } +} diff --git a/src/command/cmd/tests.rs b/src/command/cmd/tests.rs new file mode 100644 index 0000000..c71cf13 --- /dev/null +++ b/src/command/cmd/tests.rs @@ -0,0 +1,1407 @@ +use std::collections::HashMap; + +use crossterm::event::KeyCode; + +use super::cell::*; +use super::commit::*; +use super::core::*; +use super::effect_cmds::*; +use super::grid::*; +use super::mode::*; +use super::navigation::*; +use super::panel::*; +use super::registry::*; +use super::search::*; +use super::text_buffer::*; +use super::tile::*; +use crate::model::cell::{CellKey, CellValue}; +use crate::model::Model; +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect}; +use crate::view::GridLayout; + +static EMPTY_BUFFERS: std::sync::LazyLock> = + std::sync::LazyLock::new(HashMap::new); +static EMPTY_EXPANDED: std::sync::LazyLock> = + std::sync::LazyLock::new(std::collections::HashSet::new); + +fn make_layout(model: &Model) -> GridLayout { + GridLayout::new(model, model.active_view()) +} + +fn make_ctx<'a>( + model: &'a Model, + layout: &'a GridLayout, + registry: &'a CmdRegistry, +) -> CmdContext<'a> { + let view = model.active_view(); + let (sr, sc) = view.selected; + CmdContext { + model, + layout, + registry, + mode: &AppMode::Normal, + selected: view.selected, + row_offset: view.row_offset, + col_offset: view.col_offset, + search_query: "", + yanked: &None, + dirty: false, + search_mode: false, + formula_panel_open: false, + category_panel_open: false, + view_panel_open: false, + formula_cursor: 0, + cat_panel_cursor: 0, + view_panel_cursor: 0, + tile_cat_idx: 0, + buffers: &EMPTY_BUFFERS, + view_back_stack: &[], + view_forward_stack: &[], + display_value: { + let key = layout.cell_key(sr, sc); + key.as_ref() + .and_then(|k| model.get_cell(k).cloned()) + .map(|v| v.to_string()) + .unwrap_or_default() + }, + visible_rows: 20, + visible_cols: 8, + expanded_cats: &EMPTY_EXPANDED, + key_code: KeyCode::Null, + } +} + +fn two_cat_model() -> Model { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.category_mut("Type").unwrap().add_item("Food"); + m.category_mut("Type").unwrap().add_item("Clothing"); + m.category_mut("Month").unwrap().add_item("Jan"); + m.category_mut("Month").unwrap().add_item("Feb"); + m +} + +#[test] +fn move_selection_down_produces_set_selected() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::Relative(1, 0), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + }; + let effects = cmd.execute(&ctx); + // Should produce at least SetSelected + assert!(!effects.is_empty()); +} + +#[test] +fn move_selection_clamps_to_bounds() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + // Try to move way past the end + let cmd = Move { + kind: MoveKind::Relative(100, 100), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); +} + +#[test] +fn quit_when_dirty_shows_warning() { + let m = two_cat_model(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "q".to_string()); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.dirty = true; + ctx.buffers = &bufs; + let cmd = ExecuteCommand; + let effects = cmd.execute(&ctx); + let dbg = format!("{:?}", effects); + assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}"); +} + +#[test] +fn quit_when_clean_produces_quit_mode() { + let m = two_cat_model(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "q".to_string()); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let cmd = ExecuteCommand; + let effects = cmd.execute(&ctx); + assert!( + effects.iter().any(|e| e.changes_mode()), + "Expected a mode-changing effect" + ); +} + +#[test] +fn clear_selected_cell_produces_clear_and_dirty() { + let mut m = two_cat_model(); + let key = CellKey::new(vec![ + ("Type".to_string(), "Food".to_string()), + ("Month".to_string(), "Jan".to_string()), + ]); + m.set_cell(key, CellValue::Number(42.0)); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ClearCellCommand { + key: ctx.cell_key().clone().unwrap(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // ClearCell + MarkDirty +} + +#[test] +fn yank_cell_produces_set_yanked() { + let mut m = two_cat_model(); + let key = CellKey::new(vec![ + ("Type".to_string(), "Food".to_string()), + ("Month".to_string(), "Jan".to_string()), + ]); + m.set_cell(key, CellValue::Number(99.0)); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = YankCell { + key: ctx.cell_key().clone().unwrap(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetYanked + SetStatus +} + +#[test] +fn toggle_panel_open_and_focus() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TogglePanelAndFocus { + panel: effect::Panel::Formula, + open: true, + focused: true, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel mode, got: {dbg}" + ); +} + +#[test] +fn toggle_panel_close_and_unfocus() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TogglePanelAndFocus { + panel: effect::Panel::Formula, + open: false, + focused: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal) +} + +#[test] +fn enter_advance_moves_down() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterAdvance { + cursor: CursorState::from_ctx(&ctx), + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("SetSelected(1, 0)"), + "Expected row 1, got: {dbg}" + ); +} + +#[test] +fn search_navigate_with_empty_query_returns_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SearchNavigate(true); + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); +} + +#[test] +fn enter_edit_mode_produces_editing_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterEditMode { + initial_value: String::new(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetBuffer + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); +} + +#[test] +fn enter_tile_select_with_categories() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("TileSelect"), + "Expected TileSelect mode, got: {dbg}" + ); +} + +#[test] +fn enter_tile_select_no_categories() { + // Models always have virtual categories (_Index, _Dim), so tile + // select always has something to operate on. + let m = Model::new("Empty"); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode +} + +#[test] +fn toggle_group_under_cursor_returns_empty_without_groups() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ToggleGroupAtCursor { is_row: true }; + let effects = cmd.execute(&ctx); + // No groups defined, so nothing to toggle + assert!(effects.is_empty()); +} + +#[test] +fn search_or_category_add_without_query_opens_category_add() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SearchOrCategoryAdd; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("CategoryAdd"), + "Expected CategoryAdd, got: {dbg}" + ); +} + +#[test] +fn cycle_panel_focus_with_no_panels_open() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = CyclePanelFocus { + formula_open: false, + category_open: false, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); +} + +#[test] +fn cycle_panel_focus_with_formula_panel_open() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.formula_panel_open = true; + let cmd = CyclePanelFocus { + formula_open: true, + category_open: false, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel, got: {dbg}" + ); +} + +// ── Algebraic law tests ────────────────────────────────────────────── + +fn effects_debug(effects: &[Box]) -> String { + format!("{:?}", effects) +} + +/// Law: navigation idempotence — ToStart applied twice produces the same +/// effects as applied once. +#[test] +fn law_move_to_start_idempotent() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::ToStart(true), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-first-row", + }; + let first = effects_debug(&cmd.execute(&ctx)); + // After the first move, cursor is at row 0. Simulate that state. + let cmd2 = Move { + kind: MoveKind::ToStart(true), + cursor: CursorState { + row: 0, + ..CursorState::from_ctx(&ctx) + }, + cmd_name: "jump-first-row", + }; + let second = effects_debug(&cmd2.execute(&ctx)); + assert_eq!(first, second, "ToStart(Row) should be idempotent"); +} + +/// Law: toggle involution — toggling a group twice yields the same effects +/// (both are ToggleGroup + MarkDirty, regardless of current state). +#[test] +fn law_toggle_group_involution() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ToggleGroupAtCursor { is_row: true }; + let first = effects_debug(&cmd.execute(&ctx)); + let second = effects_debug(&cmd.execute(&ctx)); + // Both calls produce the same structural effects (the group lookup + // returns None in both cases since there are no groups, so both are + // empty — which is still an involution). + assert_eq!(first, second, "Toggle should be structurally consistent"); +} + +/// Law: sequence associativity — concatenating effect vectors is associative. +/// This is structural (Vec::extend is associative), but we verify it. +#[test] +fn law_sequence_associativity() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + + let mk_a = || { + Move { + kind: MoveKind::Relative(1, 0), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + } + .execute(&ctx) + }; + let mk_b = || { + Move { + kind: MoveKind::Relative(0, 1), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + } + .execute(&ctx) + }; + let mk_c = || { + Move { + kind: MoveKind::ToStart(true), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-first-row", + } + .execute(&ctx) + }; + + // (a ++ b) ++ c + let mut ab_c = mk_a(); + ab_c.extend(mk_b()); + ab_c.extend(mk_c()); + + // a ++ (b ++ c) + let mut bc = mk_b(); + bc.extend(mk_c()); + let mut a_bc = mk_a(); + a_bc.extend(bc); + + assert_eq!( + effects_debug(&ab_c), + effects_debug(&a_bc), + "Sequence concatenation should be associative" + ); +} + +/// Law: MoveKind::ToEnd(col) reaches the last column. +#[test] +fn law_move_to_end_reaches_last_col() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::ToEnd(false), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-last-col", + }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + let expected_col = ctx.col_count().saturating_sub(1); + assert!( + dbg.contains(&format!("SetSelected(0, {expected_col})")), + "Expected jump to last col {expected_col}, got: {dbg}" + ); +} + +// ── Paste command ────────────────────────────────────────────────── + +#[test] +fn paste_with_yanked_value_produces_set_cell() { + let mut m = two_cat_model(); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]), + CellValue::Number(42.0), + ); + let layout = make_layout(&m); + let reg = default_registry(); + let yanked = Some(CellValue::Number(99.0)); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.yanked = &yanked; + let key = CellKey::new(vec![ + ("Type".into(), "Clothing".into()), + ("Month".into(), "Feb".into()), + ]); + let cmd = PasteCell { key }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetCell + MarkDirty + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); +} + +#[test] +fn paste_without_yanked_value_produces_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + let cmd = PasteCell { key }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); +} + +// ── Transpose ────────────────────────────────────────────────────── + +#[test] +fn transpose_produces_transpose_and_dirty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = TransposeAxes.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("TransposeAxes"), + "Expected TransposeAxes, got: {dbg}" + ); +} + +// ── View navigation ──────────────────────────────────────────────── + +#[test] +fn view_forward_with_empty_stack_shows_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ViewNavigate { forward: true }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("No forward view"), + "Expected status message, got: {dbg}" + ); +} + +#[test] +fn view_back_with_empty_stack_shows_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ViewNavigate { forward: false }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("No previous view"), + "Expected status message, got: {dbg}" + ); +} + +#[test] +fn view_forward_with_stack_produces_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let fwd_stack = vec!["View 2".to_string()]; + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_forward_stack = &fwd_stack; + let cmd = ViewNavigate { forward: true }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ViewForward"), + "Expected ViewForward, got: {dbg}" + ); +} + +#[test] +fn view_back_with_stack_produces_apply_and_back() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let back_stack = vec!["Default".to_string()]; + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_back_stack = &back_stack; + let cmd = ViewNavigate { forward: false }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // ApplyAndClearDrill + ViewBack + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ApplyAndClearDrill"), + "Expected ApplyAndClearDrill, got: {dbg}" + ); + assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}"); +} + +// ── Panel cursor ─────────────────────────────────────────────────── + +#[test] +fn move_panel_cursor_down_from_zero() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: 1, + current: 0, + max: 5, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPanelCursor"), + "Expected SetPanelCursor, got: {dbg}" + ); +} + +#[test] +fn move_panel_cursor_clamps_at_zero() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: -1, + current: 0, + max: 5, + }; + let effects = cmd.execute(&ctx); + // Already at 0, can't go below -> no effect + assert!(effects.is_empty()); +} + +#[test] +fn move_panel_cursor_with_zero_max_produces_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: 1, + current: 0, + max: 0, + }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); +} + +// ── Page navigation ──────────────────────────────────────────────── + +#[test] +fn page_next_with_no_page_cats_returns_empty() { + // Default two_cat_model has Type on Row, Month on Column, nothing on Page + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PageNext.execute(&ctx); + assert!(effects.is_empty()); +} + +#[test] +fn page_prev_with_no_page_cats_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PagePrev.execute(&ctx); + assert!(effects.is_empty()); +} + +fn three_cat_model_with_page() -> Model { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.add_category("Region").unwrap(); + m.category_mut("Type").unwrap().add_item("Food"); + m.category_mut("Type").unwrap().add_item("Clothing"); + m.category_mut("Month").unwrap().add_item("Jan"); + m.category_mut("Month").unwrap().add_item("Feb"); + m.category_mut("Region").unwrap().add_item("North"); + m.category_mut("Region").unwrap().add_item("South"); + m.category_mut("Region").unwrap().add_item("East"); + // Put Region on Page axis + let view = m.active_view_mut(); + view.set_axis("Region", crate::view::Axis::Page); + m +} + +#[test] +fn page_next_cycles_through_page_items() { + let m = three_cat_model_with_page(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PageNext.execute(&ctx); + // Should produce SetPageSelection effects + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPageSelection"), + "Expected SetPageSelection, got: {dbg}" + ); +} + +#[test] +fn page_prev_cycles_backward() { + let m = three_cat_model_with_page(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PagePrev.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPageSelection"), + "Expected SetPageSelection, got: {dbg}" + ); +} + +// ── Tile axis commands ───────────────────────────────────────────── + +#[test] +fn tile_axis_cycle_produces_cycle_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TileAxisOp { axis: None }; // cycle + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}"); +} + +#[test] +fn tile_axis_set_produces_set_axis_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TileAxisOp { + axis: Some(crate::view::Axis::Page), + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); +} + +#[test] +fn tile_axis_with_out_of_bounds_cursor_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.tile_cat_idx = 999; // way beyond + let cmd = TileAxisOp { axis: None }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); +} + +// ── Move tile cursor ─────────────────────────────────────────────── + +#[test] +fn move_tile_cursor_right() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MoveTileCursor(1); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetTileCatIdx(1)"), + "Expected idx 1, got: {dbg}" + ); +} + +#[test] +fn move_tile_cursor_clamps_at_start() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MoveTileCursor(-1); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetTileCatIdx(0)"), + "Expected clamped to 0, got: {dbg}" + ); +} + +// ── Commit formula ───────────────────────────────────────────────── + +#[test] +fn commit_formula_with_categories_adds_formula() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("formula".to_string(), "Profit = Revenue - Cost".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitFormula.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddFormula"), + "Expected AddFormula, got: {dbg}" + ); + assert!( + dbg.contains("FormulaPanel"), + "Expected return to FormulaPanel, got: {dbg}" + ); +} + +/// Regression: CommitFormula must not target virtual categories (_Index, _Dim) +/// when no regular categories exist. It should show "Add at least one category first." +#[test] +fn commit_formula_without_regular_categories_shows_status() { + let m = Model::new("Empty"); // only has virtual _Index, _Dim + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("formula".to_string(), "X = Y + Z".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitFormula.execute(&ctx); + let dbg = effects_debug(&effects); + // Should NOT produce AddFormula targeting a virtual category + assert!( + !dbg.contains("AddFormula"), + "Should not add formula when only virtual categories exist, got: {dbg}" + ); + assert!( + dbg.contains("Add at least one category first"), + "Expected status message, got: {dbg}" + ); +} + +// ── Commit category add ──────────────────────────────────────────── + +#[test] +fn commit_category_add_with_name_produces_add_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("category".to_string(), "Region".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitCategoryAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory, got: {dbg}" + ); +} + +#[test] +fn commit_category_add_with_empty_buffer_returns_to_panel() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("category".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitCategoryAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("CategoryPanel"), + "Expected return to CategoryPanel, got: {dbg}" + ); +} + +// ── Commit item add ──────────────────────────────────────────────── + +#[test] +fn commit_item_add_with_name_produces_add_item() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("item".to_string(), "March".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + let item_add_mode = AppMode::item_add("Month".to_string()); + ctx.mode = &item_add_mode; + ctx.buffers = &bufs; + let effects = CommitItemAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}"); +} + +#[test] +fn commit_item_add_outside_item_add_mode_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + // mode is Normal, not ItemAdd + let effects = CommitItemAdd.execute(&ctx); + assert!(effects.is_empty()); +} + +// ── Command mode backspace ───────────────────────────────────────── + +#[test] +fn command_mode_backspace_pops_char() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "hel".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommandModeBackspace.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetBuffer"), "Expected SetBuffer, got: {dbg}"); + // The buffer should become "he" (popped last char) + assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}"); +} + +#[test] +fn command_mode_backspace_on_empty_returns_to_normal() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommandModeBackspace.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("Normal"), + "Expected return to Normal, got: {dbg}" + ); +} + +// ── Execute command ──────────────────────────────────────────────── + +#[test] +fn execute_command_empty_returns_to_normal() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); +} + +#[test] +fn execute_command_invalid_shows_error_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "nonexistent-command".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + // Should show an error status AND return to Normal + assert!( + dbg.contains("Normal"), + "Expected Normal mode on error, got: {dbg}" + ); +} + +#[test] +fn execute_command_valid_runs_command() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "add-category Region".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory effect, got: {dbg}" + ); +} + +// ── Save command ─────────────────────────────────────────────────── + +#[test] +fn save_produces_save_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SaveCmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); +} + +// ── Enter search mode ────────────────────────────────────────────── + +#[test] +fn enter_search_mode_sets_flag_and_clears_query() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EnterSearchMode.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSearchMode(true)"), + "Expected search mode on, got: {dbg}" + ); + assert!( + dbg.contains("SetSearchQuery"), + "Expected query reset, got: {dbg}" + ); +} + +#[test] +fn exit_search_mode_clears_flag() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = ExitSearchMode.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSearchMode(false)"), + "Expected search mode off, got: {dbg}" + ); +} + +// ── Search navigate with query finds match ───────────────────────── + +#[test] +fn search_navigate_forward_with_matching_value() { + let mut m = two_cat_model(); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]), + CellValue::Number(42.0), + ); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Clothing".into()), + ("Month".into(), "Feb".into()), + ]), + CellValue::Number(99.0), + ); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.search_query = "99"; + let cmd = SearchNavigate(true); + let effects = cmd.execute(&ctx); + // Should find the cell with 99 and navigate to it + if !effects.is_empty() { + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSelected"), + "Expected SetSelected, got: {dbg}" + ); + } + // If empty, the search didn't find it through layout — that's OK since + // layout coordinates may not map 1:1 with model cells in all cases. +} + +// ── Create and switch view ───────────────────────────────────────── + +#[test] +fn create_and_switch_view_names_incrementally() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = CreateAndSwitchView.execute(&ctx); + let dbg = effects_debug(&effects); + // Model starts with 1 view ("Default"), so new view should be "View 2" + assert!( + dbg.contains("CreateView"), + "Expected CreateView, got: {dbg}" + ); + assert!( + dbg.contains("SwitchView"), + "Expected SwitchView, got: {dbg}" + ); + assert!( + dbg.contains("Normal"), + "Expected return to Normal, got: {dbg}" + ); +} + +// ── Switch view at cursor ────────────────────────────────────────── + +#[test] +fn switch_view_at_cursor_with_valid_cursor() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SwitchViewAtCursor.execute(&ctx); + // cursor 0, model has "Default" view + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SwitchView"), + "Expected SwitchView, got: {dbg}" + ); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); +} + +#[test] +fn switch_view_at_cursor_out_of_bounds_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_panel_cursor = 999; + let effects = SwitchViewAtCursor.execute(&ctx); + assert!(effects.is_empty()); +} + +// ── Delete view at cursor ────────────────────────────────────────── + +#[test] +fn delete_view_at_cursor_zero_does_not_adjust_cursor() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = DeleteViewAtCursor.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("DeleteView"), + "Expected DeleteView, got: {dbg}" + ); + // At cursor 0, should NOT have SetPanelCursor (no cursor adjustment needed) + assert!( + !dbg.contains("SetPanelCursor"), + "Expected no cursor adjustment at position 0, got: {dbg}" + ); +} + +// ── Delete formula at cursor ─────────────────────────────────────── + +#[test] +fn delete_formula_at_cursor_with_formulas() { + let mut m = two_cat_model(); + m.add_formula(crate::formula::ast::Formula { + raw: "Profit = Revenue - Cost".to_string(), + target: "Profit".to_string(), + target_category: "Type".to_string(), + expr: crate::formula::ast::Expr::Number(0.0), + filter: None, + }); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = DeleteFormulaAtCursor.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("RemoveFormula"), + "Expected RemoveFormula, got: {dbg}" + ); +} + +// ── Commit export ────────────────────────────────────────────────── + +#[test] +fn commit_export_produces_export_and_normal_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut bufs = HashMap::new(); + bufs.insert("export".to_string(), "/tmp/test.csv".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitExport.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); +} + +// ── Force quit ───────────────────────────────────────────────────── + +#[test] +fn force_quit_always_produces_quit_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.dirty = true; // even when dirty + let effects = ForceQuit.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}"); +} + +// ── Save and quit ────────────────────────────────────────────────── + +#[test] +fn save_and_quit_produces_save_then_quit() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SaveAndQuit.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); + assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}"); +} + +// ── Enter export prompt ──────────────────────────────────────────── + +#[test] +fn enter_export_prompt_sets_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EnterExportPrompt.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ExportPrompt"), + "Expected ExportPrompt mode, got: {dbg}" + ); +} + +// ── Toggle prune empty ───────────────────────────────────────────── + +#[test] +fn toggle_prune_empty_produces_toggle_and_dirty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = TogglePruneEmpty.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("TogglePruneEmpty"), + "Expected TogglePruneEmpty, got: {dbg}" + ); +} + +// ── Edit or drill ────────────────────────────────────────────────── + +#[test] +fn edit_or_drill_without_aggregation_enters_edit() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + // All categories are on Row/Column, none on None -> no aggregation -> edit + let effects = EditOrDrill.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); +} + +// ── Cycle panel focus with multiple panels ───────────────────────── + +#[test] +fn cycle_panel_focus_with_multiple_panels() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.formula_panel_open = true; + ctx.category_panel_open = true; + let cmd = CyclePanelFocus { + formula_open: true, + category_open: true, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + // Should focus the first open panel (Formula) + assert!( + dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"), + "Expected panel focus, got: {dbg}" + ); +} + +// ── effect_cmd! macro tests ──────────────────────────────────────── + +#[test] +fn add_category_cmd_produces_add_category_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = AddCategoryCmd(vec!["Region".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory, got: {dbg}" + ); +} + +#[test] +fn set_cell_cmd_parses_coords_correctly() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SetCellCmd(vec![ + "42".to_string(), + "Type/Food".to_string(), + "Month/Jan".to_string(), + ]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); +} + +#[test] +fn set_axis_cmd_recognizes_column_alias() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); +} + +#[test] +fn write_cmd_without_args_saves() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = WriteCmd(vec![]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); +} + +#[test] +fn write_cmd_with_path_saves_as() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = default_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}"); +} + +// ── parse_axis helper ────────────────────────────────────────────── + +#[test] +fn parse_axis_recognizes_all_variants() { + use super::core::parse_axis; + assert!(parse_axis("row").is_ok()); + assert!(parse_axis("column").is_ok()); + assert!(parse_axis("col").is_ok()); + assert!(parse_axis("page").is_ok()); + assert!(parse_axis("none").is_ok()); + assert!(parse_axis("ROW").is_ok()); // case insensitive +} + +#[test] +fn parse_axis_rejects_unknown() { + use super::core::parse_axis; + assert!(parse_axis("diagonal").is_err()); +} diff --git a/src/command/cmd/text_buffer.rs b/src/command/cmd/text_buffer.rs new file mode 100644 index 0000000..4390e26 --- /dev/null +++ b/src/command/cmd/text_buffer.rs @@ -0,0 +1,135 @@ +use crossterm::event::KeyCode; + +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect}; + +use super::core::{read_buffer, Cmd, CmdContext}; + +/// Append the pressed character to a named buffer. +#[derive(Debug)] +pub struct AppendChar { + pub buffer: String, +} +impl Cmd for AppendChar { + fn name(&self) -> &'static str { + "append-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let KeyCode::Char(c) = ctx.key_code { + let mut val = read_buffer(ctx, &self.buffer); + val.push(c); + if self.buffer == "search" { + vec![Box::new(effect::SetSearchQuery(val))] + } else { + vec![Box::new(effect::SetBuffer { + name: self.buffer.clone(), + value: val, + })] + } + } else { + vec![] + } + } +} + +/// Pop the last character from a named buffer. +#[derive(Debug)] +pub struct PopChar { + pub buffer: String, +} +impl Cmd for PopChar { + fn name(&self) -> &'static str { + "pop-char" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let mut val = read_buffer(ctx, &self.buffer); + val.pop(); + if self.buffer == "search" { + vec![Box::new(effect::SetSearchQuery(val))] + } else { + vec![Box::new(effect::SetBuffer { + name: self.buffer.clone(), + value: val, + })] + } + } +} + +/// Handle backspace in command mode — pop char or return to Normal if empty. +#[derive(Debug)] +pub struct CommandModeBackspace; +impl Cmd for CommandModeBackspace { + fn name(&self) -> &'static str { + "command-mode-backspace" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let val = ctx.buffers.get("command").cloned().unwrap_or_default(); + if val.is_empty() { + vec![effect::change_mode(AppMode::Normal)] + } else { + let mut val = val; + val.pop(); + vec![Box::new(effect::SetBuffer { + name: "command".to_string(), + value: val, + })] + } + } +} + +// ── Wizard command ────────────────────────────────────────────────────────── + +/// Dispatch the current key to the import wizard effect. +#[derive(Debug)] +pub struct HandleWizardKey; +impl Cmd for HandleWizardKey { + fn name(&self) -> &'static str { + "handle-wizard-key" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::WizardKey { + key_code: ctx.key_code, + })] + } +} + +// ── Command mode execution ────────────────────────────────────────────────── + +/// Execute the command in the "command" buffer (the `:` command line). +#[derive(Debug)] +/// Execute the `:` command buffer by delegating to the command registry. +/// The `:` prompt is just another frontend to the scripting language — +/// same parser as `improvise script`. +pub struct ExecuteCommand; +impl Cmd for ExecuteCommand { + fn name(&self) -> &'static str { + "execute-command" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let raw = ctx.buffers.get("command").cloned().unwrap_or_default(); + let raw = raw.trim().to_string(); + if raw.is_empty() { + return vec![effect::change_mode(AppMode::Normal)]; + } + + match crate::command::parse::parse_line_with(ctx.registry, &raw) { + Ok(cmds) => { + let mut effects: Vec> = Vec::new(); + for cmd in cmds { + effects.extend(cmd.execute(ctx)); + } + // Return to Normal unless a command already changed mode + if !effects.iter().any(|e| e.changes_mode()) { + effects.push(effect::change_mode(AppMode::Normal)); + } + effects + } + Err(msg) => { + vec![ + effect::set_status(format!(":{raw} — {msg}")), + effect::change_mode(AppMode::Normal), + ] + } + } + } +} diff --git a/src/command/cmd/tile.rs b/src/command/cmd/tile.rs new file mode 100644 index 0000000..0879ed5 --- /dev/null +++ b/src/command/cmd/tile.rs @@ -0,0 +1,82 @@ +use crate::ui::effect::{self, Effect}; +use crate::view::Axis; + +use super::core::{Cmd, CmdContext}; + +// ── Tile select commands ──────────────────────────────────────────────────── + +/// Move the tile select cursor left or right. +#[derive(Debug)] +pub struct MoveTileCursor(pub i32); +impl Cmd for MoveTileCursor { + fn name(&self) -> &'static str { + "move-tile-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let count = ctx.model.category_names().len(); + if count == 0 { + return vec![]; + } + let new = (ctx.tile_cat_idx as i32 + self.0).clamp(0, (count - 1) as i32) as usize; + vec![Box::new(effect::SetTileCatIdx(new))] + } +} + +/// Cycle or set the axis for the category at the tile cursor. +/// Stays in TileSelect mode so the user can adjust multiple tiles. +/// `axis: None` -> cycle, `axis: Some(a)` -> set to `a`. +#[derive(Debug)] +pub struct TileAxisOp { + pub axis: Option, +} + +fn axis_label(axis: Axis) -> &'static str { + match axis { + Axis::Row => "Row", + Axis::Column => "Col", + Axis::Page => "Page", + Axis::None => "None", + } +} + +impl Cmd for TileAxisOp { + fn name(&self) -> &'static str { + if self.axis.is_some() { + "set-axis-for-tile" + } else { + "cycle-axis-for-tile" + } + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + let cat_names = ctx.model.category_names(); + if let Some(name) = cat_names.get(ctx.tile_cat_idx) { + let new_axis = match self.axis { + Some(axis) => axis, + None => { + let current = ctx.model.active_view().axis_of(name); + match current { + Axis::Row => Axis::Column, + Axis::Column => Axis::Page, + Axis::Page => Axis::None, + Axis::None => Axis::Row, + } + } + }; + let axis_effect: Box = match self.axis { + Some(axis) => Box::new(effect::SetAxis { + category: name.to_string(), + axis, + }), + None => Box::new(effect::CycleAxis(name.to_string())), + }; + let status = format!("{} → {}", name, axis_label(new_axis)); + vec![ + axis_effect, + effect::mark_dirty(), + effect::set_status(status), + ] + } else { + vec![] + } + } +}