Refactored CmdContext to delegate row/col counts, cell_key, none_cats, view stacks, and records handling to GridLayout. Updated all command implementations to use layout methods. Updated tests to construct CmdContext with layout. Changed GridLayout to store records as Rc and added synthetic_record_info helper. Updated view/layout.rs and view/mod.rs accordingly. BREAKING CHANGE: CmdContext fields changed; external callers must update to use layout methods. GridLayout records field changed to Rc. Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
3231 lines
108 KiB
Rust
3231 lines
108 KiB
Rust
use std::collections::HashMap;
|
|
use std::fmt::Debug;
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::model::Model;
|
|
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::{self, Effect, Panel};
|
|
use crate::view::{Axis, AxisEntry, GridLayout};
|
|
|
|
/// Read-only context available to commands for decision-making.
|
|
pub struct CmdContext<'a> {
|
|
pub model: &'a Model,
|
|
pub layout: &'a GridLayout,
|
|
pub mode: &'a AppMode,
|
|
pub selected: (usize, usize),
|
|
pub row_offset: usize,
|
|
pub col_offset: usize,
|
|
pub search_query: &'a str,
|
|
pub yanked: &'a Option<CellValue>,
|
|
pub dirty: bool,
|
|
pub search_mode: bool,
|
|
pub formula_panel_open: bool,
|
|
pub category_panel_open: bool,
|
|
pub view_panel_open: bool,
|
|
/// Panel cursors
|
|
pub formula_cursor: usize,
|
|
pub cat_panel_cursor: usize,
|
|
pub view_panel_cursor: usize,
|
|
/// Tile select cursor (which category is selected)
|
|
pub tile_cat_idx: usize,
|
|
/// Named text buffers
|
|
pub buffers: &'a HashMap<String, String>,
|
|
/// View navigation stacks (for drill back/forward)
|
|
pub view_back_stack: &'a [String],
|
|
pub view_forward_stack: &'a [String],
|
|
/// Display value at the cursor — works uniformly for pivot and records mode.
|
|
pub display_value: String,
|
|
/// How many data rows/cols fit on screen (for viewport scrolling).
|
|
pub visible_rows: usize,
|
|
pub visible_cols: usize,
|
|
/// Expanded categories in the tree panel
|
|
pub expanded_cats: &'a std::collections::HashSet<String>,
|
|
/// The key that triggered this command
|
|
pub key_code: KeyCode,
|
|
}
|
|
|
|
impl<'a> CmdContext<'a> {
|
|
pub fn cell_key(&self) -> Option<CellKey> {
|
|
self.layout.cell_key(self.selected.0, self.selected.1)
|
|
}
|
|
pub fn row_count(&self) -> usize {
|
|
self.layout.row_count()
|
|
}
|
|
pub fn col_count(&self) -> usize {
|
|
self.layout.col_count()
|
|
}
|
|
pub fn none_cats(&self) -> &[String] {
|
|
&self.layout.none_cats
|
|
}
|
|
}
|
|
|
|
impl<'a> CmdContext<'a> {
|
|
/// Resolve the category panel tree entry at the current cursor.
|
|
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
|
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats);
|
|
tree.into_iter().nth(self.cat_panel_cursor)
|
|
}
|
|
|
|
/// The category name at the current tree cursor (whether on a
|
|
/// category header or an item).
|
|
pub fn cat_at_cursor(&self) -> Option<String> {
|
|
self.cat_tree_entry().map(|e| e.cat_name().to_string())
|
|
}
|
|
|
|
/// Total number of entries in the category tree.
|
|
pub fn cat_tree_len(&self) -> usize {
|
|
crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len()
|
|
}
|
|
}
|
|
|
|
/// A command that reads state and produces effects.
|
|
pub trait Cmd: Debug + Send + Sync {
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
|
/// The canonical name of this command (matches its registry key).
|
|
/// Used by the parser tests and for introspection.
|
|
#[allow(dead_code)]
|
|
fn name(&self) -> &'static str;
|
|
}
|
|
|
|
/// Factory that constructs a Cmd from text arguments (headless/script).
|
|
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
|
|
|
/// Factory that constructs a Cmd from the interactive context (keymap dispatch).
|
|
/// Receives both the keymap args and the interactive context so commands can
|
|
/// combine text arguments (e.g. panel name) with runtime state (e.g. whether
|
|
/// the panel is currently open).
|
|
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
|
|
|
|
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
|
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
|
|
|
/// A registered command entry with both text and interactive constructors.
|
|
struct CmdEntry {
|
|
name: &'static str,
|
|
parse: BoxParseFn,
|
|
interactive: BoxInteractiveFn,
|
|
}
|
|
|
|
/// Registry of commands constructible from text or from interactive context.
|
|
#[derive(Default)]
|
|
pub struct CmdRegistry {
|
|
entries: Vec<CmdEntry>,
|
|
}
|
|
|
|
impl CmdRegistry {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
entries: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Register a command with both a text parser and an interactive constructor.
|
|
/// The name is derived from a prototype command instance.
|
|
pub fn register(
|
|
&mut self,
|
|
prototype: &dyn Cmd,
|
|
parse: ParseFn,
|
|
interactive: InteractiveFn,
|
|
) {
|
|
self.entries.push(CmdEntry {
|
|
name: prototype.name(),
|
|
parse: Box::new(parse),
|
|
interactive: Box::new(interactive),
|
|
});
|
|
}
|
|
|
|
/// Register a command that doesn't need interactive context.
|
|
/// When called interactively with args, delegates to parse.
|
|
/// When called interactively without args, returns an error.
|
|
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
|
self.entries.push(CmdEntry {
|
|
name: prototype.name(),
|
|
parse: Box::new(parse),
|
|
interactive: Box::new(move |args, _ctx| {
|
|
if args.is_empty() {
|
|
Err("this command requires arguments".into())
|
|
} else {
|
|
parse(args)
|
|
}
|
|
}),
|
|
});
|
|
}
|
|
|
|
/// Register a zero-arg command (same instance for parse and interactive).
|
|
/// The name is derived by calling `f()` once.
|
|
pub fn register_nullary(&mut self, f: fn() -> Box<dyn Cmd>) {
|
|
let name = f().name();
|
|
self.entries.push(CmdEntry {
|
|
name,
|
|
parse: Box::new(move |_| Ok(f())),
|
|
interactive: Box::new(move |_, _| Ok(f())),
|
|
});
|
|
}
|
|
|
|
/// Construct a command from text arguments (script/headless).
|
|
pub fn parse(&self, name: &str, args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
|
for e in &self.entries {
|
|
if e.name == name {
|
|
return (e.parse)(args);
|
|
}
|
|
}
|
|
Err(format!("Unknown command: {name}"))
|
|
}
|
|
|
|
/// Construct a command from interactive context (keymap dispatch).
|
|
/// Always calls the interactive constructor with both args and ctx,
|
|
/// so commands can combine text arguments with runtime state.
|
|
pub fn interactive(
|
|
&self,
|
|
name: &str,
|
|
args: &[String],
|
|
ctx: &CmdContext,
|
|
) -> Result<Box<dyn Cmd>, String> {
|
|
for e in &self.entries {
|
|
if e.name == name {
|
|
return (e.interactive)(args, ctx);
|
|
}
|
|
}
|
|
Err(format!("Unknown command: {name}"))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
|
self.entries.iter().map(|e| e.name)
|
|
}
|
|
}
|
|
|
|
/// Dummy prototype used only for name extraction in registry calls
|
|
/// where the real command struct is built by a closure.
|
|
#[derive(Debug)]
|
|
struct NamedCmd(&'static str);
|
|
impl Cmd for NamedCmd {
|
|
fn name(&self) -> &'static str {
|
|
self.0
|
|
}
|
|
fn execute(&self, _: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> {
|
|
if args.len() < n {
|
|
Err(format!(
|
|
"{word} requires {n} argument(s), got {}",
|
|
args.len()
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Parse Cat/Item coordinate args into a CellKey.
|
|
fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey {
|
|
let coords: Vec<(String, String)> = args
|
|
.iter()
|
|
.filter_map(|s| {
|
|
let (cat, item) = s.split_once('/')?;
|
|
Some((cat.to_string(), item.to_string()))
|
|
})
|
|
.collect();
|
|
crate::model::cell::CellKey::new(coords)
|
|
}
|
|
|
|
// ── Navigation commands ──────────────────────────────────────────────────────
|
|
// All navigation commands take explicit cursor state. The interactive spec
|
|
// fills position/bounds from context; the parser accepts them as args.
|
|
|
|
/// Shared viewport state for navigation commands.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CursorState {
|
|
pub row: usize,
|
|
pub col: usize,
|
|
pub row_count: usize,
|
|
pub col_count: usize,
|
|
pub row_offset: usize,
|
|
pub col_offset: usize,
|
|
pub visible_rows: usize,
|
|
pub visible_cols: usize,
|
|
}
|
|
|
|
impl CursorState {
|
|
pub fn from_ctx(ctx: &CmdContext) -> Self {
|
|
Self {
|
|
row: ctx.selected.0,
|
|
col: ctx.selected.1,
|
|
row_count: ctx.row_count(),
|
|
col_count: ctx.col_count(),
|
|
row_offset: ctx.row_offset,
|
|
col_offset: ctx.col_offset,
|
|
visible_rows: ctx.visible_rows,
|
|
visible_cols: ctx.visible_cols,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute viewport-tracking effects for a new row/col position.
|
|
fn viewport_effects(
|
|
nr: usize,
|
|
nc: usize,
|
|
old_row_offset: usize,
|
|
old_col_offset: usize,
|
|
visible_rows: usize,
|
|
visible_cols: usize,
|
|
) -> Vec<Box<dyn Effect>> {
|
|
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
|
|
let mut row_offset = old_row_offset;
|
|
let mut col_offset = old_col_offset;
|
|
let vr = visible_rows.max(1);
|
|
let vc = visible_cols.max(1);
|
|
if nr < row_offset {
|
|
row_offset = nr;
|
|
}
|
|
if nr >= row_offset + vr {
|
|
row_offset = nr.saturating_sub(vr - 1);
|
|
}
|
|
if nc < col_offset {
|
|
col_offset = nc;
|
|
}
|
|
if nc >= col_offset + vc {
|
|
col_offset = nc.saturating_sub(vc - 1);
|
|
}
|
|
if row_offset != old_row_offset {
|
|
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
|
}
|
|
if col_offset != old_col_offset {
|
|
effects.push(Box::new(effect::SetColOffset(col_offset)));
|
|
}
|
|
effects
|
|
}
|
|
|
|
#[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, self.cursor.visible_rows, self.cursor.visible_cols)
|
|
}
|
|
}
|
|
|
|
/// Unified jump-to-edge: jump to first/last row or column.
|
|
/// `is_row` selects the axis; `end` selects first (false) or last (true).
|
|
#[derive(Debug)]
|
|
pub struct JumpToEdge {
|
|
pub cursor: CursorState,
|
|
pub is_row: bool,
|
|
pub end: bool,
|
|
pub cmd_name: &'static str,
|
|
}
|
|
impl Cmd for JumpToEdge {
|
|
fn name(&self) -> &'static str {
|
|
self.cmd_name
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let (nr, nc) = if self.is_row {
|
|
let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 };
|
|
(r, self.cursor.col)
|
|
} else {
|
|
let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 };
|
|
(self.cursor.row, c)
|
|
};
|
|
viewport_effects(
|
|
nr, nc,
|
|
self.cursor.row_offset, self.cursor.col_offset,
|
|
self.cursor.visible_rows, self.cursor.visible_cols,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
viewport_effects(
|
|
nr,
|
|
self.cursor.col,
|
|
self.cursor.row_offset,
|
|
self.cursor.col_offset,
|
|
self.cursor.visible_rows,
|
|
self.cursor.visible_cols,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PageScroll {
|
|
pub direction: i32, // 1 for down, -1 for up
|
|
pub cursor: CursorState,
|
|
}
|
|
impl Cmd for PageScroll {
|
|
fn name(&self) -> &'static str {
|
|
"page-scroll"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * self.direction;
|
|
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
|
|
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
|
|
viewport_effects(
|
|
nr,
|
|
self.cursor.col,
|
|
self.cursor.row_offset,
|
|
self.cursor.col_offset,
|
|
self.cursor.visible_rows,
|
|
self.cursor.visible_cols,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Mode change commands ─────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct EnterMode(pub AppMode);
|
|
impl Cmd for EnterMode {
|
|
fn name(&self) -> &'static str {
|
|
"enter-mode"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
// Clear the corresponding buffer when entering a text-entry mode
|
|
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 value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
|
|
vec![
|
|
Box::new(effect::SetYanked(value)),
|
|
effect::set_status("Yanked"),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Paste the yanked value into a cell.
|
|
#[derive(Debug)]
|
|
pub struct PasteCell {
|
|
pub key: crate::model::cell::CellKey,
|
|
}
|
|
impl Cmd for PasteCell {
|
|
fn name(&self) -> &'static str {
|
|
"paste"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if let Some(value) = ctx.yanked.clone() {
|
|
vec![
|
|
Box::new(effect::SetCell(self.key.clone(), value)),
|
|
effect::mark_dirty(),
|
|
]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── View commands ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct TransposeAxes;
|
|
impl Cmd for TransposeAxes {
|
|
fn name(&self) -> &'static str {
|
|
"transpose"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::TransposeAxes), effect::mark_dirty()]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SaveCmd;
|
|
impl Cmd for SaveCmd {
|
|
fn name(&self) -> &'static str {
|
|
"save"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::Save)]
|
|
}
|
|
}
|
|
|
|
// ── Search ───────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct EnterSearchMode;
|
|
impl Cmd for EnterSearchMode {
|
|
fn name(&self) -> &'static str {
|
|
"search"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![
|
|
Box::new(effect::SetSearchMode(true)),
|
|
Box::new(effect::SetSearchQuery(String::new())),
|
|
]
|
|
}
|
|
}
|
|
|
|
// ── Panel commands ──────────────────────────────────────────────────────
|
|
|
|
/// Toggle a panel's visibility; if it opens, focus it (enter its mode).
|
|
#[derive(Debug)]
|
|
pub struct TogglePanelAndFocus {
|
|
pub panel: Panel,
|
|
pub open: bool,
|
|
pub focused: bool,
|
|
}
|
|
impl Cmd for TogglePanelAndFocus {
|
|
fn name(&self) -> &'static str {
|
|
"toggle-panel-and-focus"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
effects.push(Box::new(effect::SetPanelOpen {
|
|
panel: self.panel,
|
|
open: self.open,
|
|
}));
|
|
if self.focused {
|
|
effects.push(effect::change_mode(self.panel.mode()));
|
|
} else {
|
|
effects.push(effect::change_mode(AppMode::Normal));
|
|
}
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Toggle a panel's visibility without changing mode.
|
|
#[derive(Debug)]
|
|
pub struct TogglePanelVisibility {
|
|
pub panel: Panel,
|
|
pub currently_open: bool,
|
|
}
|
|
impl Cmd for TogglePanelVisibility {
|
|
fn name(&self) -> &'static str {
|
|
"toggle-panel-visibility"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::SetPanelOpen {
|
|
panel: self.panel,
|
|
open: !self.currently_open,
|
|
})]
|
|
}
|
|
}
|
|
|
|
/// Tab through open panels, entering the first open panel's mode.
|
|
#[derive(Debug)]
|
|
pub struct CyclePanelFocus {
|
|
pub formula_open: bool,
|
|
pub category_open: bool,
|
|
pub view_open: bool,
|
|
}
|
|
impl Cmd for CyclePanelFocus {
|
|
fn name(&self) -> &'static str {
|
|
"cycle-panel-focus"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if self.formula_open {
|
|
vec![effect::change_mode(AppMode::FormulaPanel)]
|
|
} else if self.category_open {
|
|
vec![effect::change_mode(AppMode::CategoryPanel)]
|
|
} else if self.view_open {
|
|
vec![effect::change_mode(AppMode::ViewPanel)]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Editing entry ───────────────────────────────────────────────────────
|
|
|
|
/// Enter editing mode with an initial buffer value.
|
|
#[derive(Debug)]
|
|
pub struct EnterEditMode {
|
|
pub initial_value: String,
|
|
}
|
|
impl Cmd for EnterEditMode {
|
|
fn name(&self) -> &'static str {
|
|
"enter-edit-mode"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![
|
|
Box::new(effect::SetBuffer {
|
|
name: "edit".to_string(),
|
|
value: self.initial_value.clone(),
|
|
}),
|
|
effect::change_mode(AppMode::Editing {
|
|
buffer: String::new(),
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
|
|
/// (categories on `Axis::None`, no records mode), drill into it instead of
|
|
/// editing. Otherwise enter edit mode with the current displayed value.
|
|
#[derive(Debug)]
|
|
pub struct EditOrDrill;
|
|
impl Cmd for EditOrDrill {
|
|
fn name(&self) -> &'static str {
|
|
"edit-or-drill"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
// Only consider regular (non-virtual, non-label) categories on None
|
|
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
|
// pivot mode and don't imply aggregation.
|
|
let regular_none = ctx.none_cats().iter().any(|c| {
|
|
ctx.model
|
|
.category(c)
|
|
.map(|cat| cat.kind.is_regular())
|
|
.unwrap_or(false)
|
|
});
|
|
// In records mode (synthetic key), always edit directly — no drilling.
|
|
let is_synthetic = ctx
|
|
.cell_key()
|
|
.as_ref()
|
|
.and_then(|k| crate::view::synthetic_record_info(k))
|
|
.is_some();
|
|
let is_aggregated = !is_synthetic && regular_none;
|
|
if is_aggregated {
|
|
let Some(key) = ctx.cell_key().clone() else {
|
|
return vec![effect::set_status("cannot drill — no cell at cursor")];
|
|
};
|
|
return DrillIntoCell { key }.execute(ctx);
|
|
}
|
|
EnterEditMode {
|
|
initial_value: ctx.display_value.clone(),
|
|
}
|
|
.execute(ctx)
|
|
}
|
|
}
|
|
|
|
/// In records mode, add a new row with an empty value. The new cell gets
|
|
/// coords from the current page filters. In pivot mode, this is a no-op.
|
|
#[derive(Debug)]
|
|
pub struct AddRecordRow;
|
|
impl Cmd for AddRecordRow {
|
|
fn name(&self) -> &'static str {
|
|
"add-record-row"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let is_records = ctx
|
|
.cell_key()
|
|
.as_ref()
|
|
.and_then(|k| crate::view::synthetic_record_info(k))
|
|
.is_some();
|
|
if !is_records {
|
|
return vec![effect::set_status(
|
|
"add-record-row only works in records mode",
|
|
)];
|
|
}
|
|
// Build a CellKey from the current page filters
|
|
let view = ctx.model.active_view();
|
|
let page_cats: Vec<String> = view
|
|
.categories_on(crate::view::Axis::Page)
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
let coords: Vec<(String, String)> = page_cats
|
|
.iter()
|
|
.map(|cat| {
|
|
let sel = view
|
|
.page_selection(cat)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
(cat.clone(), sel)
|
|
})
|
|
.filter(|(_, v)| !v.is_empty())
|
|
.collect();
|
|
let key = crate::model::cell::CellKey::new(coords);
|
|
vec![
|
|
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
|
|
effect::mark_dirty(),
|
|
effect::set_status("Added new record row"),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Vim-style 'o': add a new record row below cursor and enter edit mode.
|
|
#[derive(Debug)]
|
|
pub struct OpenRecordRow;
|
|
impl Cmd for OpenRecordRow {
|
|
fn name(&self) -> &'static str {
|
|
"open-record-row"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let is_records = ctx
|
|
.cell_key()
|
|
.as_ref()
|
|
.and_then(|k| crate::view::synthetic_record_info(k))
|
|
.is_some();
|
|
if !is_records {
|
|
return vec![effect::set_status(
|
|
"open-record-row only works in records mode",
|
|
)];
|
|
}
|
|
let mut effects = AddRecordRow.execute(ctx);
|
|
effects.push(Box::new(effect::EnterEditAtCursor));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
|
#[derive(Debug)]
|
|
pub struct EnterAdvance {
|
|
pub cursor: CursorState,
|
|
}
|
|
impl Cmd for EnterAdvance {
|
|
fn name(&self) -> &'static str {
|
|
"enter-advance"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let row_max = self.cursor.row_count.saturating_sub(1);
|
|
let col_max = self.cursor.col_count.saturating_sub(1);
|
|
let (r, c) = (self.cursor.row, self.cursor.col);
|
|
let (nr, nc) = if r < row_max {
|
|
(r + 1, c)
|
|
} else if c < col_max {
|
|
(0, c + 1)
|
|
} else {
|
|
(r, c) // already at bottom-right; stay
|
|
};
|
|
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
|
|
}
|
|
}
|
|
|
|
/// Enter export prompt mode.
|
|
#[derive(Debug)]
|
|
pub struct EnterExportPrompt;
|
|
impl Cmd for EnterExportPrompt {
|
|
fn name(&self) -> &'static str {
|
|
"enter-export-prompt"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![effect::change_mode(AppMode::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 (cur_row, cur_col) = ctx.selected;
|
|
let total_rows = ctx.row_count().max(1);
|
|
let total_cols = ctx.col_count().max(1);
|
|
let total = total_rows * total_cols;
|
|
let cur_flat = cur_row * total_cols + cur_col;
|
|
|
|
let matches: Vec<usize> = (0..total)
|
|
.filter(|&flat| {
|
|
let ri = flat / total_cols;
|
|
let ci = flat % total_cols;
|
|
let key = match ctx.layout.cell_key(ri, ci) {
|
|
Some(k) => k,
|
|
None => return false,
|
|
};
|
|
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
|
Some(CellValue::Number(n)) => format!("{n}"),
|
|
Some(CellValue::Text(t)) => t,
|
|
None => String::new(),
|
|
};
|
|
s.to_lowercase().contains(&query)
|
|
})
|
|
.collect();
|
|
|
|
if matches.is_empty() {
|
|
return vec![effect::set_status(format!(
|
|
"No matches for '{}'",
|
|
ctx.search_query
|
|
))];
|
|
}
|
|
|
|
let target_flat = if self.0 {
|
|
matches
|
|
.iter()
|
|
.find(|&&f| f > cur_flat)
|
|
.or_else(|| matches.first())
|
|
.copied()
|
|
} else {
|
|
matches
|
|
.iter()
|
|
.rev()
|
|
.find(|&&f| f < cur_flat)
|
|
.or_else(|| matches.last())
|
|
.copied()
|
|
};
|
|
|
|
if let Some(flat) = target_flat {
|
|
let ri = flat / total_cols;
|
|
let ci = flat % total_cols;
|
|
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(ri, ci)];
|
|
if ri < ctx.row_offset {
|
|
effects.push(Box::new(effect::SetRowOffset(ri)));
|
|
}
|
|
if ci < ctx.col_offset {
|
|
effects.push(Box::new(effect::SetColOffset(ci)));
|
|
}
|
|
effects.push(effect::set_status(format!(
|
|
"Match {}/{} for '{}'",
|
|
matches.iter().position(|&f| f == flat).unwrap_or(0) + 1,
|
|
matches.len(),
|
|
ctx.search_query,
|
|
)));
|
|
effects
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// If search query is active, navigate backward; otherwise open CategoryAdd.
|
|
#[derive(Debug)]
|
|
pub struct SearchOrCategoryAdd;
|
|
impl Cmd for SearchOrCategoryAdd {
|
|
fn name(&self) -> &'static str {
|
|
"search-or-category-add"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if !ctx.search_query.is_empty() {
|
|
SearchNavigate(false).execute(ctx)
|
|
} else {
|
|
vec![
|
|
Box::new(effect::SetPanelOpen {
|
|
panel: Panel::Category,
|
|
open: true,
|
|
}),
|
|
effect::change_mode(AppMode::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 sel_row = ctx.selected.0;
|
|
let Some((cat, group)) = ctx.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 sel_col = ctx.selected.1;
|
|
let Some((cat, group)) = ctx.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 Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
|
|
return vec![];
|
|
};
|
|
let sel_row = ctx.selected.0;
|
|
let Some(items) = ctx
|
|
.layout
|
|
.row_items
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AxisEntry::DataItem(v) = e {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.nth(sel_row)
|
|
else {
|
|
return vec![];
|
|
};
|
|
let item_name = items[0].clone();
|
|
vec![
|
|
Box::new(effect::HideItem {
|
|
category: cat_name,
|
|
item: item_name,
|
|
}),
|
|
effect::mark_dirty(),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Navigate back 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 {
|
|
// Apply any pending drill edits first, then navigate back.
|
|
vec![
|
|
Box::new(effect::ApplyAndClearDrill),
|
|
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 with _Index on
|
|
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
|
|
/// from the drilled cell become page filters.
|
|
#[derive(Debug)]
|
|
pub struct DrillIntoCell {
|
|
pub key: crate::model::cell::CellKey,
|
|
}
|
|
impl Cmd for DrillIntoCell {
|
|
fn name(&self) -> &'static str {
|
|
"drill-into-cell"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let drill_name = "_Drill".to_string();
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
|
|
// Capture the records snapshot NOW (before we switch views).
|
|
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
|
|
if self.key.0.is_empty() {
|
|
ctx.model
|
|
.data
|
|
.iter_cells()
|
|
.map(|(k, v)| (k, v.clone()))
|
|
.collect()
|
|
} else {
|
|
ctx.model
|
|
.data
|
|
.matching_cells(&self.key.0)
|
|
.into_iter()
|
|
.map(|(k, v)| (k, v.clone()))
|
|
.collect()
|
|
};
|
|
let n = records.len();
|
|
|
|
// Freeze the snapshot in the drill state
|
|
effects.push(Box::new(effect::StartDrill(records)));
|
|
|
|
// Create (or replace) the drill view
|
|
effects.push(Box::new(effect::CreateView(drill_name.clone())));
|
|
effects.push(Box::new(effect::SwitchView(drill_name)));
|
|
|
|
// Records mode: _Index on Row, _Dim on Column
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: "_Index".to_string(),
|
|
axis: crate::view::Axis::Row,
|
|
}));
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: "_Dim".to_string(),
|
|
axis: crate::view::Axis::Column,
|
|
}));
|
|
|
|
// Fixed coords (from drilled cell) → Page with that value as filter
|
|
let fixed_cats: std::collections::HashSet<String> =
|
|
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
|
for (cat, item) in &self.key.0 {
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: cat.clone(),
|
|
axis: crate::view::Axis::Page,
|
|
}));
|
|
effects.push(Box::new(effect::SetPageSelection {
|
|
category: cat.clone(),
|
|
item: item.clone(),
|
|
}));
|
|
}
|
|
// Previously-aggregated categories (none_cats) stay on Axis::None so
|
|
// they don't filter records; they'll appear as columns in records mode.
|
|
// Skip virtual categories — we already set _Index/_Dim above.
|
|
for cat in ctx.none_cats() {
|
|
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
|
continue;
|
|
}
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: cat.clone(),
|
|
axis: crate::view::Axis::None,
|
|
}));
|
|
}
|
|
|
|
effects.push(effect::set_status(format!("Drilled into cell: {n} rows")));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Enter tile select mode.
|
|
#[derive(Debug)]
|
|
pub struct EnterTileSelect;
|
|
impl Cmd for EnterTileSelect {
|
|
fn name(&self) -> &'static str {
|
|
"enter-tile-select"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let count = ctx.model.category_names().len();
|
|
if count > 0 {
|
|
vec![
|
|
Box::new(effect::SetTileCatIdx(0)),
|
|
effect::change_mode(AppMode::TileSelect),
|
|
]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Panel cursor commands ────────────────────────────────────────────────────
|
|
|
|
/// Move a panel cursor by delta, clamping to bounds.
|
|
#[derive(Debug)]
|
|
pub struct MovePanelCursor {
|
|
pub panel: Panel,
|
|
pub delta: i32,
|
|
pub current: usize,
|
|
pub max: usize,
|
|
}
|
|
impl Cmd for MovePanelCursor {
|
|
fn name(&self) -> &'static str {
|
|
"move-panel-cursor"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let cursor = self.current;
|
|
let max = self.max;
|
|
if max == 0 {
|
|
return vec![];
|
|
}
|
|
let clamped_cursor = cursor.min(max - 1);
|
|
let new = (clamped_cursor as i32 + self.delta).clamp(0, (max - 1) as i32) as usize;
|
|
if new != cursor {
|
|
vec![Box::new(effect::SetPanelCursor {
|
|
panel: self.panel,
|
|
cursor: new,
|
|
})]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Formula panel commands ──────────────────────────────────────────────────
|
|
|
|
/// Enter formula edit mode with an empty buffer.
|
|
#[derive(Debug)]
|
|
pub struct EnterFormulaEdit;
|
|
impl Cmd for EnterFormulaEdit {
|
|
fn name(&self) -> &'static str {
|
|
"enter-formula-edit"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![effect::change_mode(AppMode::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>> {
|
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
|
vec![Box::new(effect::CycleAxis(cat_name))]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enter ItemAdd mode for the category at the panel cursor.
|
|
#[derive(Debug)]
|
|
pub struct OpenItemAddAtCursor;
|
|
impl Cmd for OpenItemAddAtCursor {
|
|
fn name(&self) -> &'static str {
|
|
"open-item-add-at-cursor"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
|
vec![effect::change_mode(AppMode::ItemAdd {
|
|
category: cat_name,
|
|
buffer: String::new(),
|
|
})]
|
|
} else {
|
|
vec![effect::set_status(
|
|
"No category selected. Press n to add a category first.",
|
|
)]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle expand/collapse of the category at the tree cursor.
|
|
#[derive(Debug)]
|
|
pub struct ToggleCatExpand;
|
|
impl Cmd for ToggleCatExpand {
|
|
fn name(&self) -> &'static str {
|
|
"toggle-cat-expand"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
|
vec![Box::new(effect::ToggleCatExpand(cat_name))]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Filter to item: when on an item row, set the category to Page with the
|
|
/// item as the filter value.
|
|
#[derive(Debug)]
|
|
pub struct FilterToItem;
|
|
impl Cmd for FilterToItem {
|
|
fn name(&self) -> &'static str {
|
|
"filter-to-item"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
use crate::ui::cat_tree::CatTreeEntry;
|
|
match ctx.cat_tree_entry() {
|
|
Some(CatTreeEntry::Item {
|
|
cat_name,
|
|
item_name,
|
|
}) => {
|
|
vec![
|
|
Box::new(effect::SetAxis {
|
|
category: cat_name.clone(),
|
|
axis: crate::view::Axis::Page,
|
|
}),
|
|
Box::new(effect::SetPageSelection {
|
|
category: cat_name.clone(),
|
|
item: item_name.clone(),
|
|
}),
|
|
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
|
|
]
|
|
}
|
|
Some(CatTreeEntry::Category { .. }) => {
|
|
// On a category header — toggle expand instead
|
|
ToggleCatExpand.execute(ctx)
|
|
}
|
|
None => vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle pruning of empty rows/columns in the current view.
|
|
#[derive(Debug)]
|
|
pub struct TogglePruneEmpty;
|
|
impl Cmd for TogglePruneEmpty {
|
|
fn name(&self) -> &'static str {
|
|
"toggle-prune-empty"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let currently_on = ctx.model.active_view().prune_empty;
|
|
vec![
|
|
Box::new(effect::TogglePruneEmpty),
|
|
effect::set_status(if currently_on {
|
|
"Showing all rows/columns"
|
|
} else {
|
|
"Hiding empty rows/columns"
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Toggle between records mode (_Index on Row, _Dim on Column) and
|
|
/// pivot mode (auto-assigned axes). In records mode every cell is shown
|
|
/// as a flat row; in pivot mode the view is a cross-tab.
|
|
#[derive(Debug)]
|
|
pub struct ToggleRecordsMode;
|
|
impl Cmd for ToggleRecordsMode {
|
|
fn name(&self) -> &'static str {
|
|
"toggle-records-mode"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let is_records = ctx.layout.is_records_mode();
|
|
|
|
if is_records {
|
|
// Navigate back to the previous view (restores original axes)
|
|
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
|
|
}
|
|
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
let records_name = "_Records".to_string();
|
|
|
|
// Create (or replace) a _Records view and switch to it
|
|
effects.push(Box::new(effect::CreateView(records_name.clone())));
|
|
effects.push(Box::new(effect::SwitchView(records_name)));
|
|
|
|
// _Index on Row, _Dim on Column, everything else → None
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: "_Index".to_string(),
|
|
axis: crate::view::Axis::Row,
|
|
}));
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: "_Dim".to_string(),
|
|
axis: crate::view::Axis::Column,
|
|
}));
|
|
for name in ctx.model.categories.keys() {
|
|
if name != "_Index" && name != "_Dim" {
|
|
effects.push(Box::new(effect::SetAxis {
|
|
category: name.clone(),
|
|
axis: crate::view::Axis::None,
|
|
}));
|
|
}
|
|
}
|
|
effects.push(effect::set_status("Records mode"));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Delete the category or item at the panel cursor.
|
|
/// On a category header → delete the whole category.
|
|
/// On an item row → delete just that item.
|
|
#[derive(Debug)]
|
|
pub struct DeleteCategoryAtCursor;
|
|
impl Cmd for DeleteCategoryAtCursor {
|
|
fn name(&self) -> &'static str {
|
|
"delete-category-at-cursor"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
use crate::ui::cat_tree::CatTreeEntry;
|
|
match ctx.cat_tree_entry() {
|
|
Some(CatTreeEntry::Category { name, .. }) => {
|
|
vec![
|
|
Box::new(effect::RemoveCategory(name.clone())),
|
|
effect::mark_dirty(),
|
|
effect::set_status(format!("Deleted category '{name}'")),
|
|
]
|
|
}
|
|
Some(CatTreeEntry::Item {
|
|
cat_name,
|
|
item_name,
|
|
}) => {
|
|
vec![
|
|
Box::new(effect::RemoveItem {
|
|
category: cat_name.clone(),
|
|
item: item_name.clone(),
|
|
}),
|
|
effect::mark_dirty(),
|
|
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
|
|
]
|
|
}
|
|
None => vec![effect::set_status("No category to delete")],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── View panel commands ─────────────────────────────────────────────────────
|
|
|
|
/// Switch to the view at the panel cursor and return to Normal mode.
|
|
#[derive(Debug)]
|
|
pub struct SwitchViewAtCursor;
|
|
impl Cmd for SwitchViewAtCursor {
|
|
fn name(&self) -> &'static str {
|
|
"switch-view-at-cursor"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
|
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
|
vec![
|
|
Box::new(effect::SwitchView(name.clone())),
|
|
effect::change_mode(AppMode::Normal),
|
|
]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new view, switch to it, and return to Normal mode.
|
|
#[derive(Debug)]
|
|
pub struct CreateAndSwitchView;
|
|
impl Cmd for CreateAndSwitchView {
|
|
fn name(&self) -> &'static str {
|
|
"create-and-switch-view"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let name = format!("View {}", ctx.model.views.len() + 1);
|
|
vec![
|
|
Box::new(effect::CreateView(name.clone())),
|
|
Box::new(effect::SwitchView(name)),
|
|
effect::mark_dirty(),
|
|
effect::change_mode(AppMode::Normal),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Delete the view at the panel cursor.
|
|
#[derive(Debug)]
|
|
pub struct DeleteViewAtCursor;
|
|
impl Cmd for DeleteViewAtCursor {
|
|
fn name(&self) -> &'static str {
|
|
"delete-view-at-cursor"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
|
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
|
Box::new(effect::DeleteView(name.clone())),
|
|
effect::mark_dirty(),
|
|
];
|
|
if ctx.view_panel_cursor > 0 {
|
|
effects.push(Box::new(effect::SetPanelCursor {
|
|
panel: Panel::View,
|
|
cursor: ctx.view_panel_cursor - 1,
|
|
}));
|
|
}
|
|
effects
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tile select commands ────────────────────────────────────────────────────
|
|
|
|
/// Move the tile select cursor left or right.
|
|
#[derive(Debug)]
|
|
pub struct MoveTileCursor(pub i32);
|
|
impl Cmd for MoveTileCursor {
|
|
fn name(&self) -> &'static str {
|
|
"move-tile-cursor"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let count = ctx.model.category_names().len();
|
|
if count == 0 {
|
|
return vec![];
|
|
}
|
|
let new = (ctx.tile_cat_idx as i32 + self.0).clamp(0, (count - 1) as i32) as usize;
|
|
vec![Box::new(effect::SetTileCatIdx(new))]
|
|
}
|
|
}
|
|
|
|
/// Cycle 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.
|
|
/// In records mode, stages the edit in drill_state.pending_edits instead of
|
|
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
|
|
/// or apply directly; for real keys, write to the model.
|
|
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
|
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
|
|
effects.push(Box::new(effect::SetDrillPendingEdit {
|
|
record_idx,
|
|
col_name,
|
|
new_value: value.to_string(),
|
|
}));
|
|
} else if value.is_empty() {
|
|
effects.push(Box::new(effect::ClearCell(key.clone())));
|
|
effects.push(effect::mark_dirty());
|
|
} else if let Ok(n) = value.parse::<f64>() {
|
|
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
|
|
effects.push(effect::mark_dirty());
|
|
} else {
|
|
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string()))));
|
|
effects.push(effect::mark_dirty());
|
|
}
|
|
}
|
|
|
|
/// Commit a cell edit: set cell value, advance cursor, return to editing.
|
|
/// In records mode with drill, stages the edit in drill_state.pending_edits.
|
|
/// In records mode without drill or in pivot mode, writes directly to the model.
|
|
#[derive(Debug)]
|
|
pub struct CommitCellEdit {
|
|
pub key: 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();
|
|
|
|
commit_cell_value(&self.key, &self.value, &mut effects);
|
|
// Advance cursor down (typewriter-style) and re-enter edit mode
|
|
// at the new cell so the user can continue data entry.
|
|
let adv = EnterAdvance {
|
|
cursor: CursorState::from_ctx(ctx),
|
|
};
|
|
effects.extend(adv.execute(ctx));
|
|
effects.push(Box::new(effect::EnterEditAtCursor));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Tab in editing: commit cell, move right, re-enter edit mode (Excel-style).
|
|
#[derive(Debug)]
|
|
pub struct CommitAndAdvanceRight {
|
|
pub key: CellKey,
|
|
pub value: String,
|
|
pub cursor: CursorState,
|
|
}
|
|
impl Cmd for CommitAndAdvanceRight {
|
|
fn name(&self) -> &'static str {
|
|
"commit-and-advance-right"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
commit_cell_value(&self.key, &self.value, &mut effects);
|
|
// Move right instead of down
|
|
let col_max = self.cursor.col_count.saturating_sub(1);
|
|
let nc = (self.cursor.col + 1).min(col_max);
|
|
effects.extend(viewport_effects(
|
|
self.cursor.row,
|
|
nc,
|
|
self.cursor.row_offset,
|
|
self.cursor.col_offset,
|
|
self.cursor.visible_rows,
|
|
self.cursor.visible_cols,
|
|
));
|
|
effects.push(Box::new(effect::EnterEditAtCursor));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// Commit a formula from the formula edit buffer.
|
|
#[derive(Debug)]
|
|
pub struct CommitFormula;
|
|
impl Cmd for CommitFormula {
|
|
fn name(&self) -> &'static str {
|
|
"commit-formula"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let buf = ctx.buffers.get("formula").cloned().unwrap_or_default();
|
|
let first_cat = ctx
|
|
.model
|
|
.category_names()
|
|
.into_iter()
|
|
.next()
|
|
.map(String::from);
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
if let Some(cat) = first_cat {
|
|
effects.push(Box::new(effect::AddFormula {
|
|
raw: buf,
|
|
target_category: cat,
|
|
}));
|
|
effects.push(effect::mark_dirty());
|
|
effects.push(effect::set_status("Formula added"));
|
|
} else {
|
|
effects.push(effect::set_status("Add at least one category first."));
|
|
}
|
|
effects.push(effect::change_mode(AppMode::FormulaPanel));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
if trimmed.is_empty() {
|
|
// Empty → exit category-add mode
|
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
|
}
|
|
vec![
|
|
Box::new(effect::AddCategory(trimmed.clone())),
|
|
effect::mark_dirty(),
|
|
effect::set_status(format!("Added category \"{trimmed}\"")),
|
|
Box::new(effect::SetBuffer {
|
|
name: "category".to_string(),
|
|
value: String::new(),
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
if trimmed.is_empty() {
|
|
// Empty → exit item-add mode
|
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
|
}
|
|
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
|
category.clone()
|
|
} else {
|
|
return vec![];
|
|
};
|
|
vec![
|
|
Box::new(effect::AddItem {
|
|
category,
|
|
item: trimmed.clone(),
|
|
}),
|
|
effect::mark_dirty(),
|
|
effect::set_status(format!("Added \"{trimmed}\"")),
|
|
Box::new(effect::SetBuffer {
|
|
name: "item".to_string(),
|
|
value: String::new(),
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
visible_rows: 20,
|
|
visible_cols: 8,
|
|
},
|
|
}))
|
|
},
|
|
|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),
|
|
}))
|
|
},
|
|
);
|
|
// Jump-to-edge commands: first/last row/col
|
|
macro_rules! reg_jump {
|
|
($r:expr, $is_row:expr, $end:expr, $name:expr) => {
|
|
$r.register(
|
|
&JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name },
|
|
|_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })),
|
|
|_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })),
|
|
);
|
|
};
|
|
}
|
|
reg_jump!(r, true, false, "jump-first-row");
|
|
reg_jump!(r, true, true, "jump-last-row");
|
|
reg_jump!(r, false, false, "jump-first-col");
|
|
reg_jump!(r, false, true, "jump-last-col");
|
|
r.register(
|
|
&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,
|
|
visible_rows: 20,
|
|
visible_cols: 8,
|
|
},
|
|
}))
|
|
},
|
|
|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(
|
|
&PageScroll { direction: 0, cursor: CursorState::default() },
|
|
|args| {
|
|
require_args("page-scroll", args, 1)?;
|
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Box::new(PageScroll {
|
|
direction: dir,
|
|
cursor: CursorState::default(),
|
|
}))
|
|
},
|
|
|args, ctx| {
|
|
require_args("page-scroll", args, 1)?;
|
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Box::new(PageScroll {
|
|
direction: dir,
|
|
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,
|
|
visible_rows: 20,
|
|
visible_cols: 8,
|
|
},
|
|
}))
|
|
},
|
|
|_, ctx| {
|
|
Ok(Box::new(EnterAdvance {
|
|
cursor: CursorState::from_ctx(ctx),
|
|
}))
|
|
},
|
|
);
|
|
|
|
// ── Cell operations ──────────────────────────────────────────────────
|
|
r.register(
|
|
&YankCell { key: CellKey::new(vec![]) },
|
|
|args| {
|
|
if args.is_empty() {
|
|
return Err("yank requires at least one Cat/Item coordinate".into());
|
|
}
|
|
Ok(Box::new(YankCell {
|
|
key: parse_cell_key_from_args(args),
|
|
}))
|
|
},
|
|
|_args, ctx| {
|
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
|
Ok(Box::new(YankCell { key }))
|
|
},
|
|
);
|
|
r.register(
|
|
&PasteCell {
|
|
key: CellKey::new(vec![]),
|
|
},
|
|
|args| {
|
|
if args.is_empty() {
|
|
return Err("paste requires at least one Cat/Item coordinate".into());
|
|
}
|
|
Ok(Box::new(PasteCell {
|
|
key: parse_cell_key_from_args(args),
|
|
}))
|
|
},
|
|
|_args, ctx| {
|
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
|
Ok(Box::new(PasteCell { key }))
|
|
},
|
|
);
|
|
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
|
|
|
|
// ── View / page ──────────────────────────────────────────────────────
|
|
r.register_nullary(|| Box::new(TransposeAxes));
|
|
r.register_nullary(|| Box::new(PageNext));
|
|
r.register_nullary(|| Box::new(PagePrev));
|
|
|
|
// ── Mode changes ─────────────────────────────────────────────────────
|
|
r.register_nullary(|| Box::new(ForceQuit));
|
|
r.register_nullary(|| Box::new(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(EditOrDrill));
|
|
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, open: true, focused: true },
|
|
|args| {
|
|
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
|
require_args("toggle-panel-and-focus", args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
|
|
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
|
|
Ok(Box::new(TogglePanelAndFocus {
|
|
panel,
|
|
open,
|
|
focused,
|
|
}))
|
|
},
|
|
|args, ctx| {
|
|
require_args("toggle-panel-and-focus", args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
// Default interactive: if already open+focused → close, else open+focus
|
|
let currently_open = match panel {
|
|
Panel::Formula => ctx.formula_panel_open,
|
|
Panel::Category => ctx.category_panel_open,
|
|
Panel::View => ctx.view_panel_open,
|
|
};
|
|
let currently_focused = match panel {
|
|
Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }),
|
|
Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }),
|
|
Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
|
|
};
|
|
let (open, focused) = if currently_open && currently_focused {
|
|
(false, false) // close
|
|
} else {
|
|
(true, true) // open + focus
|
|
};
|
|
Ok(Box::new(TogglePanelAndFocus {
|
|
panel,
|
|
open,
|
|
focused,
|
|
}))
|
|
},
|
|
);
|
|
r.register(
|
|
&TogglePanelVisibility { panel: Panel::Formula, currently_open: false },
|
|
|args| {
|
|
require_args("toggle-panel-visibility", args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
Ok(Box::new(TogglePanelVisibility {
|
|
panel,
|
|
currently_open: false,
|
|
}))
|
|
},
|
|
|args, ctx| {
|
|
require_args("toggle-panel-visibility", args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let currently_open = match panel {
|
|
Panel::Formula => ctx.formula_panel_open,
|
|
Panel::Category => ctx.category_panel_open,
|
|
Panel::View => ctx.view_panel_open,
|
|
};
|
|
Ok(Box::new(TogglePanelVisibility {
|
|
panel,
|
|
currently_open,
|
|
}))
|
|
},
|
|
);
|
|
r.register(
|
|
&CyclePanelFocus { formula_open: false, category_open: false, view_open: false },
|
|
|_| {
|
|
Ok(Box::new(CyclePanelFocus {
|
|
formula_open: false,
|
|
category_open: false,
|
|
view_open: false,
|
|
}))
|
|
},
|
|
|_, ctx| {
|
|
Ok(Box::new(CyclePanelFocus {
|
|
formula_open: ctx.formula_panel_open,
|
|
category_open: ctx.category_panel_open,
|
|
view_open: ctx.view_panel_open,
|
|
}))
|
|
},
|
|
);
|
|
r.register(
|
|
&MovePanelCursor { panel: Panel::Formula, delta: 0, current: 0, max: 0 },
|
|
|args| {
|
|
require_args("move-panel-cursor", args, 2)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Box::new(MovePanelCursor {
|
|
panel,
|
|
delta,
|
|
current: 0,
|
|
max: 0,
|
|
}))
|
|
},
|
|
|args, ctx| {
|
|
require_args("move-panel-cursor", args, 2)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
|
let (current, max) = match panel {
|
|
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
|
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
|
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
|
|
};
|
|
Ok(Box::new(MovePanelCursor {
|
|
panel,
|
|
delta,
|
|
current,
|
|
max,
|
|
}))
|
|
},
|
|
);
|
|
r.register_nullary(|| {
|
|
Box::new(DeleteFormulaAtCursor)
|
|
});
|
|
r.register_nullary(|| Box::new(AddRecordRow));
|
|
r.register_nullary(|| Box::new(OpenRecordRow));
|
|
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
|
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
|
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
|
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
|
|
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
|
|
r.register_nullary(|| Box::new(ToggleCatExpand));
|
|
r.register_nullary(|| Box::new(FilterToItem));
|
|
r.register_nullary(|| Box::new(SwitchViewAtCursor));
|
|
r.register_nullary(|| Box::new(CreateAndSwitchView));
|
|
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
|
|
|
// ── Tile select ──────────────────────────────────────────────────────
|
|
r.register_pure(&NamedCmd("move-tile-cursor"), |args| {
|
|
require_args("move-tile-cursor", args, 1)?;
|
|
let delta = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Box::new(MoveTileCursor(delta)))
|
|
});
|
|
r.register_nullary(|| Box::new(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| {
|
|
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 value = read_buffer(ctx, "edit");
|
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
|
Ok(Box::new(CommitCellEdit { key, value }))
|
|
},
|
|
);
|
|
r.register(
|
|
&CommitAndAdvanceRight {
|
|
key: CellKey::new(vec![]),
|
|
value: String::new(),
|
|
cursor: CursorState::default(),
|
|
},
|
|
|_| Err("commit-and-advance-right requires context".into()),
|
|
|_args, ctx| {
|
|
let value = read_buffer(ctx, "edit");
|
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
|
Ok(Box::new(CommitAndAdvanceRight {
|
|
key,
|
|
value,
|
|
cursor: CursorState::from_ctx(ctx),
|
|
}))
|
|
},
|
|
);
|
|
r.register_nullary(|| Box::new(CommitFormula));
|
|
r.register_nullary(|| Box::new(CommitCategoryAdd));
|
|
r.register_nullary(|| Box::new(CommitItemAdd));
|
|
r.register_nullary(|| Box::new(CommitExport));
|
|
r.register_nullary(|| Box::new(ExecuteCommand));
|
|
|
|
// ── Wizard ───────────────────────────────────────────────────────────
|
|
r.register_nullary(|| Box::new(HandleWizardKey));
|
|
|
|
r
|
|
}
|
|
|
|
fn parse_panel(s: &str) -> Result<Panel, String> {
|
|
match s {
|
|
"formula" => Ok(Panel::Formula),
|
|
"category" => Ok(Panel::Category),
|
|
"view" => Ok(Panel::View),
|
|
other => Err(format!("Unknown panel: {other}")),
|
|
}
|
|
}
|
|
|
|
fn parse_axis(s: &str) -> Result<Axis, String> {
|
|
match s.to_lowercase().as_str() {
|
|
"row" => Ok(Axis::Row),
|
|
"column" | "col" => Ok(Axis::Column),
|
|
"page" => Ok(Axis::Page),
|
|
"none" => Ok(Axis::None),
|
|
other => Err(format!("Unknown axis: {other}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::model::Model;
|
|
|
|
static EMPTY_BUFFERS: std::sync::LazyLock<HashMap<String, String>> =
|
|
std::sync::LazyLock::new(HashMap::new);
|
|
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
|
std::sync::LazyLock::new(std::collections::HashSet::new);
|
|
|
|
fn make_layout(model: &Model) -> GridLayout {
|
|
GridLayout::new(model, model.active_view())
|
|
}
|
|
|
|
fn make_ctx<'a>(model: &'a Model, layout: &'a GridLayout) -> CmdContext<'a> {
|
|
let view = model.active_view();
|
|
let (sr, sc) = view.selected;
|
|
CmdContext {
|
|
model,
|
|
layout,
|
|
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(),
|
|
records_col: None,
|
|
records_value: None,
|
|
cell_key: layout.cell_key(sr, sc),
|
|
row_count: layout.row_count(),
|
|
col_count: layout.col_count(),
|
|
visible_rows: 20,
|
|
visible_cols: 8,
|
|
expanded_cats: &EMPTY_EXPANDED,
|
|
key_code: KeyCode::Null,
|
|
}
|
|
}
|
|
|
|
fn two_cat_model() -> Model {
|
|
let mut m = Model::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_category("Month").unwrap();
|
|
m.category_mut("Type").unwrap().add_item("Food");
|
|
m.category_mut("Type").unwrap().add_item("Clothing");
|
|
m.category_mut("Month").unwrap().add_item("Jan");
|
|
m.category_mut("Month").unwrap().add_item("Feb");
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn move_selection_down_produces_set_selected() {
|
|
let m = two_cat_model();
|
|
let 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_open_and_focus() {
|
|
let m = two_cat_model();
|
|
let ctx = make_ctx(&m);
|
|
let cmd = TogglePanelAndFocus {
|
|
panel: effect::Panel::Formula,
|
|
open: true,
|
|
focused: true,
|
|
};
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
|
let dbg = format!("{:?}", effects[1]);
|
|
assert!(
|
|
dbg.contains("FormulaPanel"),
|
|
"Expected FormulaPanel mode, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_panel_close_and_unfocus() {
|
|
let m = two_cat_model();
|
|
let ctx = make_ctx(&m);
|
|
let cmd = TogglePanelAndFocus {
|
|
panel: effect::Panel::Formula,
|
|
open: false,
|
|
focused: false,
|
|
};
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal)
|
|
}
|
|
|
|
#[test]
|
|
fn enter_advance_moves_down() {
|
|
let m = two_cat_model();
|
|
let 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() {
|
|
// Models always have virtual categories (_Index, _Dim), so tile
|
|
// select always has something to operate on.
|
|
let m = Model::new("Empty");
|
|
let ctx = make_ctx(&m);
|
|
let cmd = EnterTileSelect;
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
|
let m = two_cat_model();
|
|
let 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}"
|
|
);
|
|
}
|
|
}
|