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, } impl CmdRegistry { pub fn new() -> Self { Self { entries: Vec::new(), } } /// 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> { 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> { 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, 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![] } } } /// 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, then return to Normal. /// `axis: None` → cycle, `axis: Some(a)` → set to `a`. #[derive(Debug)] pub struct TileAxisOp { pub axis: Option, } 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 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())), }; vec![ axis_effect, effect::mark_dirty(), effect::change_mode(AppMode::Normal), ] } else { vec![effect::change_mode(AppMode::Normal)] } } } // ── 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 .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!( 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::change_mode(AppMode::Help)] } ); 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(&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); // ── 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)); 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}" ); } }