Files
improvise/crates/improvise-command/src/command/cmd/core.rs
T
Edward Langley f4c0feec2c Close the entry half of improvise-enz6: no editor re-opens on a cell i declines
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
2026-09-23 17:49:44 -07:00

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());
}
}