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 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, /// Pre-resolved cell key at the cursor position (None if out of bounds) pub cell_key: Option, /// Grid dimensions (so commands don't need GridLayout) pub row_count: usize, pub col_count: usize, /// Categories on Axis::None — aggregated away in the current view pub none_cats: Vec, /// View navigation stacks (for drill back/forward) pub view_back_stack: Vec, pub view_forward_stack: Vec, /// The key that triggered this command pub key_code: KeyCode, } /// 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, } 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, } } } /// 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, ) -> 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; if nr < row_offset { row_offset = nr; } if nr >= row_offset + 20 { row_offset = nr.saturating_sub(19); } if nc < col_offset { col_offset = nc; } if nc >= col_offset + 8 { col_offset = nc.saturating_sub(7); } 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 } #[derive(Debug)] pub struct MoveSelection { pub dr: i32, pub dc: i32, pub cursor: CursorState, } impl Cmd for MoveSelection { fn name(&self) -> &'static str { "move-selection" } 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 nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize; let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize; viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset) } } #[derive(Debug)] pub struct JumpToFirstRow { pub col: usize, } impl Cmd for JumpToFirstRow { fn name(&self) -> &'static str { "jump-first-row" } fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![ Box::new(effect::SetSelected(0, self.col)), Box::new(effect::SetRowOffset(0)), ] } } #[derive(Debug)] pub struct JumpToLastRow { pub col: usize, pub row_count: usize, pub row_offset: usize, } impl Cmd for JumpToLastRow { fn name(&self) -> &'static str { "jump-last-row" } fn execute(&self, _ctx: &CmdContext) -> Vec> { let last = self.row_count.saturating_sub(1); let mut effects: Vec> = vec![Box::new(effect::SetSelected(last, self.col))]; if last >= self.row_offset + 20 { effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19)))); } effects } } #[derive(Debug)] pub struct JumpToFirstCol { pub row: usize, } impl Cmd for JumpToFirstCol { fn name(&self) -> &'static str { "jump-first-col" } fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![ Box::new(effect::SetSelected(self.row, 0)), Box::new(effect::SetColOffset(0)), ] } } #[derive(Debug)] pub struct JumpToLastCol { pub row: usize, pub col_count: usize, pub col_offset: usize, } impl Cmd for JumpToLastCol { fn name(&self) -> &'static str { "jump-last-col" } fn execute(&self, _ctx: &CmdContext) -> Vec> { let last = self.col_count.saturating_sub(1); let mut effects: Vec> = vec![Box::new(effect::SetSelected(self.row, last))]; if last >= self.col_offset + 8 { effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7)))); } effects } } #[derive(Debug)] pub struct ScrollRows { pub delta: i32, pub cursor: CursorState, } impl Cmd for ScrollRows { fn name(&self) -> &'static str { "scroll-rows" } fn execute(&self, _ctx: &CmdContext) -> Vec> { let row_max = self.cursor.row_count.saturating_sub(1) as i32; let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize; let mut effects: Vec> = vec![Box::new(effect::SetSelected(nr, self.cursor.col))]; let mut row_offset = self.cursor.row_offset; if nr < row_offset { row_offset = nr; } if nr >= row_offset + 20 { row_offset = nr.saturating_sub(19); } if row_offset != self.cursor.row_offset { effects.push(Box::new(effect::SetRowOffset(row_offset))); } effects } } // ── 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 let buffer_name = match &self.0 { AppMode::CommandMode { .. } => Some("command"), AppMode::Editing { .. } => Some("edit"), AppMode::FormulaEdit { .. } => Some("formula"), AppMode::CategoryAdd { .. } => Some("category"), AppMode::ExportPrompt { .. } => Some("export"), _ => None, }; if let Some(name) = buffer_name { effects.push(Box::new(effect::SetBuffer { name: name.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)] } } #[derive(Debug)] pub struct SaveAndQuit; impl Cmd for SaveAndQuit { fn name(&self) -> &'static str { "save-and-quit" } 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 layout = GridLayout::new(ctx.model, ctx.model.active_view()); let value = ctx.model.evaluate_aggregated(&self.key, &layout.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 currently_open: bool, } impl Cmd for TogglePanelAndFocus { fn name(&self) -> &'static str { "toggle-panel-and-focus" } fn execute(&self, _ctx: &CmdContext) -> Vec> { let new_open = !self.currently_open; let mut effects: Vec> = vec![Box::new(effect::SetPanelOpen { panel: self.panel, open: new_open, })]; if new_open { let mode = match self.panel { Panel::Formula => AppMode::FormulaPanel, Panel::Category => AppMode::CategoryPanel, Panel::View => AppMode::ViewPanel, }; effects.push(effect::change_mode(mode)); } 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 { buffer: String::new(), }), ] } } /// 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) } } /// 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::ExportPrompt { buffer: String::new(), })] } } // ── 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 layout = GridLayout::new(ctx.model, ctx.model.active_view()); let (cur_row, cur_col) = ctx.selected; let total_rows = layout.row_count().max(1); let total_cols = layout.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 layout.cell_key(ri, ci) { Some(k) => k, None => return false, }; let s = match ctx.model.evaluate_aggregated(&key, &layout.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::CategoryAdd { buffer: String::new(), }), ] } } } // ── 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 group collapse under the cursor. #[derive(Debug)] pub struct ToggleGroupUnderCursor; impl Cmd for ToggleGroupUnderCursor { fn name(&self) -> &'static str { "toggle-group-under-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let sel_row = ctx.selected.0; let Some((cat, group)) = layout.row_group_for(sel_row) else { return vec![]; }; vec![ Box::new(effect::ToggleGroup { category: cat, group, }), effect::mark_dirty(), ] } } /// Toggle the column group collapse under the cursor. #[derive(Debug)] pub struct ToggleColGroupUnderCursor; impl Cmd for ToggleColGroupUnderCursor { fn name(&self) -> &'static str { "toggle-col-group-under-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { let layout = GridLayout::new(ctx.model, ctx.model.active_view()); let sel_col = ctx.selected.1; let Some((cat, group)) = layout.col_group_for(sel_col) else { return vec![]; }; // After toggling, col_count may shrink — clamp selection // We return ToggleGroup + MarkDirty; selection clamping will need // to happen in the effect or in a follow-up pass 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 layout = GridLayout::new(ctx.model, ctx.model.active_view()); let Some(cat_name) = layout.row_cats.first().cloned() else { return vec![]; }; let sel_row = ctx.selected.0; let Some(items) = 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 in view history. #[derive(Debug)] pub struct ViewBackCmd; impl Cmd for ViewBackCmd { fn name(&self) -> &'static str { "view-back" } fn execute(&self, ctx: &CmdContext) -> Vec> { if ctx.view_back_stack.is_empty() { vec![effect::set_status("No previous view")] } else { vec![Box::new(effect::ViewBack)] } } } /// Navigate forward in view history. #[derive(Debug)] pub struct ViewForwardCmd; impl Cmd for ViewForwardCmd { fn name(&self) -> &'static str { "view-forward" } fn execute(&self, ctx: &CmdContext) -> Vec> { if ctx.view_forward_stack.is_empty() { vec![effect::set_status("No forward view")] } else { vec![Box::new(effect::ViewForward)] } } } /// Drill down into an aggregated cell: create a _Drill view that shows the /// raw (un-aggregated) data for this cell. Categories on Axis::None in the /// current view become visible (Row + Column) in the drill view; the cell's /// fixed coordinates 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(); // Create (or replace) the drill view effects.push(Box::new(effect::CreateView(drill_name.clone()))); effects.push(Box::new(effect::SwitchView(drill_name))); // All categories currently exist. Set axes: // - none_cats → Row (first) and Column (rest) to expand them // - cell_key cats → Page with their specific items (filter) // - other cats (not in cell_key or none_cats) → Page as well let none_cats = &ctx.none_cats; let fixed_cats: std::collections::HashSet = self.key.0.iter().map(|(c, _)| c.clone()).collect(); for (i, cat) in none_cats.iter().enumerate() { let axis = if i == 0 { crate::view::Axis::Row } else { crate::view::Axis::Column }; effects.push(Box::new(effect::SetAxis { category: cat.clone(), axis, })); } // All other categories → Page, with the cell's value as the page selection for cat_name in ctx.model.category_names() { let cat = cat_name.to_string(); if none_cats.contains(&cat) { continue; } effects.push(Box::new(effect::SetAxis { category: cat.clone(), axis: crate::view::Axis::Page, })); // If this category was in the drilled cell's key, fix its page // selection to the cell's value if fixed_cats.contains(&cat) { if let Some((_, item)) = self.key.0.iter().find(|(c, _)| c == &cat) { effects.push(Box::new(effect::SetPageSelection { category: cat, item: item.clone(), })); } } } effects.push(effect::set_status("Drilled into cell")); 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::FormulaEdit { buffer: String::new(), })] } } /// 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> { let cat_names = ctx.model.category_names(); if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) { vec![Box::new(effect::CycleAxis(cat_name.to_string()))] } 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> { let cat_names = ctx.model.category_names(); if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) { vec![effect::change_mode(AppMode::ItemAdd { category: cat_name.to_string(), buffer: String::new(), })] } else { vec![effect::set_status( "No category selected. Press n to add a category first.", )] } } } // ── 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 the axis for the category at the tile cursor, then return to Normal. #[derive(Debug)] pub struct CycleAxisForTile; impl Cmd for CycleAxisForTile { fn name(&self) -> &'static str { "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) { vec![ Box::new(effect::CycleAxis(name.to_string())), effect::mark_dirty(), effect::change_mode(AppMode::Normal), ] } else { vec![effect::change_mode(AppMode::Normal)] } } } /// Set a specific axis for the category at the tile cursor, then return to Normal. #[derive(Debug)] pub struct SetAxisForTile(pub Axis); impl Cmd for SetAxisForTile { fn name(&self) -> &'static str { "set-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) { vec![ Box::new(effect::SetAxis { category: name.to_string(), axis: self.0, }), 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)] 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(); let (cmd_name, rest) = raw .split_once(char::is_whitespace) .map(|(c, r)| (c, r.trim())) .unwrap_or((raw, "")); // Default: return to Normal let mut effects: Vec> = Vec::new(); match cmd_name { "q" | "quit" => { if ctx.dirty { effects.push(effect::set_status( "Unsaved changes. Use :q! to force quit or :wq to save+quit.", )); } else { effects.push(effect::change_mode(AppMode::Quit)); } } "q!" => { effects.push(effect::change_mode(AppMode::Quit)); } "w" | "write" => { if rest.is_empty() { effects.push(Box::new(effect::Save)); } else { effects.push(Box::new(effect::SaveAs(std::path::PathBuf::from(rest)))); } } "wq" | "x" => { effects.push(Box::new(effect::Save)); effects.push(effect::change_mode(AppMode::Quit)); } "import" => { if rest.is_empty() { effects.push(effect::set_status("Usage: :import ")); } else { effects.push(Box::new(effect::StartImportWizard(rest.to_string()))); } } "export" => { let path = if rest.is_empty() { "export.csv" } else { rest }; effects.push(Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))); } "add-cat" | "add-category" | "cat" => { if rest.is_empty() { effects.push(effect::set_status("Usage: :add-cat ")); } else { effects.push(Box::new(effect::AddCategory(rest.to_string()))); effects.push(effect::mark_dirty()); } } "add-item" | "item" => { let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim(); let item = parts.next().unwrap_or("").trim(); if cat.is_empty() || item.is_empty() { effects.push(effect::set_status("Usage: :add-item ")); } else { effects.push(Box::new(effect::AddItem { category: cat.to_string(), item: item.to_string(), })); effects.push(effect::mark_dirty()); effects.push(effect::set_status("Item added")); } } "add-items" | "items" => { let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim().to_string(); let items_str = parts.next().unwrap_or("").trim().to_string(); if cat.is_empty() || items_str.is_empty() { effects.push(effect::set_status( "Usage: :add-items item1 item2 ...", )); } else { let items: Vec<&str> = items_str.split_whitespace().collect(); let count = items.len(); for item in &items { effects.push(Box::new(effect::AddItem { category: cat.clone(), item: item.to_string(), })); } effects.push(effect::mark_dirty()); effects.push(effect::set_status(format!( "Added {count} items to \"{cat}\".", ))); } } "formula" | "add-formula" => { if rest.is_empty() { effects.push(Box::new(effect::SetPanelOpen { panel: Panel::Formula, open: true, })); effects.push(effect::change_mode(AppMode::FormulaPanel)); return effects; // Don't set mode to Normal } else { let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim(); let formula = parts.next().unwrap_or("").trim(); if cat.is_empty() || formula.is_empty() { effects.push(effect::set_status( "Usage: :formula ", )); } else { effects.push(Box::new(effect::AddFormula { raw: formula.to_string(), target_category: cat.to_string(), })); effects.push(effect::mark_dirty()); effects.push(effect::set_status("Formula added")); } } } "add-view" | "view" => { let name = if rest.is_empty() { format!("View {}", ctx.model.views.len() + 1) } else { rest.to_string() }; effects.push(Box::new(effect::CreateView(name.clone()))); effects.push(Box::new(effect::SwitchView(name))); effects.push(effect::mark_dirty()); } "set-format" | "fmt" => { if rest.is_empty() { effects.push(effect::set_status( "Usage: :set-format e.g. ,.0 ,.2 .4", )); } else { effects.push(Box::new(effect::SetNumberFormat(rest.to_string()))); effects.push(effect::mark_dirty()); effects.push(effect::set_status(format!("Number format set to '{rest}'"))); } } "show-item" | "show" => { let mut parts = rest.splitn(2, char::is_whitespace); let cat = parts.next().unwrap_or("").trim(); let item = parts.next().unwrap_or("").trim(); if cat.is_empty() || item.is_empty() { effects.push(effect::set_status("Usage: :show-item ")); } else { effects.push(Box::new(effect::ShowItem { category: cat.to_string(), item: item.to_string(), })); effects.push(effect::mark_dirty()); effects.push(effect::set_status(format!( "Showed \"{item}\" in \"{cat}\"" ))); } } "help" | "h" => { effects.push(effect::change_mode(AppMode::Help)); return effects; // Don't also set Normal } "" => {} // just pressed Enter with empty buffer other => { effects.push(effect::set_status(format!( "Unknown command: :{other} (try :help)" ))); } } // Default: return to Normal (unless a command already set a different mode) if !effects .iter() .any(|e| format!("{e:?}").contains("ChangeMode")) { effects.push(effect::change_mode(AppMode::Normal)); } effects } } // ── 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); 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(); 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. #[derive(Debug)] pub struct CommitCellEdit { pub key: crate::model::cell::CellKey, pub value: String, } impl Cmd for CommitCellEdit { fn name(&self) -> &'static str { "commit-cell-edit" } fn execute(&self, ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); if self.value.is_empty() { effects.push(Box::new(effect::ClearCell(self.key.clone()))); } else if let Ok(n) = self.value.parse::() { effects.push(Box::new(effect::SetCell( self.key.clone(), CellValue::Number(n), ))); } else { effects.push(Box::new(effect::SetCell( self.key.clone(), CellValue::Text(self.value.clone()), ))); } effects.push(effect::mark_dirty()); effects.push(effect::change_mode(AppMode::Normal)); // Advance cursor down (typewriter-style) let adv = EnterAdvance { cursor: CursorState::from_ctx(ctx), }; effects.extend(adv.execute(ctx)); 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 } } /// 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> { let buf = ctx.buffers.get("category").cloned().unwrap_or_default(); let trimmed = buf.trim().to_string(); let mut effects: Vec> = Vec::new(); if !trimmed.is_empty() { effects.push(Box::new(effect::AddCategory(trimmed.clone()))); effects.push(effect::mark_dirty()); effects.push(effect::set_status(format!("Added category \"{trimmed}\""))); } // Clear buffer for next entry effects.push(Box::new(effect::SetBuffer { name: "category".to_string(), value: String::new(), })); effects } } /// 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 buf = ctx.buffers.get("item").cloned().unwrap_or_default(); let trimmed = buf.trim().to_string(); // Get the category from the mode let category = if let AppMode::ItemAdd { category, .. } = ctx.mode { category.clone() } else { return vec![]; }; let mut effects: Vec> = Vec::new(); if !trimmed.is_empty() { effects.push(Box::new(effect::AddItem { category, item: trimmed.clone(), })); effects.push(effect::mark_dirty()); effects.push(effect::set_status(format!("Added \"{trimmed}\""))); } // Clear buffer for next entry effects.push(Box::new(effect::SetBuffer { name: "item".to_string(), value: String::new(), })); effects } } /// 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))] } } /// Append a character to the search query. #[derive(Debug)] pub struct SearchAppendChar; impl Cmd for SearchAppendChar { fn name(&self) -> &'static str { "search-append-char" } fn execute(&self, ctx: &CmdContext) -> Vec> { if let KeyCode::Char(c) = ctx.key_code { let mut q = ctx.search_query.to_string(); q.push(c); vec![Box::new(effect::SetSearchQuery(q))] } else { vec![] } } } /// Pop the last character from the search query. #[derive(Debug)] pub struct SearchPopChar; impl Cmd for SearchPopChar { fn name(&self) -> &'static str { "search-pop-char" } fn execute(&self, ctx: &CmdContext) -> Vec> { let mut q = ctx.search_query.to_string(); q.pop(); vec![Box::new(effect::SetSearchQuery(q))] } } /// 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!( 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); // ── Navigation ─────────────────────────────────────────────────────── r.register( &MoveSelection { dr: 0, dc: 0, cursor: CursorState::default() }, |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(MoveSelection { dr, dc, cursor: CursorState { row: 0, col: 0, row_count: 0, col_count: 0, row_offset: 0, col_offset: 0, }, })) }, |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(MoveSelection { dr, dc, cursor: CursorState::from_ctx(ctx), })) }, ); r.register( &JumpToFirstRow { col: 0 }, |_| Ok(Box::new(JumpToFirstRow { col: 0 })), |_, ctx| { Ok(Box::new(JumpToFirstRow { col: ctx.selected.1, })) }, ); r.register( &JumpToLastRow { col: 0, row_count: 0, row_offset: 0 }, |_| { Ok(Box::new(JumpToLastRow { col: 0, row_count: 0, row_offset: 0, })) }, |_, ctx| { Ok(Box::new(JumpToLastRow { col: ctx.selected.1, row_count: ctx.row_count, row_offset: ctx.row_offset, })) }, ); r.register( &JumpToFirstCol { row: 0 }, |_| Ok(Box::new(JumpToFirstCol { row: 0 })), |_, ctx| { Ok(Box::new(JumpToFirstCol { row: ctx.selected.0, })) }, ); r.register( &JumpToLastCol { row: 0, col_count: 0, col_offset: 0 }, |_| { Ok(Box::new(JumpToLastCol { row: 0, col_count: 0, col_offset: 0, })) }, |_, ctx| { Ok(Box::new(JumpToLastCol { row: ctx.selected.0, col_count: ctx.col_count, col_offset: ctx.col_offset, })) }, ); r.register( &ScrollRows { delta: 0, cursor: CursorState::default() }, |args| { require_args("scroll-rows", args, 1)?; let n = args[0].parse::().map_err(|e| e.to_string())?; Ok(Box::new(ScrollRows { delta: n, cursor: CursorState { row: 0, col: 0, row_count: 0, col_count: 0, row_offset: 0, col_offset: 0, }, })) }, |args, ctx| { require_args("scroll-rows", args, 1)?; let n = args[0].parse::().map_err(|e| e.to_string())?; Ok(Box::new(ScrollRows { delta: n, cursor: CursorState::from_ctx(ctx), })) }, ); 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, }, })) }, |_, 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(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| { let current = ctx .cell_key .as_ref() .and_then(|k| ctx.model.get_cell(k).cloned()) .map(|v| v.to_string()) .unwrap_or_default(); Ok(Box::new(EnterEditMode { initial_value: current, })) }, ); 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(ViewBackCmd)); r.register_nullary(|| Box::new(ViewForwardCmd)); 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::CommandMode { buffer: String::new(), }, "category-add" => AppMode::CategoryAdd { buffer: String::new(), }, "editing" => AppMode::Editing { buffer: String::new(), }, "formula-edit" => AppMode::FormulaEdit { buffer: String::new(), }, "export-prompt" => AppMode::ExportPrompt { buffer: String::new(), }, 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)); r.register_nullary(|| Box::new(SearchAppendChar)); r.register_nullary(|| Box::new(SearchPopChar)); // ── Panel operations ───────────────────────────────────────────────── r.register( &TogglePanelAndFocus { panel: Panel::Formula, currently_open: false }, |args| { require_args("toggle-panel-and-focus", args, 1)?; let panel = parse_panel(&args[0])?; Ok(Box::new(TogglePanelAndFocus { panel, currently_open: false, })) }, |args, ctx| { require_args("toggle-panel-and-focus", 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(TogglePanelAndFocus { panel, currently_open, })) }, ); 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.model.category_names().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(CycleAxisAtCursor)); r.register_nullary(|| Box::new(OpenItemAddAtCursor)); 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(CycleAxisForTile)); 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(SetAxisForTile(axis))) }); // ── Grid operations ────────────────────────────────────────────────── r.register_nullary(|| { Box::new(ToggleGroupUnderCursor) }); r.register_nullary(|| { Box::new(ToggleColGroupUnderCursor) }); 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( &CommitCellEdit { key: CellKey::new(vec![]), value: String::new() }, |args| { // parse: commit-cell-edit ... if args.len() < 2 { return Err("commit-cell-edit requires a value and coords".into()); } Ok(Box::new(CommitCellEdit { key: parse_cell_key_from_args(&args[1..]), value: args[0].clone(), })) }, |_args, ctx| { let key = ctx.cell_key.clone().ok_or("no cell at cursor")?; let value = read_buffer(ctx, "edit"); Ok(Box::new(CommitCellEdit { key, value })) }, ); 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); fn make_ctx(model: &Model) -> CmdContext<'_> { let view = model.active_view(); let layout = GridLayout::new(model, view); let (sr, sc) = view.selected; CmdContext { model, 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, none_cats: layout.none_cats.clone(), view_back_stack: Vec::new(), view_forward_stack: Vec::new(), cell_key: layout.cell_key(sr, sc), row_count: layout.row_count(), col_count: layout.col_count(), 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 ctx = make_ctx(&m); let cmd = MoveSelection { dr: 1, dc: 0, cursor: CursorState::from_ctx(&ctx), }; 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 ctx = make_ctx(&m); // Try to move way past the end let cmd = MoveSelection { dr: 100, dc: 100, cursor: CursorState::from_ctx(&ctx), }; 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 mut ctx = make_ctx(&m); 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 mut ctx = make_ctx(&m); ctx.buffers = &bufs; let cmd = ExecuteCommand; let effects = cmd.execute(&ctx); let dbg = format!("{:?}", effects); assert!( dbg.contains("ChangeMode"), "Expected ChangeMode, got: {dbg}" ); } #[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 ctx = make_ctx(&m); 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 ctx = make_ctx(&m); 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_and_focus_opens_and_enters_mode() { let m = two_cat_model(); let ctx = make_ctx(&m); let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, currently_open: false, }; 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_and_focus_closes_when_open() { let m = two_cat_model(); let mut ctx = make_ctx(&m); ctx.formula_panel_open = true; let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, currently_open: true, }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); // SetPanelOpen only, no mode change } #[test] fn enter_advance_moves_down() { let m = two_cat_model(); let ctx = make_ctx(&m); 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 ctx = make_ctx(&m); 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 ctx = make_ctx(&m); 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 ctx = make_ctx(&m); 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() { let m = Model::new("Empty"); let ctx = make_ctx(&m); let cmd = EnterTileSelect; let effects = cmd.execute(&ctx); assert!(effects.is_empty()); } #[test] fn toggle_group_under_cursor_returns_empty_without_groups() { let m = two_cat_model(); let ctx = make_ctx(&m); let cmd = ToggleGroupUnderCursor; 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 ctx = make_ctx(&m); 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 ctx = make_ctx(&m); 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 mut ctx = make_ctx(&m); 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}" ); } }