f4c0feec2c
EnterEditAtCursor asks core::uneditable, the commit door's own question, now shared with committable_cell_key. The browser's advance asks the projected id at the landing cell; lockstep pins both ends on a fold and on a formula's cell. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
472 lines
17 KiB
Rust
472 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
use std::fmt::Debug;
|
|
|
|
use crate::command::key::KeyCode;
|
|
|
|
use super::navigation::CursorState;
|
|
|
|
use crate::model::Model;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::{Effect, Panel};
|
|
use crate::view::{Axis, GridLayout, View};
|
|
use crate::workbook::Workbook;
|
|
use improvise_protocol::CellId;
|
|
|
|
/// What a command says when the cursor sits on a collapsed group's row (§14).
|
|
pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first";
|
|
const NO_CELL: &str = "no cell at cursor";
|
|
pub const AGGREGATE_REFUSAL: &str =
|
|
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
|
|
pub const NO_VALUE_REFUSAL: &str = "Only a measure column holds a value";
|
|
pub const INSIDE_A_DRILL_REFUSAL: &str =
|
|
"A value is not staged inside a drill — edit the cell instead";
|
|
|
|
/// What a command says when the cell it would write is a formula's result.
|
|
pub fn derived_refusal(target: &str) -> String {
|
|
format!("{target} is computed by a formula — edit the formula, not the cell")
|
|
}
|
|
|
|
/// Why no editor may open on or commit to `(row, col)`: an aggregate (a fold
|
|
/// named as one, §14) or a formula's result. `i` declines the same two arms.
|
|
pub fn uneditable(model: &Model, layout: &GridLayout, row: usize, col: usize) -> Option<String> {
|
|
match crate::ui::project::cell_id(model, layout, row, col) {
|
|
Some(CellId::Aggregate(_)) => Some(
|
|
match layout.folds_a_group(row, col) {
|
|
true => FOLD_REFUSAL,
|
|
false => AGGREGATE_REFUSAL,
|
|
}
|
|
.to_string(),
|
|
),
|
|
Some(CellId::Derived { target }) => Some(derived_refusal(&target)),
|
|
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => None,
|
|
}
|
|
}
|
|
|
|
/// Read-only context available to commands for decision-making: a `&Model`, the
|
|
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
|
|
pub struct CmdContext<'a> {
|
|
pub model: &'a Model,
|
|
pub workbook: &'a Workbook,
|
|
pub view: &'a View,
|
|
pub layout: &'a GridLayout,
|
|
pub registry: &'a CmdRegistry,
|
|
pub mode: &'a AppMode,
|
|
pub selected: (usize, usize),
|
|
pub row_offset: usize,
|
|
pub col_offset: usize,
|
|
pub search_query: &'a str,
|
|
pub yanked: &'a Option<CellValue>,
|
|
pub dirty: bool,
|
|
pub search_mode: bool,
|
|
pub formula_panel_open: bool,
|
|
pub category_panel_open: bool,
|
|
pub view_panel_open: bool,
|
|
/// Panel cursors
|
|
pub formula_cursor: usize,
|
|
pub cat_panel_cursor: usize,
|
|
pub view_panel_cursor: usize,
|
|
/// Tile select cursor (which category is selected)
|
|
pub tile_cat_idx: usize,
|
|
/// Named text buffers
|
|
pub buffers: &'a HashMap<String, String>,
|
|
/// What the open cell editor captured as it opened (improvise-ixxx).
|
|
pub opened_editor: Option<&'a improvise_protocol::OpenedEditor>,
|
|
/// View navigation stacks (for drill back/forward)
|
|
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
|
|
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
|
|
/// Whether the app currently has an active drill snapshot.
|
|
pub has_drill_state: bool,
|
|
/// Display value at the cursor — works uniformly for pivot and records mode.
|
|
pub display_value: String,
|
|
/// How many data rows/cols fit on screen (for viewport scrolling).
|
|
pub visible_rows: usize,
|
|
pub visible_cols: usize,
|
|
/// Expanded categories in the tree panel
|
|
pub expanded_cats: &'a std::collections::HashSet<String>,
|
|
/// The open import wizard, when one is running. Commands read its step
|
|
/// to decide what a key means (improvise-2wm).
|
|
pub wizard: Option<&'a crate::import::wizard::ImportWizard>,
|
|
/// The key that triggered this command
|
|
pub key_code: KeyCode,
|
|
}
|
|
|
|
impl<'a> CmdContext<'a> {
|
|
/// Return true when the current layout is a records-mode layout.
|
|
pub fn is_records_mode(&self) -> bool {
|
|
self.layout.is_records_mode()
|
|
}
|
|
|
|
/// The cell under the cursor, for a command that only READS it. The writers'
|
|
/// doors: `context/code/command.md`, "The cursor's cell has three doors".
|
|
pub fn cell_key(&self) -> Option<CellKey> {
|
|
self.layout.cell_key(self.selected.0, self.selected.1)
|
|
}
|
|
|
|
/// The cursor's key for a command that resolves it itself (commit, drill),
|
|
/// refusing a fold: its key names a group, which no record carries (§14).
|
|
pub fn unfolded_cell_key(&self) -> Result<CellKey, String> {
|
|
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
|
true => Err(FOLD_REFUSAL.to_string()),
|
|
false => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
|
|
}
|
|
}
|
|
|
|
/// The cursor's key for a commit, which resolves a records position itself
|
|
/// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it.
|
|
pub fn committable_cell_key(&self) -> Result<CellKey, String> {
|
|
let (row, col) = self.selected;
|
|
match uneditable(self.model, self.layout, row, col) {
|
|
Some(refusal) => Err(refusal),
|
|
None => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
|
|
}
|
|
}
|
|
|
|
/// The stored cell a VALUE written at the cursor lands on, answered by the
|
|
/// `CellId` the browser is given: `Record` is the one settable arm (ADR 002).
|
|
pub fn value_cell_key(&self) -> Result<CellKey, String> {
|
|
match self.cursor_cell_id() {
|
|
Some(CellId::Record(_)) => self.stored_cell_key(),
|
|
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
|
|
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
|
|
Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()),
|
|
None => Err(match self.cell_key() {
|
|
Some(_) => NO_VALUE_REFUSAL,
|
|
None => NO_CELL,
|
|
}
|
|
.to_string()),
|
|
}
|
|
}
|
|
|
|
/// What the cursor's cell is, asked exactly as the projection asks it.
|
|
fn cursor_cell_id(&self) -> Option<CellId> {
|
|
crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1)
|
|
}
|
|
|
|
/// An aggregate's refusal, naming the fold where the aggregate is one (§14).
|
|
fn aggregate_refusal(&self) -> String {
|
|
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
|
true => FOLD_REFUSAL,
|
|
false => AGGREGATE_REFUSAL,
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
/// A `Record`'s real key: a records position resolves through its row, and a
|
|
/// drill's records are staged, never written directly (gotcha 1, 19).
|
|
fn stored_cell_key(&self) -> Result<CellKey, String> {
|
|
let (row, col) = self.selected;
|
|
let stored = match (self.is_records_mode(), self.has_drill_state) {
|
|
(true, true) => return Err(INSIDE_A_DRILL_REFUSAL.to_string()),
|
|
(true, false) => self.layout.record_cell_key(row, col),
|
|
(false, _) => self.cell_key(),
|
|
};
|
|
stored.ok_or_else(|| NO_CELL.to_string())
|
|
}
|
|
|
|
/// The formula that owns the cell under the cursor, if one does. In records
|
|
/// mode the synthetic key names no measure, so the record's key is resolved.
|
|
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
|
|
let key = self.cell_key()?;
|
|
let real = self.layout.record_cell_key_of(&key).unwrap_or(key);
|
|
self.model.formula_for(&real)
|
|
}
|
|
|
|
/// Return synthetic record coordinates for the current cursor, if any.
|
|
pub fn synthetic_record_at_cursor(&self) -> Option<(usize, usize)> {
|
|
self.cell_key()
|
|
.as_ref()
|
|
.and_then(crate::view::synthetic_record_info)
|
|
}
|
|
|
|
/// Whether `panel` is open. The one `Panel` → flag mapping on this side of
|
|
/// the wall, mirroring `ViewState::panel_open` (improvise-y4xg).
|
|
pub fn panel_open(&self, panel: Panel) -> bool {
|
|
match panel {
|
|
Panel::Formula => self.formula_panel_open,
|
|
Panel::Category => self.category_panel_open,
|
|
Panel::View => self.view_panel_open,
|
|
}
|
|
}
|
|
|
|
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> {
|
|
self.cat_tree_entry_at(self.cat_panel_cursor)
|
|
}
|
|
|
|
/// The entry at an arbitrary row, which a delete needs to see what its
|
|
/// cursor would be left sitting on (improvise-uej8).
|
|
pub fn cat_tree_entry_at(&self, index: usize) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
|
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.view, self.expanded_cats);
|
|
tree.into_iter().nth(index)
|
|
}
|
|
|
|
/// 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.view, 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>;
|
|
|
|
/// Builds a command from context, reached ONLY for args `parse` refused.
|
|
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
|
|
|
|
/// A command whose one context-only field is the cursor it moves from. The
|
|
/// registry fills it, and no registration can — `context/code/cli.md` §8.
|
|
pub trait AtCursor: Cmd {
|
|
fn cursor_mut(&mut self) -> &mut CursorState;
|
|
}
|
|
|
|
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 whose interactive arm is `parse`, reaching `fallback`
|
|
/// only where `parse` refused — `context/code/cli.md` §8.
|
|
pub fn register<C: Cmd + 'static>(
|
|
&mut self,
|
|
prototype: &C,
|
|
parse: fn(&[String]) -> Result<C, String>,
|
|
fallback: Option<FallbackFn<C>>,
|
|
) {
|
|
self.push(prototype.name(), parse, fallback, |_, _| {});
|
|
}
|
|
|
|
/// As [`register`](Self::register), then the interactive arm moves the
|
|
/// command's cursor to the live one. The cursor is the only field it writes.
|
|
pub fn register_at_cursor<C: AtCursor + 'static>(
|
|
&mut self,
|
|
prototype: &C,
|
|
parse: fn(&[String]) -> Result<C, String>,
|
|
fallback: Option<FallbackFn<C>>,
|
|
) {
|
|
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
|
|
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
|
|
});
|
|
}
|
|
|
|
fn push<C: Cmd + 'static>(
|
|
&mut self,
|
|
name: &'static str,
|
|
parse: fn(&[String]) -> Result<C, String>,
|
|
fallback: Option<FallbackFn<C>>,
|
|
at_cursor: fn(&mut C, &CmdContext),
|
|
) {
|
|
self.entries.push(CmdEntry {
|
|
name,
|
|
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
|
|
interactive: Box::new(move |args, ctx| {
|
|
let mut built = match (parse(args), fallback) {
|
|
(Ok(cmd), _) => cmd,
|
|
(Err(_), Some(fallback)) => fallback(args, ctx)?,
|
|
(Err(err), None) => return Err(err),
|
|
};
|
|
at_cursor(&mut built, ctx);
|
|
Ok(Box::new(built) as Box<dyn Cmd>)
|
|
}),
|
|
});
|
|
}
|
|
|
|
/// Register a command that needs no interactive context: BOTH arms are
|
|
/// `parse`, so neither can demote the other — `context/code/command.md`.
|
|
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| 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> {
|
|
// The registry is the only thing that knows the word the user typed, so
|
|
// it is the only thing that may name it — `context/code/command.md`.
|
|
let canonical = self.resolve(name);
|
|
for e in &self.entries {
|
|
if e.name == canonical {
|
|
return (e.parse)(args).map_err(|err| format!("{name}: {err}"));
|
|
}
|
|
}
|
|
Err(format!("Unknown command: {name}"))
|
|
}
|
|
|
|
/// Construct a command from interactive context, always 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)
|
|
}
|
|
}
|
|
|
|
pub(super) fn require_args(args: &[String], n: usize) -> Result<(), String> {
|
|
if args.len() < n {
|
|
Err(format!("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()
|
|
}
|
|
}
|
|
|
|
/// The effect that writes a named buffer — the mirror of [`read_buffer`], and the
|
|
/// only place that knows "search" is not in the buffers map (improvise-gdl).
|
|
pub(super) fn write_buffer(name: &str, value: String) -> Box<dyn Effect> {
|
|
if name == "search" {
|
|
Box::new(crate::ui::effect::SetSearchQuery(value))
|
|
} else {
|
|
Box::new(crate::ui::effect::SetBuffer {
|
|
name: name.to_string(),
|
|
value,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(super) fn parse_panel(s: &str) -> Result<Panel, String> {
|
|
match s {
|
|
"formula" => Ok(Panel::Formula),
|
|
"category" => Ok(Panel::Category),
|
|
"view" => Ok(Panel::View),
|
|
other => Err(format!("Unknown panel: {other}")),
|
|
}
|
|
}
|
|
|
|
pub(super) fn parse_axis(s: &str) -> Result<Axis, String> {
|
|
match s.to_lowercase().as_str() {
|
|
"row" => Ok(Axis::Row),
|
|
"column" | "col" => Ok(Axis::Column),
|
|
"page" => Ok(Axis::Page),
|
|
"none" => Ok(Axis::None),
|
|
other => Err(format!("Unknown axis: {other}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_axis_recognizes_all_variants() {
|
|
assert!(parse_axis("row").is_ok());
|
|
assert!(parse_axis("column").is_ok());
|
|
assert!(parse_axis("col").is_ok());
|
|
assert!(parse_axis("page").is_ok());
|
|
assert!(parse_axis("none").is_ok());
|
|
assert!(parse_axis("ROW").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_axis_rejects_unknown() {
|
|
assert!(parse_axis("diagonal").is_err());
|
|
}
|
|
}
|