refactor: split cmd.rs
This commit is contained in:
277
src/command/cmd/core.rs
Normal file
277
src/command/cmd/core.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user