refactor: split cmd.rs
This commit is contained in:
4300
src/command/cmd.rs
4300
src/command/cmd.rs
File diff suppressed because it is too large
Load Diff
87
src/command/cmd/cell.rs
Normal file
87
src/command/cmd/cell.rs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── 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)]
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/command/cmd/commit.rs
Normal file
203
src/command/cmd/commit.rs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
|
||||||
|
|
||||||
|
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direction to advance after committing a cell edit.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum AdvanceDir {
|
||||||
|
/// Move down (typewriter-style, wraps to next column at bottom).
|
||||||
|
Down,
|
||||||
|
/// Move right (clamps at rightmost column).
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
|
||||||
|
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitAndAdvance {
|
||||||
|
pub key: CellKey,
|
||||||
|
pub value: String,
|
||||||
|
pub advance: AdvanceDir,
|
||||||
|
pub cursor: CursorState,
|
||||||
|
}
|
||||||
|
impl Cmd for CommitAndAdvance {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
match self.advance {
|
||||||
|
AdvanceDir::Down => "commit-cell-edit",
|
||||||
|
AdvanceDir::Right => "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);
|
||||||
|
match self.advance {
|
||||||
|
AdvanceDir::Down => {
|
||||||
|
let adv = EnterAdvance {
|
||||||
|
cursor: self.cursor.clone(),
|
||||||
|
};
|
||||||
|
effects.extend(adv.execute(ctx));
|
||||||
|
}
|
||||||
|
AdvanceDir::Right => {
|
||||||
|
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
|
||||||
|
.regular_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty
|
||||||
|
/// + status + clear-buffer effects. If empty, return to CategoryPanel.
|
||||||
|
fn commit_add_from_buffer(
|
||||||
|
ctx: &CmdContext,
|
||||||
|
buffer_name: &str,
|
||||||
|
add_effect: impl FnOnce(&str) -> Option<Box<dyn Effect>>,
|
||||||
|
status_msg: impl FnOnce(&str) -> String,
|
||||||
|
) -> Vec<Box<dyn Effect>> {
|
||||||
|
let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default();
|
||||||
|
let trimmed = buf.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||||
|
}
|
||||||
|
let Some(add) = add_effect(&trimmed) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
add,
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(status_msg(&trimmed)),
|
||||||
|
Box::new(effect::SetBuffer {
|
||||||
|
name: buffer_name.to_string(),
|
||||||
|
value: String::new(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>> {
|
||||||
|
commit_add_from_buffer(
|
||||||
|
ctx,
|
||||||
|
"category",
|
||||||
|
|name| Some(Box::new(effect::AddCategory(name.to_string()))),
|
||||||
|
|name| format!("Added category \"{name}\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
||||||
|
category.clone()
|
||||||
|
} else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
commit_add_from_buffer(
|
||||||
|
ctx,
|
||||||
|
"item",
|
||||||
|
|name| {
|
||||||
|
Some(Box::new(effect::AddItem {
|
||||||
|
category: category.clone(),
|
||||||
|
item: name.to_string(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|name| format!("Added \"{name}\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
359
src/command/cmd/effect_cmds.rs
Normal file
359
src/command/cmd/effect_cmds.rs
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{require_args, Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── 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!(
|
||||||
|
AddItemsCmd,
|
||||||
|
"add-items",
|
||||||
|
|args: &[String]| {
|
||||||
|
if args.len() < 2 {
|
||||||
|
Err("add-items requires a category and at least one item".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let category = &args[0];
|
||||||
|
args[1..]
|
||||||
|
.iter()
|
||||||
|
.map(|item| -> Box<dyn Effect> {
|
||||||
|
Box::new(effect::AddItem {
|
||||||
|
category: category.clone(),
|
||||||
|
item: item.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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!(
|
||||||
|
SetFormatCmd,
|
||||||
|
"set-format",
|
||||||
|
|args: &[String]| require_args("set-format", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetNumberFormat(args.join(" "))),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ImportCmd,
|
||||||
|
"import",
|
||||||
|
|args: &[String]| require_args("import", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::StartImportWizard(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ExportCmd,
|
||||||
|
"export",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv");
|
||||||
|
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
WriteCmd,
|
||||||
|
"w",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
if args.is_empty() {
|
||||||
|
vec![Box::new(effect::Save)]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpCmd,
|
||||||
|
"help",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpPageNextCmd,
|
||||||
|
"help-page-next",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::help_page_next()]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpPagePrevCmd,
|
||||||
|
"help-page-prev",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::help_page_prev()]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
300
src/command/cmd/grid.rs
Normal file
300
src/command/cmd/grid.rs
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::AxisEntry;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── Grid operations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Toggle the row or column group collapse under the cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleGroupAtCursor {
|
||||||
|
pub is_row: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for ToggleGroupAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.is_row {
|
||||||
|
"toggle-group-under-cursor"
|
||||||
|
} else {
|
||||||
|
"toggle-col-group-under-cursor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let lookup = if self.is_row {
|
||||||
|
ctx.layout.row_group_for(ctx.selected.0)
|
||||||
|
} else {
|
||||||
|
ctx.layout.col_group_for(ctx.selected.1)
|
||||||
|
};
|
||||||
|
let Some((cat, group)) = lookup else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
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 or forward in view history.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ViewNavigate {
|
||||||
|
pub forward: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for ViewNavigate {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.forward {
|
||||||
|
"view-forward"
|
||||||
|
} else {
|
||||||
|
"view-back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if self.forward {
|
||||||
|
if ctx.view_forward_stack.is_empty() {
|
||||||
|
vec![effect::set_status("No forward view")]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::ViewForward)]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ctx.view_back_stack.is_empty() {
|
||||||
|
vec![effect::set_status("No previous view")]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::ApplyAndClearDrill),
|
||||||
|
Box::new(effect::ViewBack),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 and pivot mode using the view stack.
|
||||||
|
/// Entering records mode creates a `_Records` view and switches to it.
|
||||||
|
/// Leaving records mode navigates back to the previous view.
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(crate::view::synthetic_record_info)
|
||||||
|
.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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/command/cmd/mod.rs
Normal file
19
src/command/cmd/mod.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
pub mod core;
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod cell;
|
||||||
|
pub mod search;
|
||||||
|
pub mod panel;
|
||||||
|
pub mod grid;
|
||||||
|
pub mod tile;
|
||||||
|
pub mod text_buffer;
|
||||||
|
pub mod commit;
|
||||||
|
pub mod effect_cmds;
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
// Re-export items used by external code
|
||||||
|
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
||||||
|
pub use registry::default_registry;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
189
src/command/cmd/mode.rs
Normal file
189
src/command/cmd/mode.rs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
use super::grid::DrillIntoCell;
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
if let Some(mb) = self.0.minibuffer() {
|
||||||
|
effects.push(Box::new(effect::SetBuffer {
|
||||||
|
name: mb.buffer_key.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)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quit with dirty check — refuses if unsaved changes exist.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Quit;
|
||||||
|
impl Cmd for Quit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"q"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if ctx.dirty {
|
||||||
|
vec![effect::set_status(
|
||||||
|
"Unsaved changes. Use :q! to force quit or :wq to save+quit.",
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
vec![effect::change_mode(AppMode::Quit)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save then quit.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SaveAndQuit;
|
||||||
|
impl Cmd for SaveAndQuit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"wq"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::Save), effect::change_mode(AppMode::Quit)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(crate::view::synthetic_record_info)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
|
||||||
|
/// participate in `Binding::Sequence`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterEditAtCursorCmd;
|
||||||
|
impl Cmd for EnterEditAtCursorCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-edit-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::EnterEditAtCursor)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::export_prompt())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter search mode.
|
||||||
|
#[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())),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/command/cmd/navigation.rs
Normal file
279
src/command/cmd/navigation.rs
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
pub(super) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to move the cursor.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum MoveKind {
|
||||||
|
/// Relative offset (dr, dc) — subsumes MoveSelection and ScrollRows.
|
||||||
|
Relative(i32, i32),
|
||||||
|
/// Jump to start of axis: `true` = row, `false` = col.
|
||||||
|
ToStart(bool),
|
||||||
|
/// Jump to end of axis: `true` = row, `false` = col.
|
||||||
|
ToEnd(bool),
|
||||||
|
/// Page scroll: +1 = down, -1 = up (delta computed from visible_rows).
|
||||||
|
Page(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified navigation command. All variants go through `viewport_effects`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Move {
|
||||||
|
pub kind: MoveKind,
|
||||||
|
pub cursor: CursorState,
|
||||||
|
pub cmd_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Move {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
self.cmd_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
|
||||||
|
let col_max = self.cursor.col_count.saturating_sub(1) as i32;
|
||||||
|
let (nr, nc) = match &self.kind {
|
||||||
|
MoveKind::Relative(dr, dc) => {
|
||||||
|
let nr = (self.cursor.row as i32 + dr).clamp(0, row_max) as usize;
|
||||||
|
let nc = (self.cursor.col as i32 + dc).clamp(0, col_max) as usize;
|
||||||
|
(nr, nc)
|
||||||
|
}
|
||||||
|
MoveKind::ToStart(is_row) => {
|
||||||
|
if *is_row {
|
||||||
|
(0, self.cursor.col)
|
||||||
|
} else {
|
||||||
|
(self.cursor.row, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MoveKind::ToEnd(is_row) => {
|
||||||
|
if *is_row {
|
||||||
|
(row_max.max(0) as usize, self.cursor.col)
|
||||||
|
} else {
|
||||||
|
(self.cursor.row, col_max.max(0) as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MoveKind::Page(dir) => {
|
||||||
|
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir;
|
||||||
|
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
|
||||||
|
(nr, self.cursor.col)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viewport_effects(
|
||||||
|
nr,
|
||||||
|
nc,
|
||||||
|
self.cursor.row_offset,
|
||||||
|
self.cursor.col_offset,
|
||||||
|
self.cursor.visible_rows,
|
||||||
|
self.cursor.visible_cols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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()
|
||||||
|
}
|
||||||
347
src/command/cmd/panel.rs
Normal file
347
src/command/cmd/panel.rs
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect, Panel};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── 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![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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::formula_edit())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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![effect::set_status(
|
||||||
|
"Move cursor to a category header to change axis".to_string(),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::item_add(cat_name))]
|
||||||
|
} 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![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
586
src/command/cmd/registry.rs
Normal file
586
src/command/cmd/registry.rs
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
use crate::model::cell::CellKey;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::Panel;
|
||||||
|
|
||||||
|
use super::cell::*;
|
||||||
|
use super::commit::*;
|
||||||
|
use super::core::*;
|
||||||
|
use super::effect_cmds::*;
|
||||||
|
use super::grid::*;
|
||||||
|
use super::mode::*;
|
||||||
|
use super::navigation::*;
|
||||||
|
use super::panel::*;
|
||||||
|
use super::search::*;
|
||||||
|
use super::text_buffer::*;
|
||||||
|
use super::tile::*;
|
||||||
|
|
||||||
|
/// 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(&AddItemsCmd(vec![]), AddItemsCmd::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);
|
||||||
|
r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse);
|
||||||
|
r.register_pure(&ImportCmd(vec![]), ImportCmd::parse);
|
||||||
|
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
|
||||||
|
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
|
||||||
|
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
|
||||||
|
r.register(
|
||||||
|
&HelpPageNextCmd(vec![]),
|
||||||
|
HelpPageNextCmd::parse,
|
||||||
|
|_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))),
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&HelpPagePrevCmd(vec![]),
|
||||||
|
HelpPagePrevCmd::parse,
|
||||||
|
|_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Navigation (unified Move) ──────────────────────────────────────
|
||||||
|
r.register(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Relative(0, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
},
|
||||||
|
|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(Move {
|
||||||
|
kind: MoveKind::Relative(dr, dc),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|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(Move {
|
||||||
|
kind: MoveKind::Relative(dr, dc),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Jump-to-edge commands: first/last row/col
|
||||||
|
macro_rules! reg_jump {
|
||||||
|
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
|
||||||
|
$r.register(
|
||||||
|
&Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: $name,
|
||||||
|
},
|
||||||
|
|_| {
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: $name,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_, ctx| {
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
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(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Relative(0, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("scroll-rows", args, 1)?;
|
||||||
|
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(n, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("scroll-rows", args, 1)?;
|
||||||
|
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(n, 0),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Page(0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("page-scroll", args, 1)?;
|
||||||
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Page(dir),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("page-scroll", args, 1)?;
|
||||||
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Page(dir),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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(Quit));
|
||||||
|
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| {
|
||||||
|
Ok(Box::new(EnterEditMode {
|
||||||
|
initial_value: ctx.display_value.clone(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register_nullary(|| Box::new(EditOrDrill));
|
||||||
|
r.register_nullary(|| Box::new(EnterEditAtCursorCmd));
|
||||||
|
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(ViewNavigate { forward: false }));
|
||||||
|
r.register(
|
||||||
|
&ViewNavigate { forward: true },
|
||||||
|
|_| Ok(Box::new(ViewNavigate { forward: true })),
|
||||||
|
|_, _| Ok(Box::new(ViewNavigate { forward: true })),
|
||||||
|
);
|
||||||
|
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::command_mode(),
|
||||||
|
"category-add" => AppMode::category_add(),
|
||||||
|
"editing" => AppMode::editing(),
|
||||||
|
"formula-edit" => AppMode::formula_edit(),
|
||||||
|
"export-prompt" => AppMode::export_prompt(),
|
||||||
|
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));
|
||||||
|
|
||||||
|
// ── 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(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(TileAxisOp { axis: None }));
|
||||||
|
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(TileAxisOp { axis: Some(axis) }))
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grid operations ──────────────────────────────────────────────────
|
||||||
|
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
|
||||||
|
r.register(
|
||||||
|
&ToggleGroupAtCursor { is_row: false },
|
||||||
|
|_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|
||||||
|
|_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
&CommitAndAdvance {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
value: String::new(),
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.len() < 2 {
|
||||||
|
return Err("commit-cell-edit requires a value and coords".into());
|
||||||
|
}
|
||||||
|
Ok(Box::new(CommitAndAdvance {
|
||||||
|
key: parse_cell_key_from_args(&args[1..]),
|
||||||
|
value: args[0].clone(),
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_args, ctx| {
|
||||||
|
let value = read_buffer(ctx, "edit");
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(CommitAndAdvance {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&CommitAndAdvance {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
value: String::new(),
|
||||||
|
advance: AdvanceDir::Right,
|
||||||
|
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(CommitAndAdvance {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
advance: AdvanceDir::Right,
|
||||||
|
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));
|
||||||
|
|
||||||
|
// ── Aliases (short names for common commands) ────────────────────────
|
||||||
|
r.alias("add-cat", "add-category");
|
||||||
|
r.alias("formula", "add-formula");
|
||||||
|
r.alias("add-view", "create-view");
|
||||||
|
r.alias("q!", "force-quit");
|
||||||
|
|
||||||
|
r
|
||||||
|
}
|
||||||
121
src/command/cmd/search.rs
Normal file
121
src/command/cmd/search.rs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect, Panel};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||||
|
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::category_add()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))]
|
||||||
|
}
|
||||||
|
}
|
||||||
1407
src/command/cmd/tests.rs
Normal file
1407
src/command/cmd/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
135
src/command/cmd/text_buffer.rs
Normal file
135
src/command/cmd/text_buffer.rs
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{read_buffer, Cmd, CmdContext};
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
if self.buffer == "search" {
|
||||||
|
vec![Box::new(effect::SetSearchQuery(val))]
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
|
if self.buffer == "search" {
|
||||||
|
vec![Box::new(effect::SetSearchQuery(val))]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::SetBuffer {
|
||||||
|
name: self.buffer.clone(),
|
||||||
|
value: val,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)]
|
||||||
|
/// Execute the `:` command buffer by delegating to the command registry.
|
||||||
|
/// The `:` prompt is just another frontend to the scripting language —
|
||||||
|
/// same parser as `improvise script`.
|
||||||
|
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().to_string();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return vec![effect::change_mode(AppMode::Normal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
|
||||||
|
Ok(cmds) => {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
for cmd in cmds {
|
||||||
|
effects.extend(cmd.execute(ctx));
|
||||||
|
}
|
||||||
|
// Return to Normal unless a command already changed mode
|
||||||
|
if !effects.iter().any(|e| e.changes_mode()) {
|
||||||
|
effects.push(effect::change_mode(AppMode::Normal));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
vec![
|
||||||
|
effect::set_status(format!(":{raw} — {msg}")),
|
||||||
|
effect::change_mode(AppMode::Normal),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/command/cmd/tile.rs
Normal file
82
src/command/cmd/tile.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── 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 or set the axis for the category at the tile cursor.
|
||||||
|
/// Stays in TileSelect mode so the user can adjust multiple tiles.
|
||||||
|
/// `axis: None` -> cycle, `axis: Some(a)` -> set to `a`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TileAxisOp {
|
||||||
|
pub axis: Option<Axis>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axis_label(axis: Axis) -> &'static str {
|
||||||
|
match axis {
|
||||||
|
Axis::Row => "Row",
|
||||||
|
Axis::Column => "Col",
|
||||||
|
Axis::Page => "Page",
|
||||||
|
Axis::None => "None",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for TileAxisOp {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.axis.is_some() {
|
||||||
|
"set-axis-for-tile"
|
||||||
|
} else {
|
||||||
|
"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) {
|
||||||
|
let new_axis = match self.axis {
|
||||||
|
Some(axis) => axis,
|
||||||
|
None => {
|
||||||
|
let current = ctx.model.active_view().axis_of(name);
|
||||||
|
match current {
|
||||||
|
Axis::Row => Axis::Column,
|
||||||
|
Axis::Column => Axis::Page,
|
||||||
|
Axis::Page => Axis::None,
|
||||||
|
Axis::None => Axis::Row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let axis_effect: Box<dyn Effect> = match self.axis {
|
||||||
|
Some(axis) => Box::new(effect::SetAxis {
|
||||||
|
category: name.to_string(),
|
||||||
|
axis,
|
||||||
|
}),
|
||||||
|
None => Box::new(effect::CycleAxis(name.to_string())),
|
||||||
|
};
|
||||||
|
let status = format!("{} → {}", name, axis_label(new_axis));
|
||||||
|
vec![
|
||||||
|
axis_effect,
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(status),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user