refactor: add Cmd trait with CmdContext and first command implementations

Define Cmd trait (execute returns Vec<Box<dyn Effect>>) 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) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-03 22:38:56 -07:00
parent 9421d01da5
commit 0c751b7b8b
2 changed files with 439 additions and 0 deletions

438
src/command/cmd.rs Normal file
View File

@ -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<CellValue>,
pub pending_key: Option<char>,
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<Box<dyn Effect>>;
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let last = layout.row_count().saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let last = layout.col_count().saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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
}
}

View File

@ -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;