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)
2848 lines
94 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|