Files
improvise/src/command/cmd.rs
Edward Langley 67041dd4a5 feat: add view history navigation and drill-into-cell
Add view navigation history with back/forward stacks (bound to < and >).

Introduce CategoryKind enum to distinguish regular categories from
virtual ones (_Index, _Dim) that are synthesized at query time.

Add DrillIntoCell command that creates a drill view showing raw data
for an aggregated cell, expanding categories on Axis::None into Row
and Column axes while filtering by the cell's fixed coordinates.

Virtual categories default to Axis::None and are automatically added
to all views when the model is initialized.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 10:57:28 -07:00

2848 lines
94 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 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>,
/// Pre-resolved cell key at the cursor position (None if out of bounds)
pub cell_key: Option<crate::model::cell::CellKey>,
/// Grid dimensions (so commands don't need GridLayout)
pub row_count: usize,
pub col_count: usize,
/// Categories on Axis::None — aggregated away in the current view
pub none_cats: Vec<String>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: Vec<String>,
pub view_forward_stack: Vec<String>,
/// The key that triggered this command
pub key_code: KeyCode,
}
/// 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,
}
impl CursorState {
pub fn from_ctx(ctx: &CmdContext) -> Self {
Self {
row: ctx.selected.0,
col: ctx.selected.1,
row_count: ctx.row_count,
col_count: ctx.col_count,
row_offset: ctx.row_offset,
col_offset: ctx.col_offset,
}
}
}
/// Compute viewport-tracking effects for a new row/col position.
fn viewport_effects(
nr: usize,
nc: usize,
old_row_offset: usize,
old_col_offset: usize,
) -> Vec<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;
if nr < row_offset {
row_offset = nr;
}
if nr >= row_offset + 20 {
row_offset = nr.saturating_sub(19);
}
if nc < col_offset {
col_offset = nc;
}
if nc >= col_offset + 8 {
col_offset = nc.saturating_sub(7);
}
if row_offset != old_row_offset {
effects.push(Box::new(effect::SetRowOffset(row_offset)));
}
if col_offset != old_col_offset {
effects.push(Box::new(effect::SetColOffset(col_offset)));
}
effects
}
#[derive(Debug)]
pub struct MoveSelection {
pub dr: i32,
pub dc: i32,
pub cursor: CursorState,
}
impl Cmd for MoveSelection {
fn name(&self) -> &'static str {
"move-selection"
}
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 nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset)
}
}
#[derive(Debug)]
pub struct JumpToFirstRow {
pub col: usize,
}
impl Cmd for JumpToFirstRow {
fn name(&self) -> &'static str {
"jump-first-row"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetSelected(0, self.col)),
Box::new(effect::SetRowOffset(0)),
]
}
}
#[derive(Debug)]
pub struct JumpToLastRow {
pub col: usize,
pub row_count: usize,
pub row_offset: usize,
}
impl Cmd for JumpToLastRow {
fn name(&self) -> &'static str {
"jump-last-row"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let last = self.row_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(last, self.col))];
if last >= self.row_offset + 20 {
effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19))));
}
effects
}
}
#[derive(Debug)]
pub struct JumpToFirstCol {
pub row: usize,
}
impl Cmd for JumpToFirstCol {
fn name(&self) -> &'static str {
"jump-first-col"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetSelected(self.row, 0)),
Box::new(effect::SetColOffset(0)),
]
}
}
#[derive(Debug)]
pub struct JumpToLastCol {
pub row: usize,
pub col_count: usize,
pub col_offset: usize,
}
impl Cmd for JumpToLastCol {
fn name(&self) -> &'static str {
"jump-last-col"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let last = self.col_count.saturating_sub(1);
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(self.row, last))];
if last >= self.col_offset + 8 {
effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7))));
}
effects
}
}
#[derive(Debug)]
pub struct ScrollRows {
pub delta: i32,
pub cursor: CursorState,
}
impl Cmd for ScrollRows {
fn name(&self) -> &'static str {
"scroll-rows"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize;
let mut effects: Vec<Box<dyn Effect>> =
vec![Box::new(effect::SetSelected(nr, self.cursor.col))];
let mut row_offset = self.cursor.row_offset;
if nr < row_offset {
row_offset = nr;
}
if nr >= row_offset + 20 {
row_offset = nr.saturating_sub(19);
}
if row_offset != self.cursor.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) -> &'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
let buffer_name = match &self.0 {
AppMode::CommandMode { .. } => Some("command"),
AppMode::Editing { .. } => Some("edit"),
AppMode::FormulaEdit { .. } => Some("formula"),
AppMode::CategoryAdd { .. } => Some("category"),
AppMode::ExportPrompt { .. } => Some("export"),
_ => None,
};
if let Some(name) = buffer_name {
effects.push(Box::new(effect::SetBuffer {
name: name.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)]
}
}
#[derive(Debug)]
pub struct SaveAndQuit;
impl Cmd for SaveAndQuit {
fn name(&self) -> &'static 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 ──────────────────────────────────────────────────────────
// 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 layout = GridLayout::new(ctx.model, ctx.model.active_view());
let value = ctx.model.evaluate_aggregated(&self.key, &layout.none_cats);
vec![
Box::new(effect::SetYanked(value)),
effect::set_status("Yanked"),
]
}
}
/// Paste the yanked value into a cell.
#[derive(Debug)]
pub struct PasteCell {
pub 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 currently_open: bool,
}
impl Cmd for TogglePanelAndFocus {
fn name(&self) -> &'static str {
"toggle-panel-and-focus"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let new_open = !self.currently_open;
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetPanelOpen {
panel: self.panel,
open: new_open,
})];
if new_open {
let mode = match self.panel {
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
};
effects.push(effect::change_mode(mode));
}
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 {
buffer: String::new(),
}),
]
}
}
/// 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)
}
}
/// 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::ExportPrompt {
buffer: String::new(),
})]
}
}
// ── 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 layout = GridLayout::new(ctx.model, ctx.model.active_view());
let (cur_row, cur_col) = ctx.selected;
let total_rows = layout.row_count().max(1);
let total_cols = layout.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 layout.cell_key(ri, ci) {
Some(k) => k,
None => return false,
};
let s = match ctx.model.evaluate_aggregated(&key, &layout.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::CategoryAdd {
buffer: String::new(),
}),
]
}
}
}
// ── 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 group collapse under the cursor.
#[derive(Debug)]
pub struct ToggleGroupUnderCursor;
impl Cmd for ToggleGroupUnderCursor {
fn name(&self) -> &'static str {
"toggle-group-under-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let sel_row = ctx.selected.0;
let Some((cat, group)) = layout.row_group_for(sel_row) else {
return vec![];
};
vec![
Box::new(effect::ToggleGroup {
category: cat,
group,
}),
effect::mark_dirty(),
]
}
}
/// Toggle the column group collapse under the cursor.
#[derive(Debug)]
pub struct ToggleColGroupUnderCursor;
impl Cmd for ToggleColGroupUnderCursor {
fn name(&self) -> &'static str {
"toggle-col-group-under-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
let sel_col = ctx.selected.1;
let Some((cat, group)) = layout.col_group_for(sel_col) else {
return vec![];
};
// After toggling, col_count may shrink — clamp selection
// We return ToggleGroup + MarkDirty; selection clamping will need
// to happen in the effect or in a follow-up pass
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 layout = GridLayout::new(ctx.model, ctx.model.active_view());
let Some(cat_name) = layout.row_cats.first().cloned() else {
return vec![];
};
let sel_row = ctx.selected.0;
let Some(items) = 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 in view history.
#[derive(Debug)]
pub struct ViewBackCmd;
impl Cmd for ViewBackCmd {
fn name(&self) -> &'static str {
"view-back"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.view_back_stack.is_empty() {
vec![effect::set_status("No previous view")]
} else {
vec![Box::new(effect::ViewBack)]
}
}
}
/// Navigate forward in view history.
#[derive(Debug)]
pub struct ViewForwardCmd;
impl Cmd for ViewForwardCmd {
fn name(&self) -> &'static str {
"view-forward"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if ctx.view_forward_stack.is_empty() {
vec![effect::set_status("No forward view")]
} else {
vec![Box::new(effect::ViewForward)]
}
}
}
/// Drill down into an aggregated cell: create a _Drill view that shows the
/// raw (un-aggregated) data for this cell. Categories on Axis::None in the
/// current view become visible (Row + Column) in the drill view; the cell's
/// fixed coordinates 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();
// Create (or replace) the drill view
effects.push(Box::new(effect::CreateView(drill_name.clone())));
effects.push(Box::new(effect::SwitchView(drill_name)));
// All categories currently exist. Set axes:
// - none_cats → Row (first) and Column (rest) to expand them
// - cell_key cats → Page with their specific items (filter)
// - other cats (not in cell_key or none_cats) → Page as well
let none_cats = &ctx.none_cats;
let fixed_cats: std::collections::HashSet<String> =
self.key.0.iter().map(|(c, _)| c.clone()).collect();
for (i, cat) in none_cats.iter().enumerate() {
let axis = if i == 0 {
crate::view::Axis::Row
} else {
crate::view::Axis::Column
};
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis,
}));
}
// All other categories → Page, with the cell's value as the page selection
for cat_name in ctx.model.category_names() {
let cat = cat_name.to_string();
if none_cats.contains(&cat) {
continue;
}
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::Page,
}));
// If this category was in the drilled cell's key, fix its page
// selection to the cell's value
if fixed_cats.contains(&cat) {
if let Some((_, item)) = self.key.0.iter().find(|(c, _)| c == &cat) {
effects.push(Box::new(effect::SetPageSelection {
category: cat,
item: item.clone(),
}));
}
}
}
effects.push(effect::set_status("Drilled into cell"));
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::FormulaEdit {
buffer: String::new(),
})]
}
}
/// 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>> {
let cat_names = ctx.model.category_names();
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
vec![Box::new(effect::CycleAxis(cat_name.to_string()))]
} 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>> {
let cat_names = ctx.model.category_names();
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
vec![effect::change_mode(AppMode::ItemAdd {
category: cat_name.to_string(),
buffer: String::new(),
})]
} else {
vec![effect::set_status(
"No category selected. Press n to add a category first.",
)]
}
}
}
// ── 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 the axis for the category at the tile cursor, then return to Normal.
#[derive(Debug)]
pub struct CycleAxisForTile;
impl Cmd for CycleAxisForTile {
fn name(&self) -> &'static str {
"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) {
vec![
Box::new(effect::CycleAxis(name.to_string())),
effect::mark_dirty(),
effect::change_mode(AppMode::Normal),
]
} else {
vec![effect::change_mode(AppMode::Normal)]
}
}
}
/// Set a specific axis for the category at the tile cursor, then return to Normal.
#[derive(Debug)]
pub struct SetAxisForTile(pub Axis);
impl Cmd for SetAxisForTile {
fn name(&self) -> &'static str {
"set-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) {
vec![
Box::new(effect::SetAxis {
category: name.to_string(),
axis: self.0,
}),
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)]
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();
let (cmd_name, rest) = raw
.split_once(char::is_whitespace)
.map(|(c, r)| (c, r.trim()))
.unwrap_or((raw, ""));
// Default: return to Normal
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
match cmd_name {
"q" | "quit" => {
if ctx.dirty {
effects.push(effect::set_status(
"Unsaved changes. Use :q! to force quit or :wq to save+quit.",
));
} else {
effects.push(effect::change_mode(AppMode::Quit));
}
}
"q!" => {
effects.push(effect::change_mode(AppMode::Quit));
}
"w" | "write" => {
if rest.is_empty() {
effects.push(Box::new(effect::Save));
} else {
effects.push(Box::new(effect::SaveAs(std::path::PathBuf::from(rest))));
}
}
"wq" | "x" => {
effects.push(Box::new(effect::Save));
effects.push(effect::change_mode(AppMode::Quit));
}
"import" => {
if rest.is_empty() {
effects.push(effect::set_status("Usage: :import <path.json>"));
} else {
effects.push(Box::new(effect::StartImportWizard(rest.to_string())));
}
}
"export" => {
let path = if rest.is_empty() { "export.csv" } else { rest };
effects.push(Box::new(effect::ExportCsv(std::path::PathBuf::from(path))));
}
"add-cat" | "add-category" | "cat" => {
if rest.is_empty() {
effects.push(effect::set_status("Usage: :add-cat <name>"));
} else {
effects.push(Box::new(effect::AddCategory(rest.to_string())));
effects.push(effect::mark_dirty());
}
}
"add-item" | "item" => {
let mut parts = rest.splitn(2, char::is_whitespace);
let cat = parts.next().unwrap_or("").trim();
let item = parts.next().unwrap_or("").trim();
if cat.is_empty() || item.is_empty() {
effects.push(effect::set_status("Usage: :add-item <category> <item>"));
} else {
effects.push(Box::new(effect::AddItem {
category: cat.to_string(),
item: item.to_string(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status("Item added"));
}
}
"add-items" | "items" => {
let mut parts = rest.splitn(2, char::is_whitespace);
let cat = parts.next().unwrap_or("").trim().to_string();
let items_str = parts.next().unwrap_or("").trim().to_string();
if cat.is_empty() || items_str.is_empty() {
effects.push(effect::set_status(
"Usage: :add-items <category> item1 item2 ...",
));
} else {
let items: Vec<&str> = items_str.split_whitespace().collect();
let count = items.len();
for item in &items {
effects.push(Box::new(effect::AddItem {
category: cat.clone(),
item: item.to_string(),
}));
}
effects.push(effect::mark_dirty());
effects.push(effect::set_status(format!(
"Added {count} items to \"{cat}\".",
)));
}
}
"formula" | "add-formula" => {
if rest.is_empty() {
effects.push(Box::new(effect::SetPanelOpen {
panel: Panel::Formula,
open: true,
}));
effects.push(effect::change_mode(AppMode::FormulaPanel));
return effects; // Don't set mode to Normal
} else {
let mut parts = rest.splitn(2, char::is_whitespace);
let cat = parts.next().unwrap_or("").trim();
let formula = parts.next().unwrap_or("").trim();
if cat.is_empty() || formula.is_empty() {
effects.push(effect::set_status(
"Usage: :formula <category> <Name = expr>",
));
} else {
effects.push(Box::new(effect::AddFormula {
raw: formula.to_string(),
target_category: cat.to_string(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status("Formula added"));
}
}
}
"add-view" | "view" => {
let name = if rest.is_empty() {
format!("View {}", ctx.model.views.len() + 1)
} else {
rest.to_string()
};
effects.push(Box::new(effect::CreateView(name.clone())));
effects.push(Box::new(effect::SwitchView(name)));
effects.push(effect::mark_dirty());
}
"set-format" | "fmt" => {
if rest.is_empty() {
effects.push(effect::set_status(
"Usage: :set-format <fmt> e.g. ,.0 ,.2 .4",
));
} else {
effects.push(Box::new(effect::SetNumberFormat(rest.to_string())));
effects.push(effect::mark_dirty());
effects.push(effect::set_status(format!("Number format set to '{rest}'")));
}
}
"show-item" | "show" => {
let mut parts = rest.splitn(2, char::is_whitespace);
let cat = parts.next().unwrap_or("").trim();
let item = parts.next().unwrap_or("").trim();
if cat.is_empty() || item.is_empty() {
effects.push(effect::set_status("Usage: :show-item <category> <item>"));
} else {
effects.push(Box::new(effect::ShowItem {
category: cat.to_string(),
item: item.to_string(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status(format!(
"Showed \"{item}\" in \"{cat}\""
)));
}
}
"help" | "h" => {
effects.push(effect::change_mode(AppMode::Help));
return effects; // Don't also set Normal
}
"" => {} // just pressed Enter with empty buffer
other => {
effects.push(effect::set_status(format!(
"Unknown command: :{other} (try :help)"
)));
}
}
// Default: return to Normal (unless a command already set a different mode)
if !effects
.iter()
.any(|e| format!("{e:?}").contains("ChangeMode"))
{
effects.push(effect::change_mode(AppMode::Normal));
}
effects
}
}
// ── 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);
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();
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.
#[derive(Debug)]
pub struct CommitCellEdit {
pub key: crate::model::cell::CellKey,
pub value: String,
}
impl Cmd for CommitCellEdit {
fn name(&self) -> &'static str {
"commit-cell-edit"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if self.value.is_empty() {
effects.push(Box::new(effect::ClearCell(self.key.clone())));
} else if let Ok(n) = self.value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Number(n),
)));
} else {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Text(self.value.clone()),
)));
}
effects.push(effect::mark_dirty());
effects.push(effect::change_mode(AppMode::Normal));
// Advance cursor down (typewriter-style)
let adv = EnterAdvance {
cursor: CursorState::from_ctx(ctx),
};
effects.extend(adv.execute(ctx));
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
}
}
/// 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>> {
let buf = ctx.buffers.get("category").cloned().unwrap_or_default();
let trimmed = buf.trim().to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if !trimmed.is_empty() {
effects.push(Box::new(effect::AddCategory(trimmed.clone())));
effects.push(effect::mark_dirty());
effects.push(effect::set_status(format!("Added category \"{trimmed}\"")));
}
// Clear buffer for next entry
effects.push(Box::new(effect::SetBuffer {
name: "category".to_string(),
value: String::new(),
}));
effects
}
}
/// 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 buf = ctx.buffers.get("item").cloned().unwrap_or_default();
let trimmed = buf.trim().to_string();
// Get the category from the mode
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
category.clone()
} else {
return vec![];
};
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if !trimmed.is_empty() {
effects.push(Box::new(effect::AddItem {
category,
item: trimmed.clone(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status(format!("Added \"{trimmed}\"")));
}
// Clear buffer for next entry
effects.push(Box::new(effect::SetBuffer {
name: "item".to_string(),
value: String::new(),
}));
effects
}
}
/// 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))]
}
}
/// Append a character to the search query.
#[derive(Debug)]
pub struct SearchAppendChar;
impl Cmd for SearchAppendChar {
fn name(&self) -> &'static str {
"search-append-char"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if let KeyCode::Char(c) = ctx.key_code {
let mut q = ctx.search_query.to_string();
q.push(c);
vec![Box::new(effect::SetSearchQuery(q))]
} else {
vec![]
}
}
}
/// Pop the last character from the search query.
#[derive(Debug)]
pub struct SearchPopChar;
impl Cmd for SearchPopChar {
fn name(&self) -> &'static str {
"search-pop-char"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut q = ctx.search_query.to_string();
q.pop();
vec![Box::new(effect::SetSearchQuery(q))]
}
}
/// 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!(
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);
// ── Navigation ───────────────────────────────────────────────────────
r.register(
&MoveSelection { dr: 0, dc: 0, cursor: CursorState::default() },
|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(MoveSelection {
dr,
dc,
cursor: CursorState {
row: 0,
col: 0,
row_count: 0,
col_count: 0,
row_offset: 0,
col_offset: 0,
},
}))
},
|args, ctx| {
require_args("move-selection", args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MoveSelection {
dr,
dc,
cursor: CursorState::from_ctx(ctx),
}))
},
);
r.register(
&JumpToFirstRow { col: 0 },
|_| Ok(Box::new(JumpToFirstRow { col: 0 })),
|_, ctx| {
Ok(Box::new(JumpToFirstRow {
col: ctx.selected.1,
}))
},
);
r.register(
&JumpToLastRow { col: 0, row_count: 0, row_offset: 0 },
|_| {
Ok(Box::new(JumpToLastRow {
col: 0,
row_count: 0,
row_offset: 0,
}))
},
|_, ctx| {
Ok(Box::new(JumpToLastRow {
col: ctx.selected.1,
row_count: ctx.row_count,
row_offset: ctx.row_offset,
}))
},
);
r.register(
&JumpToFirstCol { row: 0 },
|_| Ok(Box::new(JumpToFirstCol { row: 0 })),
|_, ctx| {
Ok(Box::new(JumpToFirstCol {
row: ctx.selected.0,
}))
},
);
r.register(
&JumpToLastCol { row: 0, col_count: 0, col_offset: 0 },
|_| {
Ok(Box::new(JumpToLastCol {
row: 0,
col_count: 0,
col_offset: 0,
}))
},
|_, ctx| {
Ok(Box::new(JumpToLastCol {
row: ctx.selected.0,
col_count: ctx.col_count,
col_offset: ctx.col_offset,
}))
},
);
r.register(
&ScrollRows { delta: 0, cursor: CursorState::default() },
|args| {
require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(ScrollRows {
delta: n,
cursor: CursorState {
row: 0,
col: 0,
row_count: 0,
col_count: 0,
row_offset: 0,
col_offset: 0,
},
}))
},
|args, ctx| {
require_args("scroll-rows", args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(ScrollRows {
delta: n,
cursor: CursorState::from_ctx(ctx),
}))
},
);
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,
},
}))
},
|_, 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(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| {
let current = ctx
.cell_key
.as_ref()
.and_then(|k| ctx.model.get_cell(k).cloned())
.map(|v| v.to_string())
.unwrap_or_default();
Ok(Box::new(EnterEditMode {
initial_value: current,
}))
},
);
r.register_nullary(|| 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(ViewBackCmd));
r.register_nullary(|| Box::new(ViewForwardCmd));
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::CommandMode {
buffer: String::new(),
},
"category-add" => AppMode::CategoryAdd {
buffer: String::new(),
},
"editing" => AppMode::Editing {
buffer: String::new(),
},
"formula-edit" => AppMode::FormulaEdit {
buffer: String::new(),
},
"export-prompt" => AppMode::ExportPrompt {
buffer: String::new(),
},
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));
r.register_nullary(|| Box::new(SearchAppendChar));
r.register_nullary(|| Box::new(SearchPopChar));
// ── Panel operations ─────────────────────────────────────────────────
r.register(
&TogglePanelAndFocus { panel: Panel::Formula, currently_open: false },
|args| {
require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(Box::new(TogglePanelAndFocus {
panel,
currently_open: false,
}))
},
|args, ctx| {
require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?;
let currently_open = match panel {
Panel::Formula => ctx.formula_panel_open,
Panel::Category => ctx.category_panel_open,
Panel::View => ctx.view_panel_open,
};
Ok(Box::new(TogglePanelAndFocus {
panel,
currently_open,
}))
},
);
r.register(
&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.model.category_names().len()),
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
};
Ok(Box::new(MovePanelCursor {
panel,
delta,
current,
max,
}))
},
);
r.register_nullary(|| {
Box::new(DeleteFormulaAtCursor)
});
r.register_nullary(|| Box::new(CycleAxisAtCursor));
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
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(CycleAxisForTile));
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(SetAxisForTile(axis)))
});
// ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| {
Box::new(ToggleGroupUnderCursor)
});
r.register_nullary(|| {
Box::new(ToggleColGroupUnderCursor)
});
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(
&CommitCellEdit { key: CellKey::new(vec![]), value: String::new() },
|args| {
// parse: commit-cell-edit <value> <Cat/Item>...
if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into());
}
Ok(Box::new(CommitCellEdit {
key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(),
}))
},
|_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
let value = read_buffer(ctx, "edit");
Ok(Box::new(CommitCellEdit { key, value }))
},
);
r.register_nullary(|| 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);
fn make_ctx(model: &Model) -> CmdContext<'_> {
let view = model.active_view();
let layout = GridLayout::new(model, view);
let (sr, sc) = view.selected;
CmdContext {
model,
mode: &AppMode::Normal,
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,
none_cats: layout.none_cats.clone(),
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
cell_key: layout.cell_key(sr, sc),
row_count: layout.row_count(),
col_count: layout.col_count(),
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 ctx = make_ctx(&m);
let cmd = MoveSelection {
dr: 1,
dc: 0,
cursor: CursorState::from_ctx(&ctx),
};
let effects = cmd.execute(&ctx);
// Should produce at least SetSelected
assert!(!effects.is_empty());
}
#[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,
cursor: CursorState::from_ctx(&ctx),
};
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 mut ctx = make_ctx(&m);
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 mut ctx = make_ctx(&m);
ctx.buffers = &bufs;
let cmd = ExecuteCommand;
let effects = cmd.execute(&ctx);
let dbg = format!("{:?}", effects);
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 = 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 ctx = make_ctx(&m);
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_and_focus_opens_and_enters_mode() {
let m = two_cat_model();
let ctx = make_ctx(&m);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
currently_open: false,
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
let dbg = format!("{:?}", effects[1]);
assert!(
dbg.contains("FormulaPanel"),
"Expected FormulaPanel mode, got: {dbg}"
);
}
#[test]
fn toggle_panel_and_focus_closes_when_open() {
let m = two_cat_model();
let mut ctx = make_ctx(&m);
ctx.formula_panel_open = true;
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
currently_open: true,
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1); // SetPanelOpen only, no mode change
}
#[test]
fn enter_advance_moves_down() {
let m = two_cat_model();
let ctx = make_ctx(&m);
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 ctx = make_ctx(&m);
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 ctx = make_ctx(&m);
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 ctx = make_ctx(&m);
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() {
let m = Model::new("Empty");
let ctx = make_ctx(&m);
let cmd = EnterTileSelect;
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
#[test]
fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model();
let ctx = make_ctx(&m);
let cmd = ToggleGroupUnderCursor;
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 ctx = make_ctx(&m);
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 ctx = make_ctx(&m);
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 mut ctx = make_ctx(&m);
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}"
);
}
}