320 lines
10 KiB
Rust
320 lines
10 KiB
Rust
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<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 [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<String>,
|
|
/// 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<CellKey> {
|
|
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<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}")),
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|