refactor: split cmd.rs

This commit is contained in:
Edward Langley
2026-04-09 02:25:23 -07:00
parent 767d524d4b
commit fd69126cdc
15 changed files with 4391 additions and 4300 deletions

277
src/command/cmd/core.rs Normal file
View File

@ -0,0 +1,277 @@
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::{Effect, Panel};
use crate::view::{Axis, GridLayout};
/// Read-only context available to commands for decision-making.
pub struct CmdContext<'a> {
pub model: &'a Model,
pub layout: &'a GridLayout,
pub registry: &'a CmdRegistry,
pub mode: &'a AppMode,
pub selected: (usize, usize),
pub row_offset: usize,
pub col_offset: usize,
pub search_query: &'a str,
pub yanked: &'a Option<CellValue>,
pub dirty: bool,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
/// Panel cursors
pub formula_cursor: usize,
pub cat_panel_cursor: usize,
pub view_panel_cursor: usize,
/// Tile select cursor (which category is selected)
pub tile_cat_idx: usize,
/// Named text buffers
pub buffers: &'a HashMap<String, String>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: &'a [String],
pub view_forward_stack: &'a [String],
/// Display value at the cursor — works uniformly for pivot and records mode.
pub display_value: String,
/// How many data rows/cols fit on screen (for viewport scrolling).
pub visible_rows: usize,
pub visible_cols: usize,
/// Expanded categories in the tree panel
pub expanded_cats: &'a std::collections::HashSet<String>,
/// The key that triggered this command
pub key_code: KeyCode,
}
impl<'a> CmdContext<'a> {
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
pub fn row_count(&self) -> usize {
self.layout.row_count()
}
pub fn col_count(&self) -> usize {
self.layout.col_count()
}
pub fn none_cats(&self) -> &[String] {
&self.layout.none_cats
}
}
impl<'a> CmdContext<'a> {
/// Resolve the category panel tree entry at the current cursor.
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats);
tree.into_iter().nth(self.cat_panel_cursor)
}
/// The category name at the current tree cursor (whether on a
/// category header or an item).
pub fn cat_at_cursor(&self) -> Option<String> {
self.cat_tree_entry().map(|e| e.cat_name().to_string())
}
/// Total number of entries in the category tree.
pub fn cat_tree_len(&self) -> usize {
crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len()
}
}
/// A command that reads state and produces effects.
pub trait Cmd: Debug + Send + Sync {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
/// The canonical name of this command (matches its registry key).
/// Used by the parser tests and for introspection.
#[allow(dead_code)]
fn name(&self) -> &'static str;
}
/// Factory that constructs a Cmd from text arguments (headless/script).
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
/// Factory that constructs a Cmd from the interactive context (keymap dispatch).
/// Receives both the keymap args and the interactive context so commands can
/// combine text arguments (e.g. panel name) with runtime state (e.g. whether
/// the panel is currently open).
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
/// A registered command entry with both text and interactive constructors.
struct CmdEntry {
name: &'static str,
parse: BoxParseFn,
interactive: BoxInteractiveFn,
}
/// Registry of commands constructible from text or from interactive context.
#[derive(Default)]
pub struct CmdRegistry {
entries: Vec<CmdEntry>,
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<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> {
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<Box<dyn Cmd>, 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<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)]
pub(super) struct NamedCmd(pub(super) &'static str);
impl Cmd for NamedCmd {
fn name(&self) -> &'static str {
self.0
}
fn execute(&self, _: &CmdContext) -> Vec<Box<dyn Effect>> {
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<Panel, String> {
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<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}")),
}
}