From 35946afc91c2bafbc7c2dc54968341d6d9fcb6fa Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 4 Apr 2026 12:14:03 -0700 Subject: [PATCH] refactor(command): pre-resolve cell key and grid dimensions in CmdContext Refactor command system to pre-resolve cell key and grid dimensions in CmdContext, eliminating repeated GridLayout construction. Key changes: - Add cell_key, row_count, col_count to CmdContext - Replace generic CmdRegistry::register with register/register_pure/register_nullary - Cell commands (clear-cell, yank, paste) now take explicit CellKey - Update keymap dispatch to use new interactive() method - Special-case "search" buffer in SetBuffer effect - Update tests to populate new context fields This reduces code duplication and makes command execution more efficient by computing layout once at context creation time. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M) --- src/command/cmd.rs | 876 +++++++++++++++++++++++++++--------------- src/command/keymap.rs | 4 +- src/ui/app.rs | 29 +- src/ui/effect.rs | 7 +- 4 files changed, 603 insertions(+), 313 deletions(-) diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 2bb04c9..90ae74d 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -32,6 +32,11 @@ pub struct CmdContext<'a> { 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, /// The key that triggered this command pub key_code: KeyCode, pub key_modifiers: KeyModifiers, @@ -43,12 +48,28 @@ pub trait Cmd: Debug + Send + Sync { fn name(&self) -> &str; } -/// A factory that parses string arguments into a boxed Cmd. +/// Factory that constructs a Cmd from text arguments (headless/script). pub type ParseFn = fn(&[String]) -> Result, String>; -/// Registry of commands that can be constructed from text arguments. +/// 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. pub struct CmdRegistry { - entries: Vec<(&'static str, ParseFn)>, + entries: Vec, } impl CmdRegistry { @@ -58,21 +79,67 @@ impl CmdRegistry { } } - pub fn register(&mut self, name: &'static str, parse: ParseFn) { - self.entries.push((name, parse)); + /// Register a command with both a text parser and an interactive constructor. + pub fn register( + &mut self, + name: &'static str, + parse: ParseFn, + interactive: InteractiveFn, + ) { + self.entries.push(CmdEntry { + name, + parse: Box::new(parse), + interactive: Box::new(interactive), + }); } + /// Register a command that takes no context — same constructor for both paths. + pub fn register_pure(&mut self, name: &'static str, parse: ParseFn) { + self.entries.push(CmdEntry { + name, + parse: Box::new(parse), + interactive: Box::new(|_args, _ctx| Err("not available interactively without args".into())), + }); + } + + /// Register a zero-arg command (same instance for parse and interactive). + pub fn register_nullary(&mut self, name: &'static str, f: fn() -> Box) { + 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 (n, f) in &self.entries { - if *n == name { - return f(args); + 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}")) } pub fn names(&self) -> impl Iterator + '_ { - self.entries.iter().map(|(n, _)| *n) + self.entries.iter().map(|e| e.name) } } @@ -87,12 +154,82 @@ fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> { } } +/// 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)] +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 { @@ -100,67 +237,46 @@ impl Cmd for MoveSelection { "move-selection" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let row_max = layout.row_count().saturating_sub(1); - let col_max = layout.col_count().saturating_sub(1); - let (r, c) = ctx.selected; - let nr = (r as i32 + self.dr).clamp(0, row_max as i32) as usize; - let nc = (c as i32 + self.dc).clamp(0, col_max as i32) as usize; - - let mut effects: Vec> = vec![effect::set_selected(nr, nc)]; - - // Keep cursor in visible area (approximate viewport: 20 rows, 8 cols) - let mut row_offset = ctx.row_offset; - let mut col_offset = ctx.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 != ctx.row_offset { - effects.push(Box::new(effect::SetRowOffset(row_offset))); - } - if col_offset != ctx.col_offset { - effects.push(Box::new(effect::SetColOffset(col_offset))); - } - effects + 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 struct JumpToFirstRow { + pub col: usize, +} impl Cmd for JumpToFirstRow { fn name(&self) -> &str { "jump-first-row" } - fn execute(&self, ctx: &CmdContext) -> Vec> { + fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![ - Box::new(effect::SetSelected(0, ctx.selected.1)), + Box::new(effect::SetSelected(0, self.col)), Box::new(effect::SetRowOffset(0)), ] } } #[derive(Debug)] -pub struct JumpToLastRow; +pub struct JumpToLastRow { + pub col: usize, + pub row_count: usize, + pub row_offset: usize, +} impl Cmd for JumpToLastRow { fn name(&self) -> &str { "jump-last-row" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let last = layout.row_count().saturating_sub(1); + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let last = self.row_count.saturating_sub(1); let mut effects: Vec> = - vec![Box::new(effect::SetSelected(last, ctx.selected.1))]; - if last >= ctx.row_offset + 20 { + 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 @@ -168,31 +284,36 @@ impl Cmd for JumpToLastRow { } #[derive(Debug)] -pub struct JumpToFirstCol; +pub struct JumpToFirstCol { + pub row: usize, +} impl Cmd for JumpToFirstCol { fn name(&self) -> &str { "jump-first-col" } - fn execute(&self, ctx: &CmdContext) -> Vec> { + fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![ - Box::new(effect::SetSelected(ctx.selected.0, 0)), + Box::new(effect::SetSelected(self.row, 0)), Box::new(effect::SetColOffset(0)), ] } } #[derive(Debug)] -pub struct JumpToLastCol; +pub struct JumpToLastCol { + pub row: usize, + pub col_count: usize, + pub col_offset: usize, +} impl Cmd for JumpToLastCol { fn name(&self) -> &str { "jump-last-col" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let last = layout.col_count().saturating_sub(1); + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let last = self.col_count.saturating_sub(1); let mut effects: Vec> = - vec![Box::new(effect::SetSelected(ctx.selected.0, last))]; - if last >= ctx.col_offset + 8 { + 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 @@ -200,25 +321,27 @@ impl Cmd for JumpToLastCol { } #[derive(Debug)] -pub struct ScrollRows(pub i32); +pub struct ScrollRows { + pub delta: i32, + pub cursor: CursorState, +} impl Cmd for ScrollRows { fn name(&self) -> &str { "scroll-rows" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let row_max = layout.row_count().saturating_sub(1) as i32; - let nr = (ctx.selected.0 as i32 + self.0).clamp(0, row_max) as usize; + 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, ctx.selected.1))]; - let mut row_offset = ctx.row_offset; + 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 != ctx.row_offset { + if row_offset != self.cursor.row_offset { effects.push(Box::new(effect::SetRowOffset(row_offset))); } effects @@ -261,56 +384,60 @@ impl Cmd for SaveAndQuit { } // ── 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 ClearSelectedCell; -impl Cmd for ClearSelectedCell { +pub struct ClearCellCommand { + pub key: crate::model::cell::CellKey, +} +impl Cmd for ClearCellCommand { fn name(&self) -> &str { "clear-cell" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let (ri, ci) = ctx.selected; - if let Some(key) = layout.cell_key(ri, ci) { - vec![Box::new(effect::ClearCell(key)), effect::mark_dirty()] - } else { - vec![] - } + 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 struct YankCell { + pub key: crate::model::cell::CellKey, +} impl Cmd for YankCell { fn name(&self) -> &str { "yank" } fn execute(&self, ctx: &CmdContext) -> Vec> { let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let (ri, ci) = ctx.selected; - if let Some(key) = layout.cell_key(ri, ci) { - let value = ctx.model.evaluate_aggregated(&key, &layout.none_cats); - vec![ - Box::new(effect::SetYanked(value)), - effect::set_status("Yanked"), - ] - } else { - vec![] - } + 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 struct PasteCell { + pub key: crate::model::cell::CellKey, +} impl Cmd for PasteCell { fn name(&self) -> &str { "paste" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let (ri, ci) = ctx.selected; - if let (Some(key), Some(value)) = (layout.cell_key(ri, ci), ctx.yanked.clone()) { - vec![Box::new(effect::SetCell(key, value)), effect::mark_dirty()] + if let Some(value) = ctx.yanked.clone() { + vec![ + Box::new(effect::SetCell(self.key.clone(), value)), + effect::mark_dirty(), + ] } else { vec![] } @@ -361,24 +488,22 @@ impl Cmd for EnterSearchMode { /// Toggle a panel's visibility; if it opens, focus it (enter its mode). #[derive(Debug)] -pub struct TogglePanelAndFocus(pub Panel); +pub struct TogglePanelAndFocus { + pub panel: Panel, + pub currently_open: bool, +} impl Cmd for TogglePanelAndFocus { fn name(&self) -> &str { "toggle-panel-and-focus" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let currently_open = match self.0 { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; - let new_open = !currently_open; + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let new_open = !self.currently_open; let mut effects: Vec> = vec![Box::new(effect::SetPanelOpen { - panel: self.0, + panel: self.panel, open: new_open, })]; if new_open { - let mode = match self.0 { + let mode = match self.panel { Panel::Formula => AppMode::FormulaPanel, Panel::Category => AppMode::CategoryPanel, Panel::View => AppMode::ViewPanel, @@ -391,37 +516,39 @@ impl Cmd for TogglePanelAndFocus { /// Toggle a panel's visibility without changing mode. #[derive(Debug)] -pub struct TogglePanelVisibility(pub Panel); +pub struct TogglePanelVisibility { + pub panel: Panel, + pub currently_open: bool, +} impl Cmd for TogglePanelVisibility { fn name(&self) -> &str { "toggle-panel-visibility" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let currently_open = match self.0 { - Panel::Formula => ctx.formula_panel_open, - Panel::Category => ctx.category_panel_open, - Panel::View => ctx.view_panel_open, - }; + fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![Box::new(effect::SetPanelOpen { - panel: self.0, - open: !currently_open, + panel: self.panel, + open: !self.currently_open, })] } } /// Tab through open panels, entering the first open panel's mode. #[derive(Debug)] -pub struct CyclePanelFocus; +pub struct CyclePanelFocus { + pub formula_open: bool, + pub category_open: bool, + pub view_open: bool, +} impl Cmd for CyclePanelFocus { fn name(&self) -> &str { "cycle-panel-focus" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - if ctx.formula_panel_open { + fn execute(&self, _ctx: &CmdContext) -> Vec> { + if self.formula_open { vec![effect::change_mode(AppMode::FormulaPanel)] - } else if ctx.category_panel_open { + } else if self.category_open { vec![effect::change_mode(AppMode::CategoryPanel)] - } else if ctx.view_panel_open { + } else if self.view_open { vec![effect::change_mode(AppMode::ViewPanel)] } else { vec![] @@ -431,25 +558,20 @@ impl Cmd for CyclePanelFocus { // ── Editing entry ─────────────────────────────────────────────────────── -/// Read the current cell value and enter Editing mode with it as the buffer. +/// Enter editing mode with an initial buffer value. #[derive(Debug)] -pub struct EnterEditMode; +pub struct EnterEditMode { + pub initial_value: String, +} impl Cmd for EnterEditMode { fn name(&self) -> &str { "enter-edit-mode" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let (ri, ci) = ctx.selected; - let current = layout - .cell_key(ri, ci) - .and_then(|k| ctx.model.get_cell(&k).cloned()) - .map(|v| v.to_string()) - .unwrap_or_default(); + fn execute(&self, _ctx: &CmdContext) -> Vec> { vec![ Box::new(effect::SetBuffer { name: "edit".to_string(), - value: current, + value: self.initial_value.clone(), }), effect::change_mode(AppMode::Editing { buffer: String::new(), @@ -460,16 +582,17 @@ impl Cmd for EnterEditMode { /// Typewriter-style advance: move down, wrap to top of next column at bottom. #[derive(Debug)] -pub struct EnterAdvance; +pub struct EnterAdvance { + pub cursor: CursorState, +} impl Cmd for EnterAdvance { fn name(&self) -> &str { "enter-advance" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let row_max = layout.row_count().saturating_sub(1); - let col_max = layout.col_count().saturating_sub(1); - let (r, c) = ctx.selected; + 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 { @@ -477,28 +600,7 @@ impl Cmd for EnterAdvance { } else { (r, c) // already at bottom-right; stay }; - let mut effects: Vec> = vec![effect::set_selected(nr, nc)]; - let mut row_offset = ctx.row_offset; - let mut col_offset = ctx.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 != ctx.row_offset { - effects.push(Box::new(effect::SetRowOffset(row_offset))); - } - if col_offset != ctx.col_offset { - effects.push(Box::new(effect::SetColOffset(col_offset))); - } - effects + viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset) } } @@ -851,17 +953,16 @@ impl Cmd for EnterTileSelect { pub struct MovePanelCursor { pub panel: Panel, pub delta: i32, + pub current: usize, + pub max: usize, } impl Cmd for MovePanelCursor { fn name(&self) -> &str { "move-panel-cursor" } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let (cursor, max) = match self.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()), - }; + fn execute(&self, _ctx: &CmdContext) -> Vec> { + let cursor = self.current; + let max = self.max; if max == 0 { return vec![]; } @@ -1305,6 +1406,15 @@ impl Cmd for ExecuteCommand { // ── 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 { @@ -1316,7 +1426,7 @@ impl Cmd for AppendChar { } fn execute(&self, ctx: &CmdContext) -> Vec> { if let KeyCode::Char(c) = ctx.key_code { - let mut val = ctx.buffers.get(&self.buffer).cloned().unwrap_or_default(); + let mut val = read_buffer(ctx, &self.buffer); val.push(c); vec![Box::new(effect::SetBuffer { name: self.buffer.clone(), @@ -1338,7 +1448,7 @@ impl Cmd for PopChar { "pop-char" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let mut val = ctx.buffers.get(&self.buffer).cloned().unwrap_or_default(); + let mut val = read_buffer(ctx, &self.buffer); val.pop(); vec![Box::new(effect::SetBuffer { name: self.buffer.clone(), @@ -1349,33 +1459,36 @@ impl Cmd for PopChar { // ── Commit commands (mode-specific buffer consumers) ──────────────────────── -/// Commit a cell edit: parse buffer, set cell, advance cursor, return to Normal. +/// Commit a cell edit: set cell value, advance cursor, return to Normal. #[derive(Debug)] -pub struct CommitCellEdit; +pub struct CommitCellEdit { + pub key: crate::model::cell::CellKey, + pub value: String, +} impl Cmd for CommitCellEdit { fn name(&self) -> &str { "commit-cell-edit" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let buf = ctx.buffers.get("edit").cloned().unwrap_or_default(); - let layout = GridLayout::new(ctx.model, ctx.model.active_view()); - let (ri, ci) = ctx.selected; - let mut effects: Vec> = Vec::new(); - if let Some(key) = layout.cell_key(ri, ci) { - if buf.is_empty() { - effects.push(Box::new(effect::ClearCell(key))); - } else if let Ok(n) = buf.parse::() { - effects.push(Box::new(effect::SetCell(key, CellValue::Number(n)))); - } else { - effects.push(Box::new(effect::SetCell(key, CellValue::Text(buf)))); - } - effects.push(effect::mark_dirty()); + 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; + let adv = EnterAdvance { cursor: CursorState::from_ctx(ctx) }; effects.extend(adv.execute(ctx)); effects } @@ -1643,29 +1756,6 @@ effect_cmd!( } ); -effect_cmd!( - ClearCellCmd, - "clear-cell", - |args: &[String]| { - if args.is_empty() { - Err("clear-cell requires at least one Cat/Item coordinate".to_string()) - } else { - Ok(()) - } - }, - |args: &Vec, _ctx: &CmdContext| -> Vec> { - let coords: Vec<(String, String)> = args - .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::ClearCell(key))] - } -); - effect_cmd!( AddFormulaCmd, "add-formula", @@ -1842,64 +1932,160 @@ pub fn default_registry() -> CmdRegistry { let mut r = CmdRegistry::new(); // ── Model mutations (effect_cmd! wrappers) ─────────────────────────── - r.register("add-category", AddCategoryCmd::parse); - r.register("add-item", AddItemCmd::parse); - r.register("add-item-in-group", AddItemInGroupCmd::parse); - r.register("set-cell", SetCellCmd::parse); - r.register("clear-cell", ClearCellCmd::parse); - r.register("add-formula", AddFormulaCmd::parse); - r.register("remove-formula", RemoveFormulaCmd::parse); - r.register("create-view", CreateViewCmd::parse); - r.register("delete-view", DeleteViewCmd::parse); - r.register("switch-view", SwitchViewCmd::parse); - r.register("set-axis", SetAxisCmd::parse); - r.register("set-page", SetPageCmd::parse); - r.register("toggle-group", ToggleGroupCmd::parse); - r.register("hide-item", HideItemCmd::parse); - r.register("show-item", ShowItemCmd::parse); - r.register("save-as", SaveAsCmd::parse); - r.register("load", LoadModelCmd::parse); - r.register("export-csv", ExportCsvCmd::parse); - r.register("import-json", ImportJsonCmd::parse); + r.register_pure("add-category", AddCategoryCmd::parse); + r.register_pure("add-item", AddItemCmd::parse); + r.register_pure("add-item-in-group", AddItemInGroupCmd::parse); + r.register_pure("set-cell", SetCellCmd::parse); + r.register( + "clear-cell", + |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("add-formula", AddFormulaCmd::parse); + r.register_pure("remove-formula", RemoveFormulaCmd::parse); + r.register_pure("create-view", CreateViewCmd::parse); + r.register_pure("delete-view", DeleteViewCmd::parse); + r.register_pure("switch-view", SwitchViewCmd::parse); + r.register_pure("set-axis", SetAxisCmd::parse); + r.register_pure("set-page", SetPageCmd::parse); + r.register_pure("toggle-group", ToggleGroupCmd::parse); + r.register_pure("hide-item", HideItemCmd::parse); + r.register_pure("show-item", ShowItemCmd::parse); + r.register_pure("save-as", SaveAsCmd::parse); + r.register_pure("load", LoadModelCmd::parse); + r.register_pure("export-csv", ExportCsvCmd::parse); + r.register_pure("import-json", ImportJsonCmd::parse); // ── Navigation ─────────────────────────────────────────────────────── - r.register("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(MoveSelection { dr, dc })) - }); - r.register("jump-first-row", |_| Ok(Box::new(JumpToFirstRow))); - r.register("jump-last-row", |_| Ok(Box::new(JumpToLastRow))); - r.register("jump-first-col", |_| Ok(Box::new(JumpToFirstCol))); - r.register("jump-last-col", |_| Ok(Box::new(JumpToLastCol))); - r.register("scroll-rows", |args| { - require_args("scroll-rows", args, 1)?; - let n = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(ScrollRows(n))) - }); - r.register("enter-advance", |_| Ok(Box::new(EnterAdvance))); + r.register( + "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(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( + "jump-first-row", + |_| Ok(Box::new(JumpToFirstRow { col: 0 })), + |_, ctx| Ok(Box::new(JumpToFirstRow { col: ctx.selected.1 })), + ); + r.register( + "jump-last-row", + |_| 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( + "jump-first-col", + |_| Ok(Box::new(JumpToFirstCol { row: 0 })), + |_, ctx| Ok(Box::new(JumpToFirstCol { row: ctx.selected.0 })), + ); + r.register( + "jump-last-col", + |_| 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( + "scroll-rows", + |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( + "enter-advance", + |_| 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("yank", |_| Ok(Box::new(YankCell))); - r.register("paste", |_| Ok(Box::new(PasteCell))); - r.register("clear-selected-cell", |_| Ok(Box::new(ClearSelectedCell))); + r.register( + "yank", + |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( + "paste", + |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("transpose", |_| Ok(Box::new(TransposeAxes))); - r.register("page-next", |_| Ok(Box::new(PageNext))); - r.register("page-prev", |_| Ok(Box::new(PagePrev))); + r.register_nullary("transpose", || Box::new(TransposeAxes)); + r.register_nullary("page-next", || Box::new(PageNext)); + r.register_nullary("page-prev", || Box::new(PagePrev)); // ── Mode changes ───────────────────────────────────────────────────── - r.register("force-quit", |_| Ok(Box::new(ForceQuit))); - r.register("save-and-quit", |_| Ok(Box::new(SaveAndQuit))); - r.register("save", |_| Ok(Box::new(SaveCmd))); - r.register("search", |_| Ok(Box::new(EnterSearchMode))); - r.register("enter-edit-mode", |_| Ok(Box::new(EnterEditMode))); - r.register("enter-export-prompt", |_| Ok(Box::new(EnterExportPrompt))); - r.register("enter-formula-edit", |_| Ok(Box::new(EnterFormulaEdit))); - r.register("enter-tile-select", |_| Ok(Box::new(EnterTileSelect))); - r.register("enter-mode", |args| { + r.register_nullary("force-quit", || Box::new(ForceQuit)); + r.register_nullary("save-and-quit", || Box::new(SaveAndQuit)); + r.register_nullary("save", || Box::new(SaveCmd)); + r.register_nullary("search", || Box::new(EnterSearchMode)); + r.register( + "enter-edit-mode", + |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("enter-export-prompt", || Box::new(EnterExportPrompt)); + r.register_nullary("enter-formula-edit", || Box::new(EnterFormulaEdit)); + r.register_nullary("enter-tile-select", || Box::new(EnterTileSelect)); + r.register_pure("enter-mode", |args| { require_args("enter-mode", args, 1)?; let mode = match args[0].as_str() { "normal" => AppMode::Normal, @@ -1919,79 +2105,144 @@ pub fn default_registry() -> CmdRegistry { }); // ── Search ─────────────────────────────────────────────────────────── - r.register("search-navigate", |args| { + r.register_pure("search-navigate", |args| { let forward = args.first().map(|s| s != "backward").unwrap_or(true); Ok(Box::new(SearchNavigate(forward))) }); - r.register("search-or-category-add", |_| Ok(Box::new(SearchOrCategoryAdd))); - r.register("exit-search-mode", |_| Ok(Box::new(ExitSearchMode))); - r.register("search-append-char", |_| Ok(Box::new(SearchAppendChar))); - r.register("search-pop-char", |_| Ok(Box::new(SearchPopChar))); + r.register_nullary("search-or-category-add", || Box::new(SearchOrCategoryAdd)); + r.register_nullary("exit-search-mode", || Box::new(ExitSearchMode)); + r.register_nullary("search-append-char", || Box::new(SearchAppendChar)); + r.register_nullary("search-pop-char", || Box::new(SearchPopChar)); // ── Panel operations ───────────────────────────────────────────────── - r.register("toggle-panel-and-focus", |args| { - require_args("toggle-panel-and-focus", args, 1)?; - let panel = parse_panel(&args[0])?; - Ok(Box::new(TogglePanelAndFocus(panel))) - }); - r.register("toggle-panel-visibility", |args| { - require_args("toggle-panel-visibility", args, 1)?; - let panel = parse_panel(&args[0])?; - Ok(Box::new(TogglePanelVisibility(panel))) - }); - r.register("cycle-panel-focus", |_| Ok(Box::new(CyclePanelFocus))); - r.register("move-panel-cursor", |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 })) - }); - r.register("delete-formula-at-cursor", |_| Ok(Box::new(DeleteFormulaAtCursor))); - r.register("cycle-axis-at-cursor", |_| Ok(Box::new(CycleAxisAtCursor))); - r.register("open-item-add-at-cursor", |_| Ok(Box::new(OpenItemAddAtCursor))); - r.register("switch-view-at-cursor", |_| Ok(Box::new(SwitchViewAtCursor))); - r.register("create-and-switch-view", |_| Ok(Box::new(CreateAndSwitchView))); - r.register("delete-view-at-cursor", |_| Ok(Box::new(DeleteViewAtCursor))); + r.register( + "toggle-panel-and-focus", + |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( + "toggle-panel-visibility", + |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( + "cycle-panel-focus", + |_| 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( + "move-panel-cursor", + |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("delete-formula-at-cursor", || Box::new(DeleteFormulaAtCursor)); + r.register_nullary("cycle-axis-at-cursor", || Box::new(CycleAxisAtCursor)); + r.register_nullary("open-item-add-at-cursor", || Box::new(OpenItemAddAtCursor)); + r.register_nullary("switch-view-at-cursor", || Box::new(SwitchViewAtCursor)); + r.register_nullary("create-and-switch-view", || Box::new(CreateAndSwitchView)); + r.register_nullary("delete-view-at-cursor", || Box::new(DeleteViewAtCursor)); // ── Tile select ────────────────────────────────────────────────────── - r.register("move-tile-cursor", |args| { + r.register_pure("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("cycle-axis-for-tile", |_| Ok(Box::new(CycleAxisForTile))); - r.register("set-axis-for-tile", |args| { + r.register_nullary("cycle-axis-for-tile", || Box::new(CycleAxisForTile)); + r.register_pure("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("toggle-group-under-cursor", |_| Ok(Box::new(ToggleGroupUnderCursor))); - r.register("toggle-col-group-under-cursor", |_| Ok(Box::new(ToggleColGroupUnderCursor))); - r.register("hide-selected-row-item", |_| Ok(Box::new(HideSelectedRowItem))); + r.register_nullary("toggle-group-under-cursor", || Box::new(ToggleGroupUnderCursor)); + r.register_nullary("toggle-col-group-under-cursor", || Box::new(ToggleColGroupUnderCursor)); + r.register_nullary("hide-selected-row-item", || Box::new(HideSelectedRowItem)); // ── Text buffer ────────────────────────────────────────────────────── - r.register("append-char", |args| { + r.register_pure("append-char", |args| { require_args("append-char", args, 1)?; Ok(Box::new(AppendChar { buffer: args[0].clone() })) }); - r.register("pop-char", |args| { + r.register_pure("pop-char", |args| { require_args("pop-char", args, 1)?; Ok(Box::new(PopChar { buffer: args[0].clone() })) }); - r.register("command-mode-backspace", |_| Ok(Box::new(CommandModeBackspace))); + r.register_nullary("command-mode-backspace", || Box::new(CommandModeBackspace)); // ── Commit ─────────────────────────────────────────────────────────── - r.register("commit-cell-edit", |_| Ok(Box::new(CommitCellEdit))); - r.register("commit-formula", |_| Ok(Box::new(CommitFormula))); - r.register("commit-category-add", |_| Ok(Box::new(CommitCategoryAdd))); - r.register("commit-item-add", |_| Ok(Box::new(CommitItemAdd))); - r.register("commit-export", |_| Ok(Box::new(CommitExport))); - r.register("execute-command", |_| Ok(Box::new(ExecuteCommand))); + r.register( + "commit-cell-edit", + |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("commit-formula", || Box::new(CommitFormula)); + r.register_nullary("commit-category-add", || Box::new(CommitCategoryAdd)); + r.register_nullary("commit-item-add", || Box::new(CommitItemAdd)); + r.register_nullary("commit-export", || Box::new(CommitExport)); + r.register_nullary("execute-command", || Box::new(ExecuteCommand)); // ── Wizard ─────────────────────────────────────────────────────────── - r.register("handle-wizard-key", |_| Ok(Box::new(HandleWizardKey))); + r.register_nullary("handle-wizard-key", || Box::new(HandleWizardKey)); r } @@ -2026,6 +2277,8 @@ mod tests { 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, @@ -2045,6 +2298,9 @@ mod tests { view_panel_cursor: 0, tile_cat_idx: 0, buffers: &EMPTY_BUFFERS, + cell_key: layout.cell_key(sr, sc), + row_count: layout.row_count(), + col_count: layout.col_count(), key_code: KeyCode::Null, key_modifiers: KeyModifiers::NONE, } @@ -2065,7 +2321,7 @@ mod tests { fn move_selection_down_produces_set_selected() { let m = two_cat_model(); let ctx = make_ctx(&m); - let cmd = MoveSelection { dr: 1, dc: 0 }; + 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()); @@ -2076,7 +2332,7 @@ mod tests { let m = two_cat_model(); let ctx = make_ctx(&m); // Try to move way past the end - let cmd = MoveSelection { dr: 100, dc: 100 }; + let cmd = MoveSelection { dr: 100, dc: 100, cursor: CursorState::from_ctx(&ctx) }; let effects = cmd.execute(&ctx); assert!(!effects.is_empty()); } @@ -2120,7 +2376,9 @@ mod tests { ]); m.set_cell(key, CellValue::Number(42.0)); let ctx = make_ctx(&m); - let cmd = ClearSelectedCell; + let cmd = ClearCellCommand { + key: ctx.cell_key.clone().unwrap(), + }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // ClearCell + MarkDirty } @@ -2134,7 +2392,9 @@ mod tests { ]); m.set_cell(key, CellValue::Number(99.0)); let ctx = make_ctx(&m); - let cmd = YankCell; + let cmd = YankCell { + key: ctx.cell_key.clone().unwrap(), + }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetYanked + SetStatus } @@ -2143,7 +2403,7 @@ mod tests { fn toggle_panel_and_focus_opens_and_enters_mode() { let m = two_cat_model(); let ctx = make_ctx(&m); - let cmd = TogglePanelAndFocus(effect::Panel::Formula); + 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]); @@ -2158,7 +2418,7 @@ mod tests { let m = two_cat_model(); let mut ctx = make_ctx(&m); ctx.formula_panel_open = true; - let cmd = TogglePanelAndFocus(effect::Panel::Formula); + 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 } @@ -2167,7 +2427,7 @@ mod tests { fn enter_advance_moves_down() { let m = two_cat_model(); let ctx = make_ctx(&m); - let cmd = EnterAdvance; + let cmd = EnterAdvance { cursor: CursorState::from_ctx(&ctx) }; let effects = cmd.execute(&ctx); assert!(!effects.is_empty()); let dbg = format!("{:?}", effects[0]); @@ -2190,7 +2450,9 @@ mod tests { fn enter_edit_mode_produces_editing_mode() { let m = two_cat_model(); let ctx = make_ctx(&m); - let cmd = EnterEditMode; + let cmd = EnterEditMode { + initial_value: String::new(), + }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); // SetBuffer + ChangeMode let dbg = format!("{:?}", effects[1]); @@ -2248,7 +2510,7 @@ mod tests { fn cycle_panel_focus_with_no_panels_open() { let m = two_cat_model(); let ctx = make_ctx(&m); - let cmd = CyclePanelFocus; + let cmd = CyclePanelFocus { formula_open: false, category_open: false, view_open: false }; let effects = cmd.execute(&ctx); assert!(effects.is_empty()); } @@ -2258,7 +2520,7 @@ mod tests { let m = two_cat_model(); let mut ctx = make_ctx(&m); ctx.formula_panel_open = true; - let cmd = CyclePanelFocus; + 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]); diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 9e652d9..8295f49 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -165,7 +165,7 @@ impl Keymap { let binding = self.lookup(key, mods)?; match binding { Binding::Cmd { name, args } => { - let cmd = registry.parse(name, args).ok()?; + let cmd = registry.interactive(name, args, ctx).ok()?; Some(cmd.execute(ctx)) } Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]), @@ -267,7 +267,7 @@ impl KeymapSet { normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]); // Cell operations - normal.bind(KeyCode::Char('x'), none, "clear-selected-cell"); + normal.bind(KeyCode::Char('x'), none, "clear-cell"); normal.bind(KeyCode::Char('p'), none, "paste"); // View diff --git a/src/ui/app.rs b/src/ui/app.rs index fb2ae5d..cc208cc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,6 +11,7 @@ use crate::import::wizard::ImportWizard; use crate::model::cell::CellValue; use crate::model::Model; use crate::persistence; +use crate::view::GridLayout; #[derive(Debug, Clone, PartialEq)] pub enum AppMode { @@ -101,6 +102,8 @@ impl App { pub fn cmd_context(&self, key: KeyCode, mods: KeyModifiers) -> CmdContext<'_> { let view = self.model.active_view(); + let layout = GridLayout::new(&self.model, view); + let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model, mode: &self.mode, @@ -120,6 +123,9 @@ impl App { cat_panel_cursor: self.cat_panel_cursor, view_panel_cursor: self.view_panel_cursor, tile_cat_idx: self.tile_cat_idx, + cell_key: layout.cell_key(sel_row, sel_col), + row_count: layout.row_count(), + col_count: layout.col_count(), key_code: key, key_modifiers: mods, } @@ -220,11 +226,26 @@ mod tests { app.apply_effects(effects); } + fn enter_advance_cmd(app: &App) -> crate::command::cmd::EnterAdvance { + use crate::command::cmd::CursorState; + let view = app.model.active_view(); + let cursor = CursorState { + row: view.selected.0, + col: view.selected.1, + row_count: 3, + col_count: 2, + row_offset: 0, + col_offset: 0, + }; + crate::command::cmd::EnterAdvance { cursor } + } + #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); app.model.active_view_mut().selected = (0, 0); - run_cmd(&mut app, &crate::command::cmd::EnterAdvance); + let cmd = enter_advance_cmd(&app); + run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (1, 0)); } @@ -233,7 +254,8 @@ mod tests { let mut app = two_col_model(); // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1) app.model.active_view_mut().selected = (2, 0); - run_cmd(&mut app, &crate::command::cmd::EnterAdvance); + let cmd = enter_advance_cmd(&app); + run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (0, 1)); } @@ -241,7 +263,8 @@ mod tests { fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); app.model.active_view_mut().selected = (2, 1); - run_cmd(&mut app, &crate::command::cmd::EnterAdvance); + let cmd = enter_advance_cmd(&app); + run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (2, 1)); } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index de786ff..1ef91c1 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -289,7 +289,12 @@ pub struct SetBuffer { } impl Effect for SetBuffer { fn apply(&self, app: &mut App) { - app.buffers.insert(self.name.clone(), self.value.clone()); + // "search" is special — it writes to search_query for backward compat + if self.name == "search" { + app.search_query = self.value.clone(); + } else { + app.buffers.insert(self.name.clone(), self.value.clone()); + } } }