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:
438
src/command/cmd.rs
Normal file
438
src/command/cmd.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user