Files
improvise/src/command/cmd.rs
Edward Langley 3885fc19c8 refactor(test): split let statements and clean up vec! macro
Split multi-line let statements in command tests into separate lines.
Remove unnecessary commas and inline vec! macro. Clean up test code
formatting for readability. No functional changes, only style improvements.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-11 00:06:48 -07:00

3271 lines
108 KiB
Rust

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 layout: &'a GridLayout,
pub registry: &'a CmdRegistry,
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 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<String, String>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: &'a [String],
pub view_forward_stack: &'a [String],
/// Display value at the cursor — works uniformly for pivot and records mode.
pub display_value: String,
/// How many data rows/cols fit on screen (for viewport scrolling).
pub visible_rows: usize,
pub visible_cols: usize,
/// Expanded categories in the tree panel
pub expanded_cats: &'a std::collections::HashSet<String>,
/// The key that triggered this command
pub key_code: KeyCode,
}
impl<'a> CmdContext<'a> {
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
pub fn row_count(&self) -> usize {
self.layout.row_count()
}
pub fn col_count(&self) -> usize {
self.layout.col_count()
}
pub fn none_cats(&self) -> &[String] {
&self.layout.none_cats
}
}
impl<'a> CmdContext<'a> {
/// Resolve the category panel tree entry at the current cursor.
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats);
tree.into_iter().nth(self.cat_panel_cursor)
}
/// The category name at the current tree cursor (whether on a
/// category header or an item).
pub fn cat_at_cursor(&self) -> Option<String> {
self.cat_tree_entry().map(|e| e.cat_name().to_string())
}
/// Total number of entries in the category tree.
pub fn cat_tree_len(&self) -> usize {
crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len()
}
}
/// A command that reads state and produces effects.
pub trait Cmd: Debug + Send + Sync {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
/// 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<Box<dyn Cmd>, 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<Box<dyn Cmd>, String>;
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, 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<CmdEntry>,
}
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<dyn Cmd>) {
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<Box<dyn Cmd>, 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<Box<dyn Cmd>, 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<Item = &'static str> + '_ {
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<Box<dyn Effect>> {
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,
pub visible_rows: usize,
pub visible_cols: 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,
visible_rows: ctx.visible_rows,
visible_cols: ctx.visible_cols,
}
}
}
/// 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,
visible_rows: usize,
visible_cols: usize,
) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
let mut row_offset = old_row_offset;
let mut col_offset = old_col_offset;
let vr = visible_rows.max(1);
let vc = visible_cols.max(1);
if nr < row_offset {
row_offset = nr;
}
if nr >= row_offset + vr {
row_offset = nr.saturating_sub(vr - 1);
}
if nc < col_offset {
col_offset = nc;
}
if nc >= col_offset + vc {
col_offset = nc.saturating_sub(vc - 1);
}
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
}
/// How to move the cursor.
#[derive(Debug, Clone)]
pub enum MoveKind {
/// Relative offset (dr, dc) — subsumes MoveSelection and ScrollRows.
Relative(i32, i32),
/// Jump to start of axis: `true` = row, `false` = col.
ToStart(bool),
/// Jump to end of axis: `true` = row, `false` = col.
ToEnd(bool),
/// Page scroll: +1 = down, -1 = up (delta computed from visible_rows).
Page(i32),
}
/// Unified navigation command. All variants go through `viewport_effects`.
#[derive(Debug)]
pub struct Move {
pub kind: MoveKind,
pub cursor: CursorState,
pub cmd_name: &'static str,
}
impl Cmd for Move {
fn name(&self) -> &'static str {
self.cmd_name
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
let col_max = self.cursor.col_count.saturating_sub(1) as i32;
let (nr, nc) = match &self.kind {
MoveKind::Relative(dr, dc) => {
let nr = (self.cursor.row as i32 + dr).clamp(0, row_max) as usize;
let nc = (self.cursor.col as i32 + dc).clamp(0, col_max) as usize;
(nr, nc)
}
MoveKind::ToStart(is_row) => {
if *is_row {
(0, self.cursor.col)
} else {
(self.cursor.row, 0)
}
}
MoveKind::ToEnd(is_row) => {
if *is_row {
(row_max.max(0) as usize, self.cursor.col)
} else {
(self.cursor.row, col_max.max(0) as usize)
}
}
MoveKind::Page(dir) => {
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir;
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
(nr, self.cursor.col)
}
};
viewport_effects(
nr,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
}
}
// ── 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<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Clear the corresponding buffer when entering a text-entry mode
if let Some(mb) = self.0.minibuffer() {
effects.push(Box::new(effect::SetBuffer {
name: mb.buffer_key.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<Box<dyn Effect>> {
vec![effect::change_mode(AppMode::Quit)]
}
}
/// Quit with dirty check — refuses if unsaved changes exist.
#[derive(Debug)]
pub struct Quit;
impl Cmd for Quit {
fn name(&self) -> &'static str {
"q"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.dirty {
vec![effect::set_status(
"Unsaved changes. Use :q! to force quit or :wq to save+quit.",
)]
} else {
vec![effect::change_mode(AppMode::Quit)]
}
}
}
/// Save then quit.
#[derive(Debug)]
pub struct SaveAndQuit;
impl Cmd for SaveAndQuit {
fn name(&self) -> &'static str {
"wq"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let value = ctx.model.evaluate_aggregated(&self.key, ctx.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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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 open: bool,
pub focused: bool,
}
impl Cmd for TogglePanelAndFocus {
fn name(&self) -> &'static str {
"toggle-panel-and-focus"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
effects.push(Box::new(effect::SetPanelOpen {
panel: self.panel,
open: self.open,
}));
if self.focused {
effects.push(effect::change_mode(self.panel.mode()));
} else {
effects.push(effect::change_mode(AppMode::Normal));
}
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
vec![
Box::new(effect::SetBuffer {
name: "edit".to_string(),
value: self.initial_value.clone(),
}),
effect::change_mode(AppMode::editing()),
]
}
}
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
/// (categories on `Axis::None`, no records mode), drill into it instead of
/// editing. Otherwise enter edit mode with the current displayed value.
#[derive(Debug)]
pub struct EditOrDrill;
impl Cmd for EditOrDrill {
fn name(&self) -> &'static str {
"edit-or-drill"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
// Only consider regular (non-virtual, non-label) categories on None
// as true aggregation. Virtuals like _Index/_Dim are always None in
// pivot mode and don't imply aggregation.
let regular_none = ctx.none_cats().iter().any(|c| {
ctx.model
.category(c)
.map(|cat| cat.kind.is_regular())
.unwrap_or(false)
});
// In records mode (synthetic key), always edit directly — no drilling.
let is_synthetic = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.is_some();
let is_aggregated = !is_synthetic && regular_none;
if is_aggregated {
let Some(key) = ctx.cell_key().clone() else {
return vec![effect::set_status("cannot drill — no cell at cursor")];
};
return DrillIntoCell { key }.execute(ctx);
}
EnterEditMode {
initial_value: ctx.display_value.clone(),
}
.execute(ctx)
}
}
/// In records mode, add a new row with an empty value. The new cell gets
/// coords from the current page filters. In pivot mode, this is a no-op.
#[derive(Debug)]
pub struct AddRecordRow;
impl Cmd for AddRecordRow {
fn name(&self) -> &'static str {
"add-record-row"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.is_some();
if !is_records {
return vec![effect::set_status(
"add-record-row only works in records mode",
)];
}
// Build a CellKey from the current page filters
let view = ctx.model.active_view();
let page_cats: Vec<String> = view
.categories_on(crate::view::Axis::Page)
.into_iter()
.map(String::from)
.collect();
let coords: Vec<(String, String)> = page_cats
.iter()
.map(|cat| {
let sel = view.page_selection(cat).unwrap_or("").to_string();
(cat.clone(), sel)
})
.filter(|(_, v)| !v.is_empty())
.collect();
let key = crate::model::cell::CellKey::new(coords);
vec![
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
effect::mark_dirty(),
effect::set_status("Added new record row"),
]
}
}
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
/// participate in `Binding::Sequence`.
#[derive(Debug)]
pub struct EnterEditAtCursorCmd;
impl Cmd for EnterEditAtCursorCmd {
fn name(&self) -> &'static str {
"enter-edit-at-cursor"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::EnterEditAtCursor)]
}
}
/// 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<Box<dyn Effect>> {
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,
self.cursor.visible_rows,
self.cursor.visible_cols,
)
}
}
/// 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<Box<dyn Effect>> {
vec![effect::change_mode(AppMode::export_prompt())]
}
}
// ── 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<Box<dyn Effect>> {
let query = ctx.search_query.to_lowercase();
if query.is_empty() {
return vec![];
}
let (cur_row, cur_col) = ctx.selected;
let total_rows = ctx.row_count().max(1);
let total_cols = ctx.col_count().max(1);
let total = total_rows * total_cols;
let cur_flat = cur_row * total_cols + cur_col;
let matches: Vec<usize> = (0..total)
.filter(|&flat| {
let ri = flat / total_cols;
let ci = flat % total_cols;
let key = match ctx.layout.cell_key(ri, ci) {
Some(k) => k,
None => return false,
};
let s = match ctx.model.evaluate_aggregated(&key, ctx.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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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::category_add()),
]
}
}
}
// ── 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<Box<dyn Effect>> {
let data = page_cat_data(ctx);
if data.is_empty() {
return vec![];
}
let mut indices: Vec<usize> = 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<dyn Effect>
})
.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<Box<dyn Effect>> {
let data = page_cat_data(ctx);
if data.is_empty() {
return vec![];
}
let mut indices: Vec<usize> = 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<dyn Effect>
})
.collect()
}
}
/// Gather (cat_name, items, current_idx) for page-axis categories.
fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
let view = ctx.model.active_view();
let page_cats: Vec<String> = view
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
page_cats
.into_iter()
.filter_map(|cat| {
let items: Vec<String> = 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 or column group collapse under the cursor.
#[derive(Debug)]
pub struct ToggleGroupAtCursor {
pub is_row: bool,
}
impl Cmd for ToggleGroupAtCursor {
fn name(&self) -> &'static str {
if self.is_row {
"toggle-group-under-cursor"
} else {
"toggle-col-group-under-cursor"
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let lookup = if self.is_row {
ctx.layout.row_group_for(ctx.selected.0)
} else {
ctx.layout.col_group_for(ctx.selected.1)
};
let Some((cat, group)) = lookup else {
return vec![];
};
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<Box<dyn Effect>> {
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
return vec![];
};
let sel_row = ctx.selected.0;
let Some(items) = ctx
.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 or forward in view history.
#[derive(Debug)]
pub struct ViewNavigate {
pub forward: bool,
}
impl Cmd for ViewNavigate {
fn name(&self) -> &'static str {
if self.forward {
"view-forward"
} else {
"view-back"
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if self.forward {
if ctx.view_forward_stack.is_empty() {
vec![effect::set_status("No forward view")]
} else {
vec![Box::new(effect::ViewForward)]
}
} else {
if ctx.view_back_stack.is_empty() {
vec![effect::set_status("No previous view")]
} else {
vec![
Box::new(effect::ApplyAndClearDrill),
Box::new(effect::ViewBack),
]
}
}
}
}
/// Drill down into an aggregated cell: create a _Drill view with _Index on
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
/// from the drilled cell 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<Box<dyn Effect>> {
let drill_name = "_Drill".to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Capture the records snapshot NOW (before we switch views).
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
if self.key.0.is_empty() {
ctx.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
ctx.model
.data
.matching_cells(&self.key.0)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
let n = records.len();
// Freeze the snapshot in the drill state
effects.push(Box::new(effect::StartDrill(records)));
// Create (or replace) the drill view
effects.push(Box::new(effect::CreateView(drill_name.clone())));
effects.push(Box::new(effect::SwitchView(drill_name)));
// Records mode: _Index on Row, _Dim on Column
effects.push(Box::new(effect::SetAxis {
category: "_Index".to_string(),
axis: crate::view::Axis::Row,
}));
effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(),
axis: crate::view::Axis::Column,
}));
// Fixed coords (from drilled cell) → Page with that value as filter
let fixed_cats: std::collections::HashSet<String> =
self.key.0.iter().map(|(c, _)| c.clone()).collect();
for (cat, item) in &self.key.0 {
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::Page,
}));
effects.push(Box::new(effect::SetPageSelection {
category: cat.clone(),
item: item.clone(),
}));
}
// Previously-aggregated categories (none_cats) stay on Axis::None so
// they don't filter records; they'll appear as columns in records mode.
// Skip virtual categories — we already set _Index/_Dim above.
for cat in ctx.none_cats() {
if fixed_cats.contains(cat) || cat.starts_with('_') {
continue;
}
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::None,
}));
}
effects.push(effect::set_status(format!("Drilled into cell: {n} rows")));
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
vec![effect::change_mode(AppMode::formula_edit())]
}
}
/// 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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
if let Some(cat_name) = ctx.cat_at_cursor() {
vec![Box::new(effect::CycleAxis(cat_name))]
} 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<Box<dyn Effect>> {
if let Some(cat_name) = ctx.cat_at_cursor() {
vec![effect::change_mode(AppMode::item_add(cat_name))]
} else {
vec![effect::set_status(
"No category selected. Press n to add a category first.",
)]
}
}
}
/// Toggle expand/collapse of the category at the tree cursor.
#[derive(Debug)]
pub struct ToggleCatExpand;
impl Cmd for ToggleCatExpand {
fn name(&self) -> &'static str {
"toggle-cat-expand"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if let Some(cat_name) = ctx.cat_at_cursor() {
vec![Box::new(effect::ToggleCatExpand(cat_name))]
} else {
vec![]
}
}
}
/// Filter to item: when on an item row, set the category to Page with the
/// item as the filter value.
#[derive(Debug)]
pub struct FilterToItem;
impl Cmd for FilterToItem {
fn name(&self) -> &'static str {
"filter-to-item"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
use crate::ui::cat_tree::CatTreeEntry;
match ctx.cat_tree_entry() {
Some(CatTreeEntry::Item {
cat_name,
item_name,
}) => {
vec![
Box::new(effect::SetAxis {
category: cat_name.clone(),
axis: crate::view::Axis::Page,
}),
Box::new(effect::SetPageSelection {
category: cat_name.clone(),
item: item_name.clone(),
}),
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
]
}
Some(CatTreeEntry::Category { .. }) => {
// On a category header — toggle expand instead
ToggleCatExpand.execute(ctx)
}
None => vec![],
}
}
}
/// Toggle pruning of empty rows/columns in the current view.
#[derive(Debug)]
pub struct TogglePruneEmpty;
impl Cmd for TogglePruneEmpty {
fn name(&self) -> &'static str {
"toggle-prune-empty"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let currently_on = ctx.model.active_view().prune_empty;
vec![
Box::new(effect::TogglePruneEmpty),
effect::set_status(if currently_on {
"Showing all rows/columns"
} else {
"Hiding empty rows/columns"
}),
]
}
}
/// Toggle between records mode and pivot mode using the view stack.
/// Entering records mode creates a `_Records` view and switches to it.
/// Leaving records mode navigates back to the previous view.
#[derive(Debug)]
pub struct ToggleRecordsMode;
impl Cmd for ToggleRecordsMode {
fn name(&self) -> &'static str {
"toggle-records-mode"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx.layout.is_records_mode();
if is_records {
// Navigate back to the previous view (restores original axes)
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
}
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
let records_name = "_Records".to_string();
// Create (or replace) a _Records view and switch to it
effects.push(Box::new(effect::CreateView(records_name.clone())));
effects.push(Box::new(effect::SwitchView(records_name)));
// _Index on Row, _Dim on Column, everything else → None
effects.push(Box::new(effect::SetAxis {
category: "_Index".to_string(),
axis: crate::view::Axis::Row,
}));
effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(),
axis: crate::view::Axis::Column,
}));
for name in ctx.model.categories.keys() {
if name != "_Index" && name != "_Dim" {
effects.push(Box::new(effect::SetAxis {
category: name.clone(),
axis: crate::view::Axis::None,
}));
}
}
effects.push(effect::set_status("Records mode"));
effects
}
}
/// Delete the category or item at the panel cursor.
/// On a category header → delete the whole category.
/// On an item row → delete just that item.
#[derive(Debug)]
pub struct DeleteCategoryAtCursor;
impl Cmd for DeleteCategoryAtCursor {
fn name(&self) -> &'static str {
"delete-category-at-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
use crate::ui::cat_tree::CatTreeEntry;
match ctx.cat_tree_entry() {
Some(CatTreeEntry::Category { name, .. }) => {
vec![
Box::new(effect::RemoveCategory(name.clone())),
effect::mark_dirty(),
effect::set_status(format!("Deleted category '{name}'")),
]
}
Some(CatTreeEntry::Item {
cat_name,
item_name,
}) => {
vec![
Box::new(effect::RemoveItem {
category: cat_name.clone(),
item: item_name.clone(),
}),
effect::mark_dirty(),
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
]
}
None => vec![effect::set_status("No category to delete")],
}
}
}
// ── 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<Box<dyn Effect>> {
let view_names: Vec<String> = 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
let mut effects: Vec<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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 or set the axis for the category at the tile cursor, then return to Normal.
/// `axis: None` → cycle, `axis: Some(a)` → set to `a`.
#[derive(Debug)]
pub struct TileAxisOp {
pub axis: Option<Axis>,
}
impl Cmd for TileAxisOp {
fn name(&self) -> &'static str {
if self.axis.is_some() {
"set-axis-for-tile"
} else {
"cycle-axis-for-tile"
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let cat_names = ctx.model.category_names();
if let Some(name) = cat_names.get(ctx.tile_cat_idx) {
let axis_effect: Box<dyn Effect> = match self.axis {
Some(axis) => Box::new(effect::SetAxis {
category: name.to_string(),
axis,
}),
None => Box::new(effect::CycleAxis(name.to_string())),
};
vec![
axis_effect,
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<Box<dyn Effect>> {
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)]
/// Execute the `:` command buffer by delegating to the command registry.
/// The `:` prompt is just another frontend to the scripting language —
/// same parser as `improvise script`.
pub struct ExecuteCommand;
impl Cmd for ExecuteCommand {
fn name(&self) -> &'static str {
"execute-command"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let raw = ctx.buffers.get("command").cloned().unwrap_or_default();
let raw = raw.trim().to_string();
if raw.is_empty() {
return vec![effect::change_mode(AppMode::Normal)];
}
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
Ok(cmds) => {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
for cmd in cmds {
effects.extend(cmd.execute(ctx));
}
// Return to Normal unless a command already changed mode
if !effects.iter().any(|e| e.changes_mode()) {
effects.push(effect::change_mode(AppMode::Normal));
}
effects
}
Err(msg) => {
vec![
effect::set_status(format!(":{raw}{msg}")),
effect::change_mode(AppMode::Normal),
]
}
}
}
}
// ── 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<Box<dyn Effect>> {
if let KeyCode::Char(c) = ctx.key_code {
let mut val = read_buffer(ctx, &self.buffer);
val.push(c);
if self.buffer == "search" {
vec![Box::new(effect::SetSearchQuery(val))]
} else {
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<Box<dyn Effect>> {
let mut val = read_buffer(ctx, &self.buffer);
val.pop();
if self.buffer == "search" {
vec![Box::new(effect::SetSearchQuery(val))]
} else {
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.
/// In records mode, stages the edit in drill_state.pending_edits instead of
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
/// or apply directly; for real keys, write to the model.
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx,
col_name,
new_value: value.to_string(),
}));
} else if value.is_empty() {
effects.push(Box::new(effect::ClearCell(key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(
key.clone(),
CellValue::Text(value.to_string()),
)));
effects.push(effect::mark_dirty());
}
}
/// Direction to advance after committing a cell edit.
#[derive(Debug, Clone, Copy)]
pub enum AdvanceDir {
/// Move down (typewriter-style, wraps to next column at bottom).
Down,
/// Move right (clamps at rightmost column).
Right,
}
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
#[derive(Debug)]
pub struct CommitAndAdvance {
pub key: CellKey,
pub value: String,
pub advance: AdvanceDir,
pub cursor: CursorState,
}
impl Cmd for CommitAndAdvance {
fn name(&self) -> &'static str {
match self.advance {
AdvanceDir::Down => "commit-cell-edit",
AdvanceDir::Right => "commit-and-advance-right",
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
commit_cell_value(&self.key, &self.value, &mut effects);
match self.advance {
AdvanceDir::Down => {
let adv = EnterAdvance {
cursor: self.cursor.clone(),
};
effects.extend(adv.execute(ctx));
}
AdvanceDir::Right => {
let col_max = self.cursor.col_count.saturating_sub(1);
let nc = (self.cursor.col + 1).min(col_max);
effects.extend(viewport_effects(
self.cursor.row,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
}
}
effects.push(Box::new(effect::EnterEditAtCursor));
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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
}
}
/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty
/// + status + clear-buffer effects. If empty, return to CategoryPanel.
fn commit_add_from_buffer(
ctx: &CmdContext,
buffer_name: &str,
add_effect: impl FnOnce(&str) -> Option<Box<dyn Effect>>,
status_msg: impl FnOnce(&str) -> String,
) -> Vec<Box<dyn Effect>> {
let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default();
let trimmed = buf.trim().to_string();
if trimmed.is_empty() {
return vec![effect::change_mode(AppMode::CategoryPanel)];
}
let Some(add) = add_effect(&trimmed) else {
return vec![];
};
vec![
add,
effect::mark_dirty(),
effect::set_status(status_msg(&trimmed)),
Box::new(effect::SetBuffer {
name: buffer_name.to_string(),
value: String::new(),
}),
]
}
/// 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<Box<dyn Effect>> {
commit_add_from_buffer(
ctx,
"category",
|name| Some(Box::new(effect::AddCategory(name.to_string()))),
|name| format!("Added category \"{name}\""),
)
}
}
/// 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<Box<dyn Effect>> {
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
category.clone()
} else {
return vec![];
};
commit_add_from_buffer(
ctx,
"item",
|name| {
Some(Box::new(effect::AddItem {
category: category.clone(),
item: name.to_string(),
}))
},
|name| format!("Added \"{name}\""),
)
}
}
/// 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
vec![Box::new(effect::SetSearchMode(false))]
}
}
/// 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<Box<dyn Effect>> {
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<String>);
impl Cmd for $name {
fn name(&self) -> &'static str {
$cmd_name
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let args = &self.0;
#[allow(clippy::redundant_closure_call)]
($exec)(args, ctx)
}
}
impl $name {
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, 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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::AddCategory(args[0].clone()))]
}
);
effect_cmd!(
AddItemCmd,
"add-item",
|args: &[String]| require_args("add-item", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
let value = if let Ok(n) = args[0].parse::<f64>() {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::CreateView(args[0].clone()))]
}
);
effect_cmd!(
DeleteViewCmd,
"delete-view",
|args: &[String]| require_args("delete-view", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::DeleteView(args[0].clone()))]
}
);
effect_cmd!(
SwitchViewCmd,
"switch-view",
|args: &[String]| require_args("switch-view", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SwitchView(args[0].clone()))]
}
);
effect_cmd!(
SetAxisCmd,
"set-axis",
|args: &[String]| require_args("set-axis", args, 2),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
}
);
effect_cmd!(
SetFormatCmd,
"set-format",
|args: &[String]| require_args("set-format", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetNumberFormat(args.join(" "))),
effect::mark_dirty(),
]
}
);
effect_cmd!(
ImportCmd,
"import",
|args: &[String]| require_args("import", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::StartImportWizard(args[0].clone()))]
}
);
effect_cmd!(
ExportCmd,
"export",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv");
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))]
}
);
effect_cmd!(
WriteCmd,
"w",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
if args.is_empty() {
vec![Box::new(effect::Save)]
} else {
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
}
}
);
effect_cmd!(
HelpCmd,
"help",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![effect::change_mode(AppMode::Help)]
}
);
effect_cmd!(
LoadModelCmd,
"load",
|args: &[String]| require_args("load", args, 1),
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
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);
r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse);
r.register_pure(&ImportCmd(vec![]), ImportCmd::parse);
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
cmd_name: "move-selection",
},
|args| {
require_args("move-selection", args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::default(),
cmd_name: "move-selection",
}))
},
|args, ctx| {
require_args("move-selection", args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::from_ctx(ctx),
cmd_name: "move-selection",
}))
},
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
$r.register(
&Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::default(),
cmd_name: $name,
},
|_| {
Ok(Box::new(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::default(),
cmd_name: $name,
}))
},
|_, ctx| {
Ok(Box::new(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::from_ctx(ctx),
cmd_name: $name,
}))
},
);
};
}
reg_jump!(r, true, false, "jump-first-row");
reg_jump!(r, true, true, "jump-last-row");
reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col");
r.register(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
},
|args| {
require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
}))
},
|args, ctx| {
require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::from_ctx(ctx),
cmd_name: "scroll-rows",
}))
},
);
r.register(
&Move {
kind: MoveKind::Page(0),
cursor: CursorState::default(),
cmd_name: "page-scroll",
},
|args| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::default(),
cmd_name: "page-scroll",
}))
},
|args, ctx| {
require_args("page-scroll", args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::from_ctx(ctx),
cmd_name: "page-scroll",
}))
},
);
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,
visible_rows: 20,
visible_cols: 8,
},
}))
},
|_, 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(Quit));
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| {
Ok(Box::new(EnterEditMode {
initial_value: ctx.display_value.clone(),
}))
},
);
r.register_nullary(|| Box::new(EditOrDrill));
r.register_nullary(|| Box::new(EnterEditAtCursorCmd));
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(ViewNavigate { forward: false }));
r.register(
&ViewNavigate { forward: true },
|_| Ok(Box::new(ViewNavigate { forward: true })),
|_, _| Ok(Box::new(ViewNavigate { forward: true })),
);
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::command_mode(),
"category-add" => AppMode::category_add(),
"editing" => AppMode::editing(),
"formula-edit" => AppMode::formula_edit(),
"export-prompt" => AppMode::export_prompt(),
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));
// ── Panel operations ─────────────────────────────────────────────────
r.register(
&TogglePanelAndFocus {
panel: Panel::Formula,
open: true,
focused: true,
},
|args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?;
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
},
|args, ctx| {
require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?;
// Default interactive: if already open+focused → close, else open+focus
let currently_open = match panel {
Panel::Formula => ctx.formula_panel_open,
Panel::Category => ctx.category_panel_open,
Panel::View => ctx.view_panel_open,
};
let currently_focused = match panel {
Panel::Formula => matches!(
ctx.mode,
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
),
Panel::Category => matches!(
ctx.mode,
AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }
),
Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
};
let (open, focused) = if currently_open && currently_focused {
(false, false) // close
} else {
(true, true) // open + focus
};
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
},
);
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::<i32>().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::<i32>().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.cat_tree_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(AddRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode));
r.register_nullary(|| Box::new(CycleAxisAtCursor));
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
r.register_nullary(|| Box::new(ToggleCatExpand));
r.register_nullary(|| Box::new(FilterToItem));
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::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MoveTileCursor(delta)))
});
r.register_nullary(|| Box::new(TileAxisOp { axis: None }));
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(TileAxisOp { axis: Some(axis) }))
});
// ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
r.register(
&ToggleGroupAtCursor { is_row: false },
|_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
);
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(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
},
|args| {
if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into());
}
Ok(Box::new(CommitAndAdvance {
key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
}))
},
|_args, ctx| {
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Down,
cursor: CursorState::from_ctx(ctx),
}))
},
);
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Right,
cursor: CursorState::default(),
},
|_| Err("commit-and-advance-right requires context".into()),
|_args, ctx| {
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Right,
cursor: CursorState::from_ctx(ctx),
}))
},
);
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<Panel, String> {
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<Axis, String> {
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<HashMap<String, String>> =
std::sync::LazyLock::new(HashMap::new);
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
std::sync::LazyLock::new(std::collections::HashSet::new);
fn make_layout(model: &Model) -> GridLayout {
GridLayout::new(model, model.active_view())
}
fn make_ctx<'a>(
model: &'a Model,
layout: &'a GridLayout,
registry: &'a CmdRegistry,
) -> CmdContext<'a> {
let view = model.active_view();
let (sr, sc) = view.selected;
CmdContext {
model,
layout,
registry,
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,
view_back_stack: &[],
view_forward_stack: &[],
display_value: {
let key = layout.cell_key(sr, sc);
key.as_ref()
.and_then(|k| model.get_cell(k).cloned())
.map(|v| v.to_string())
.unwrap_or_default()
},
visible_rows: 20,
visible_cols: 8,
expanded_cats: &EMPTY_EXPANDED,
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = Move {
kind: MoveKind::Relative(1, 0),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "move-selection",
};
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
// Try to move way past the end
let cmd = Move {
kind: MoveKind::Relative(100, 100),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "move-selection",
};
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 layout = make_layout(&m);
let reg = default_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
let cmd = ExecuteCommand;
let effects = cmd.execute(&ctx);
assert!(
effects.iter().any(|e| e.changes_mode()),
"Expected a mode-changing effect"
);
}
#[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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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_open_and_focus() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: true,
focused: true,
};
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_close_and_unfocus() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: false,
focused: false,
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal)
}
#[test]
fn enter_advance_moves_down() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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() {
// Models always have virtual categories (_Index, _Dim), so tile
// select always has something to operate on.
let m = Model::new("Empty");
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = EnterTileSelect;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
}
#[test]
fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ToggleGroupAtCursor { is_row: true };
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
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 layout = make_layout(&m);
let reg = default_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
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}"
);
}
// ── Algebraic law tests ──────────────────────────────────────────────
fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
format!("{:?}", effects)
}
/// Law: navigation idempotence — ToStart applied twice produces the same
/// effects as applied once.
#[test]
fn law_move_to_start_idempotent() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = Move {
kind: MoveKind::ToStart(true),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "jump-first-row",
};
let first = effects_debug(&cmd.execute(&ctx));
// After the first move, cursor is at row 0. Simulate that state.
let cmd2 = Move {
kind: MoveKind::ToStart(true),
cursor: CursorState {
row: 0,
..CursorState::from_ctx(&ctx)
},
cmd_name: "jump-first-row",
};
let second = effects_debug(&cmd2.execute(&ctx));
assert_eq!(first, second, "ToStart(Row) should be idempotent");
}
/// Law: toggle involution — toggling a group twice yields the same effects
/// (both are ToggleGroup + MarkDirty, regardless of current state).
#[test]
fn law_toggle_group_involution() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ToggleGroupAtCursor { is_row: true };
let first = effects_debug(&cmd.execute(&ctx));
let second = effects_debug(&cmd.execute(&ctx));
// Both calls produce the same structural effects (the group lookup
// returns None in both cases since there are no groups, so both are
// empty — which is still an involution).
assert_eq!(first, second, "Toggle should be structurally consistent");
}
/// Law: sequence associativity — concatenating effect vectors is associative.
/// This is structural (Vec::extend is associative), but we verify it.
#[test]
fn law_sequence_associativity() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mk_a = || {
Move {
kind: MoveKind::Relative(1, 0),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "move-selection",
}
.execute(&ctx)
};
let mk_b = || {
Move {
kind: MoveKind::Relative(0, 1),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "move-selection",
}
.execute(&ctx)
};
let mk_c = || {
Move {
kind: MoveKind::ToStart(true),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "jump-first-row",
}
.execute(&ctx)
};
// (a ++ b) ++ c
let mut ab_c = mk_a();
ab_c.extend(mk_b());
ab_c.extend(mk_c());
// a ++ (b ++ c)
let mut bc = mk_b();
bc.extend(mk_c());
let mut a_bc = mk_a();
a_bc.extend(bc);
assert_eq!(
effects_debug(&ab_c),
effects_debug(&a_bc),
"Sequence concatenation should be associative"
);
}
/// Law: MoveKind::ToEnd(col) reaches the last column.
#[test]
fn law_move_to_end_reaches_last_col() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = default_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = Move {
kind: MoveKind::ToEnd(false),
cursor: CursorState::from_ctx(&ctx),
cmd_name: "jump-last-col",
};
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
let expected_col = ctx.col_count().saturating_sub(1);
assert!(
dbg.contains(&format!("SetSelected(0, {expected_col})")),
"Expected jump to last col {expected_col}, got: {dbg}"
);
}
}