use std::collections::HashMap; use std::fmt::Debug; use crossterm::event::KeyCode; use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::AppMode; use crate::ui::effect::{Effect, Panel}; use crate::view::{Axis, GridLayout, View}; use crate::workbook::Workbook; /// Read-only context available to commands for decision-making. /// /// Commands receive a `&Model` (pure data) and a `&View` (the active view). /// The full `&Workbook` is also available for the rare command that needs /// to enumerate all views (e.g. the view panel). pub struct CmdContext<'a> { pub model: &'a Model, pub workbook: &'a Workbook, pub view: &'a View, pub layout: &'a GridLayout, pub registry: &'a CmdRegistry, pub mode: &'a AppMode, pub selected: (usize, usize), pub row_offset: usize, pub col_offset: usize, pub search_query: &'a str, pub yanked: &'a Option, 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, /// View navigation stacks (for drill back/forward) pub view_back_stack: &'a [crate::ui::app::ViewFrame], pub view_forward_stack: &'a [crate::ui::app::ViewFrame], /// Whether the app currently has an active drill snapshot. pub has_drill_state: bool, /// 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, /// The key that triggered this command pub key_code: KeyCode, } impl<'a> CmdContext<'a> { /// Return true when the current layout is a records-mode layout. pub fn is_records_mode(&self) -> bool { self.layout.is_records_mode() } pub fn cell_key(&self) -> Option { self.layout.cell_key(self.selected.0, self.selected.1) } /// Return synthetic record coordinates for the current cursor, if any. pub fn synthetic_record_at_cursor(&self) -> Option<(usize, String)> { self.cell_key() .as_ref() .and_then(crate::view::synthetic_record_info) } 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 { 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 { 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>; /// 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, 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, String>; type BoxParseFn = Box Result, String>>; type BoxInteractiveFn = Box Result, 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, aliases: Vec<(&'static str, &'static str)>, } impl CmdRegistry { pub fn new() -> Self { Self { entries: Vec::new(), aliases: Vec::new(), } } /// Register a short name that resolves to a canonical command name. pub fn alias(&mut self, short: &'static str, canonical: &'static str) { self.aliases.push((short, canonical)); } /// Resolve a command name through the alias table. fn resolve<'a>(&'a self, name: &'a str) -> &'a str { for (alias, canonical) in &self.aliases { if *alias == name { return canonical; } } name } /// 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) { 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, String> { let name = self.resolve(name); 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, String> { let name = self.resolve(name); 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 + '_ { 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)] pub(super) struct NamedCmd(pub(super) &'static str); impl Cmd for NamedCmd { fn name(&self) -> &'static str { self.0 } fn execute(&self, _: &CmdContext) -> Vec> { vec![] } } pub(super) 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. pub(super) 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) } /// Read the current value of a named buffer from context. pub(super) 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() } } pub(super) fn parse_panel(s: &str) -> Result { match s { "formula" => Ok(Panel::Formula), "category" => Ok(Panel::Category), "view" => Ok(Panel::View), other => Err(format!("Unknown panel: {other}")), } } pub(super) fn parse_axis(s: &str) -> Result { 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::*; #[test] fn parse_axis_recognizes_all_variants() { assert!(parse_axis("row").is_ok()); assert!(parse_axis("column").is_ok()); assert!(parse_axis("col").is_ok()); assert!(parse_axis("page").is_ok()); assert!(parse_axis("none").is_ok()); assert!(parse_axis("ROW").is_ok()); } #[test] fn parse_axis_rejects_unknown() { assert!(parse_axis("diagonal").is_err()); } }