From 0c751b7b8bfca84738a8c0a7312c0eb0a3d6a138 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 3 Apr 2026 22:38:56 -0700 Subject: [PATCH] refactor: add Cmd trait with CmdContext and first command implementations Define Cmd trait (execute returns Vec>) and CmdContext (read-only state snapshot). Implement navigation commands (MoveSelection, JumpTo*, ScrollRows), mode commands (EnterMode, Quit, SaveAndQuit), cell operations (ClearSelectedCell, YankCell, PasteCell), and view commands (TransposeAxes, Save, EnterSearchMode). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/command/cmd.rs | 438 +++++++++++++++++++++++++++++++++++++++++++++ src/command/mod.rs | 1 + 2 files changed, 439 insertions(+) create mode 100644 src/command/cmd.rs diff --git a/src/command/cmd.rs b/src/command/cmd.rs new file mode 100644 index 0000000..12bc677 --- /dev/null +++ b/src/command/cmd.rs @@ -0,0 +1,438 @@ +use std::fmt::Debug; + +use crate::model::cell::CellValue; +use crate::model::Model; +use crate::ui::app::AppMode; +use crate::ui::effect::{self, Effect}; +use crate::view::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 pending_key: Option, + pub dirty: bool, + pub file_path_set: bool, +} + +/// A command that reads state and produces effects. +pub trait Cmd: Debug { + fn execute(&self, ctx: &CmdContext) -> Vec>; + fn name(&self) -> &str; +} + +// ── Navigation commands ────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct MoveSelection { + pub dr: i32, + pub dc: i32, +} + +impl Cmd for MoveSelection { + fn name(&self) -> &str { + "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 + } +} + +#[derive(Debug)] +pub struct JumpToFirstRow; +impl Cmd for JumpToFirstRow { + fn name(&self) -> &str { + "jump-first-row" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetSelected(0, ctx.selected.1)), + Box::new(effect::SetRowOffset(0)), + ] + } +} + +#[derive(Debug)] +pub struct JumpToLastRow; +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); + let mut effects: Vec> = vec![ + Box::new(effect::SetSelected(last, ctx.selected.1)), + ]; + if last >= ctx.row_offset + 20 { + effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19)))); + } + effects + } +} + +#[derive(Debug)] +pub struct JumpToFirstCol; +impl Cmd for JumpToFirstCol { + fn name(&self) -> &str { + "jump-first-col" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetSelected(ctx.selected.0, 0)), + Box::new(effect::SetColOffset(0)), + ] + } +} + +#[derive(Debug)] +pub struct JumpToLastCol; +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); + let mut effects: Vec> = vec![ + Box::new(effect::SetSelected(ctx.selected.0, last)), + ]; + if last >= ctx.col_offset + 8 { + effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7)))); + } + effects + } +} + +#[derive(Debug)] +pub struct ScrollRows(pub i32); +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; + let mut effects: Vec> = vec![ + Box::new(effect::SetSelected(nr, ctx.selected.1)), + ]; + let mut row_offset = ctx.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 { + 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) -> &str { + "enter-mode" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![effect::change_mode(self.0.clone())] + } +} + +#[derive(Debug)] +pub struct QuitCmd; +impl Cmd for QuitCmd { + fn name(&self) -> &str { + "quit" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if ctx.dirty { + vec![effect::set_status( + "Unsaved changes! Use :wq to save+quit or :q! to force quit", + )] + } else { + vec![effect::change_mode(AppMode::Quit)] + } + } +} + +#[derive(Debug)] +pub struct ForceQuit; +impl Cmd for ForceQuit { + fn name(&self) -> &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) -> &str { + "save-and-quit" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::Save), + effect::change_mode(AppMode::Quit), + ] + } +} + +// ── Cell operations ────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct ClearSelectedCell; +impl Cmd for ClearSelectedCell { + 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![] + } + } +} + +#[derive(Debug)] +pub struct YankCell; +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![] + } + } +} + +#[derive(Debug)] +pub struct PasteCell; +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()] + } else { + vec![] + } + } +} + +// ── View commands ──────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct TransposeAxes; +impl Cmd for TransposeAxes { + fn name(&self) -> &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) -> &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) -> &str { + "search" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![ + Box::new(effect::SetSearchMode(true)), + Box::new(effect::SetSearchQuery(String::new())), + ] + } +} + +#[derive(Debug)] +pub struct SetPendingKey(pub char); +impl Cmd for SetPendingKey { + fn name(&self) -> &str { + "set-pending-key" + } + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(effect::SetPendingKey(Some(self.0)))] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; + + fn make_ctx(model: &Model) -> CmdContext { + let view = model.active_view(); + CmdContext { + model, + mode: &AppMode::Normal, + selected: view.selected, + row_offset: view.row_offset, + col_offset: view.col_offset, + search_query: "", + yanked: &None, + pending_key: None, + dirty: false, + file_path_set: false, + } + } + + 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 }; + 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 }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + } + + #[test] + fn quit_when_dirty_shows_warning() { + let m = two_cat_model(); + let mut ctx = make_ctx(&m); + ctx.dirty = true; + let cmd = QuitCmd; + let effects = cmd.execute(&ctx); + // Should produce a status message, not a mode change to Quit + assert_eq!(effects.len(), 1); + // Verify it's a SetStatus by checking debug output + let dbg = format!("{:?}", effects[0]); + assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}"); + } + + #[test] + fn quit_when_clean_produces_quit_mode() { + let m = two_cat_model(); + let ctx = make_ctx(&m); + let cmd = QuitCmd; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + 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 = ClearSelectedCell; + 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; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); // SetYanked + SetStatus + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs index db2f734..535da43 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -5,6 +5,7 @@ //! The headless CLI (--cmd / --script) routes through here, and the TUI //! App also calls dispatch() for every user action that mutates state. +pub mod cmd; pub mod dispatch; pub mod parse; pub mod types;