d9b10905d0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
948 lines
34 KiB
Rust
948 lines
34 KiB
Rust
use crate::model::cell::CellKey;
|
|
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::Panel;
|
|
|
|
/// Decode a mode-name string into an `AppMode`. The table is `AppMode::from_name` in
|
|
/// the protocol crate: the spellings are keymap vocabulary and the keymap is shared.
|
|
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
|
|
AppMode::from_name(s).ok_or_else(|| format!("Unknown mode: {s}"))
|
|
}
|
|
|
|
use super::cell::*;
|
|
use super::commit::*;
|
|
use super::core::*;
|
|
use super::effect_cmds::*;
|
|
use super::fit::FitMeasure;
|
|
use super::grid::*;
|
|
use super::mode::*;
|
|
use super::navigation::*;
|
|
use super::panel::*;
|
|
use super::search::*;
|
|
use super::text_buffer::*;
|
|
use super::tile::*;
|
|
use super::wizard::*;
|
|
|
|
/// The cell under the cursor: what a coordinate command falls back to when given none.
|
|
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
|
ctx.cell_key().clone().ok_or("no cell at cursor".into())
|
|
}
|
|
|
|
/// A `Move` built from args takes the live cursor as its origin.
|
|
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
|
|
Move {
|
|
cursor: CursorState::from_ctx(ctx),
|
|
..cmd
|
|
}
|
|
}
|
|
|
|
/// A commit built from args still advances from the live cursor.
|
|
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
|
|
CommitAndAdvance {
|
|
cursor: CursorState::from_ctx(ctx),
|
|
..cmd
|
|
}
|
|
}
|
|
|
|
/// `<mode>` alone: commit the edit buffer to the cell under the cursor.
|
|
fn commit_at_cursor(
|
|
args: &[String],
|
|
ctx: &CmdContext,
|
|
advance: AdvanceDir,
|
|
) -> Result<CommitAndAdvance, String> {
|
|
let [mode] = args else {
|
|
return Err("takes a mode alone, or a mode, a value and coordinates".into());
|
|
};
|
|
Ok(CommitAndAdvance {
|
|
edit_mode: parse_mode_name(mode)?,
|
|
value: read_buffer(ctx, "edit"),
|
|
key: cursor_key(ctx)?,
|
|
advance,
|
|
cursor: CursorState::from_ctx(ctx),
|
|
})
|
|
}
|
|
|
|
/// For a command with no context-only fields: `parse`'s result is used as built.
|
|
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
|
|
cmd
|
|
}
|
|
|
|
/// 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_pure(&ClearBufferCmd(vec![]), ClearBufferCmd::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(ClearCellCommand {
|
|
key: parse_cell_key_from_args(args),
|
|
})
|
|
},
|
|
as_parsed,
|
|
Some(|_, ctx| {
|
|
Ok(ClearCellCommand {
|
|
key: cursor_key(ctx)?,
|
|
})
|
|
}),
|
|
);
|
|
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
|
|
r.register_pure(&FitMeasure::prototype(), FitMeasure::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_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse);
|
|
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
|
|
|
|
// ── Navigation (unified Move) ──────────────────────────────────────
|
|
r.register(
|
|
&Move {
|
|
kind: MoveKind::Relative(0, 0),
|
|
cursor: CursorState::default(),
|
|
cmd_name: "move-selection",
|
|
},
|
|
|args| {
|
|
require_args(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(Move {
|
|
kind: MoveKind::Relative(dr, dc),
|
|
cursor: CursorState::default(),
|
|
cmd_name: "move-selection",
|
|
})
|
|
},
|
|
move_from_cursor,
|
|
None,
|
|
);
|
|
// 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(Move {
|
|
kind: if $to_end {
|
|
MoveKind::ToEnd($is_row)
|
|
} else {
|
|
MoveKind::ToStart($is_row)
|
|
},
|
|
cursor: CursorState::default(),
|
|
cmd_name: $name,
|
|
})
|
|
},
|
|
move_from_cursor,
|
|
None,
|
|
);
|
|
};
|
|
}
|
|
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(args, 1)?;
|
|
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Move {
|
|
kind: MoveKind::Relative(n, 0),
|
|
cursor: CursorState::default(),
|
|
cmd_name: "scroll-rows",
|
|
})
|
|
},
|
|
move_from_cursor,
|
|
None,
|
|
);
|
|
r.register(
|
|
&Move {
|
|
kind: MoveKind::Page(0),
|
|
cursor: CursorState::default(),
|
|
cmd_name: "page-scroll",
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(Move {
|
|
kind: MoveKind::Page(dir),
|
|
cursor: CursorState::default(),
|
|
cmd_name: "page-scroll",
|
|
})
|
|
},
|
|
move_from_cursor,
|
|
None,
|
|
);
|
|
r.register(
|
|
&EnterAdvance {
|
|
cursor: CursorState::default(),
|
|
},
|
|
|_| {
|
|
Ok(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| EnterAdvance {
|
|
cursor: CursorState::from_ctx(ctx),
|
|
},
|
|
None,
|
|
);
|
|
|
|
// ── 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(YankCell {
|
|
key: parse_cell_key_from_args(args),
|
|
})
|
|
},
|
|
as_parsed,
|
|
Some(|_, ctx| {
|
|
Ok(YankCell {
|
|
key: cursor_key(ctx)?,
|
|
})
|
|
}),
|
|
);
|
|
r.register(
|
|
&PasteCell {
|
|
key: CellKey::new(vec![]),
|
|
},
|
|
|args| {
|
|
if args.is_empty() {
|
|
return Err("paste requires at least one Cat/Item coordinate".into());
|
|
}
|
|
Ok(PasteCell {
|
|
key: parse_cell_key_from_args(args),
|
|
})
|
|
},
|
|
as_parsed,
|
|
Some(|_, ctx| {
|
|
Ok(PasteCell {
|
|
key: cursor_key(ctx)?,
|
|
})
|
|
}),
|
|
);
|
|
// 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_pure(
|
|
&EditOrDrill {
|
|
edit_mode: AppMode::Normal,
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
let edit_mode = parse_mode_name(&args[0])?;
|
|
Ok(Box::new(EditOrDrill { edit_mode }))
|
|
},
|
|
);
|
|
r.register_pure(
|
|
&EnterEditAtCursorCmd {
|
|
target_mode: AppMode::Normal,
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
let target_mode = parse_mode_name(&args[0])?;
|
|
Ok(Box::new(EnterEditAtCursorCmd { target_mode }))
|
|
},
|
|
);
|
|
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(DrillIntoCell {
|
|
key: parse_cell_key_from_args(args),
|
|
})
|
|
},
|
|
as_parsed,
|
|
Some(|_, ctx| {
|
|
Ok(DrillIntoCell {
|
|
key: cursor_key(ctx)?,
|
|
})
|
|
}),
|
|
);
|
|
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
|
|
r.register_nullary(|| Box::new(ViewNavigate { forward: true }));
|
|
r.register_pure(&EnterMode(AppMode::Normal), |args| {
|
|
require_args(args, 1)?;
|
|
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
|
|
});
|
|
|
|
// ── Search ───────────────────────────────────────────────────────────
|
|
r.register_pure(&SearchNavigate(true), |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,
|
|
target: None,
|
|
},
|
|
|args| {
|
|
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
|
require_args(args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let target = args.get(1).map(|s| {
|
|
let open = s == "true";
|
|
(open, args.get(2).map(|s| s == "true").unwrap_or(open))
|
|
});
|
|
Ok(TogglePanelAndFocus { panel, target })
|
|
},
|
|
as_parsed,
|
|
None,
|
|
);
|
|
r.register(
|
|
&TogglePanelVisibility {
|
|
panel: Panel::Formula,
|
|
currently_open: false,
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
Ok(TogglePanelVisibility {
|
|
panel,
|
|
currently_open: false,
|
|
})
|
|
},
|
|
|cmd, ctx| TogglePanelVisibility {
|
|
currently_open: ctx.panel_open(cmd.panel),
|
|
..cmd
|
|
},
|
|
None,
|
|
);
|
|
r.register(
|
|
&CyclePanelFocus {
|
|
formula_open: false,
|
|
category_open: false,
|
|
view_open: false,
|
|
},
|
|
|_| {
|
|
Ok(CyclePanelFocus {
|
|
formula_open: false,
|
|
category_open: false,
|
|
view_open: false,
|
|
})
|
|
},
|
|
|_, ctx| CyclePanelFocus {
|
|
formula_open: ctx.formula_panel_open,
|
|
category_open: ctx.category_panel_open,
|
|
view_open: ctx.view_panel_open,
|
|
},
|
|
None,
|
|
);
|
|
r.register(
|
|
&MovePanelCursor {
|
|
panel: Panel::Formula,
|
|
delta: 0,
|
|
current: 0,
|
|
max: 0,
|
|
},
|
|
|args| {
|
|
require_args(args, 2)?;
|
|
let panel = parse_panel(&args[0])?;
|
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
|
Ok(MovePanelCursor {
|
|
panel,
|
|
delta,
|
|
current: 0,
|
|
max: 0,
|
|
})
|
|
},
|
|
|cmd, ctx| {
|
|
let (current, max) = match cmd.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.workbook.views.len()),
|
|
};
|
|
MovePanelCursor {
|
|
current,
|
|
max,
|
|
..cmd
|
|
}
|
|
},
|
|
None,
|
|
);
|
|
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(&MoveTileCursor(0), |args| {
|
|
require_args(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(
|
|
&TileAxisOp {
|
|
axis: Some(crate::view::Axis::Row),
|
|
},
|
|
|args| {
|
|
require_args(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_nullary(|| Box::new(ToggleGroupAtCursor { is_row: false }));
|
|
r.register_nullary(|| Box::new(HideSelectedRowItem));
|
|
|
|
// ── Text buffer ──────────────────────────────────────────────────────
|
|
r.register_pure(
|
|
&AppendChar {
|
|
buffer: String::new(),
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
Ok(Box::new(AppendChar {
|
|
buffer: args[0].clone(),
|
|
}))
|
|
},
|
|
);
|
|
r.register_pure(
|
|
&PopChar {
|
|
buffer: String::new(),
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
Ok(Box::new(PopChar {
|
|
buffer: args[0].clone(),
|
|
}))
|
|
},
|
|
);
|
|
r.register_nullary(|| Box::new(CommandModeBackspace));
|
|
|
|
// ── Commit ───────────────────────────────────────────────────────────
|
|
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
|
|
r.register(
|
|
&CommitAndAdvance {
|
|
key: CellKey::new(vec![]),
|
|
value: String::new(),
|
|
advance: AdvanceDir::Down,
|
|
cursor: CursorState::default(),
|
|
edit_mode: AppMode::editing(),
|
|
},
|
|
|args| {
|
|
if args.len() < 3 {
|
|
return Err("commit-cell-edit requires a mode, value, and coords".into());
|
|
}
|
|
let edit_mode = parse_mode_name(&args[0])?;
|
|
Ok(CommitAndAdvance {
|
|
key: parse_cell_key_from_args(&args[2..]),
|
|
value: args[1].clone(),
|
|
advance: AdvanceDir::Down,
|
|
cursor: CursorState::default(),
|
|
edit_mode,
|
|
})
|
|
},
|
|
commit_from_cursor,
|
|
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
|
|
);
|
|
r.register(
|
|
&CommitAndAdvance {
|
|
key: CellKey::new(vec![]),
|
|
value: String::new(),
|
|
advance: AdvanceDir::Right,
|
|
cursor: CursorState::default(),
|
|
edit_mode: AppMode::editing(),
|
|
},
|
|
|_| Err("commit-and-advance-right requires context".into()),
|
|
commit_from_cursor,
|
|
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
|
|
);
|
|
r.register(
|
|
&CommitBuffer {
|
|
kind: CommitKind::Category,
|
|
},
|
|
|args| {
|
|
require_args(args, 1)?;
|
|
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
|
|
Ok(CommitBuffer { kind })
|
|
},
|
|
as_parsed,
|
|
Some(|args, ctx| {
|
|
require_args(args, 1)?;
|
|
let kind = parse_commit_kind(&args[0], ctx.mode)?;
|
|
Ok(CommitBuffer { kind })
|
|
}),
|
|
);
|
|
r.register_nullary(|| Box::new(CommitExport));
|
|
r.register_nullary(|| Box::new(CommitSave));
|
|
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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::cmd::test_helpers::*;
|
|
|
|
/// Argument vectors to try when constructing a command, most specific
|
|
/// first. A command is exercised by the first one its parser accepts.
|
|
fn candidate_args() -> Vec<Vec<String>> {
|
|
[
|
|
vec![],
|
|
vec!["1"],
|
|
vec!["-1"],
|
|
vec!["right"],
|
|
vec!["normal"],
|
|
vec!["editing"],
|
|
vec!["row"],
|
|
vec!["formula"],
|
|
vec!["backward"],
|
|
vec!["a"],
|
|
vec!["Region"],
|
|
vec!["Region", "East"],
|
|
vec!["Region", "East", "1"],
|
|
vec!["Profit = 1"],
|
|
vec![",.2f"],
|
|
vec!["/tmp/improvise-registry-test.improv"],
|
|
vec!["1", "1"],
|
|
vec!["formula", "1"],
|
|
vec!["editing", "5", "Region", "East"],
|
|
vec!["editing", "5", "Region/East"],
|
|
vec!["formula", "false", "false"],
|
|
vec!["Region/East"],
|
|
]
|
|
.into_iter()
|
|
.map(|v| v.into_iter().map(String::from).collect())
|
|
.collect()
|
|
}
|
|
|
|
/// improvise-9cn/61f: four commands name themselves off a *field*, so a wrong
|
|
/// variant compiles and files the command under the wrong name (gotcha 15).
|
|
#[test]
|
|
fn every_entry_constructs_a_command_with_its_registered_name() {
|
|
let r = default_registry();
|
|
let wb = two_cat_model();
|
|
let layout = make_layout(&wb);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&wb, &layout, ®);
|
|
let mut unexercised: Vec<&'static str> = Vec::new();
|
|
|
|
for name in r.names().collect::<Vec<_>>() {
|
|
let parsed = candidate_args()
|
|
.iter()
|
|
.find_map(|args| r.parse(name, args).ok());
|
|
let interactive = candidate_args()
|
|
.iter()
|
|
.find_map(|args| r.interactive(name, args, &ctx).ok());
|
|
|
|
for (path, cmd) in [("parse", &parsed), ("interactive", &interactive)] {
|
|
if let Some(cmd) = cmd {
|
|
assert_eq!(
|
|
cmd.name(),
|
|
name,
|
|
"registered as \"{name}\" but {path} builds \"{}\"",
|
|
cmd.name()
|
|
);
|
|
}
|
|
}
|
|
|
|
if parsed.is_none() && interactive.is_none() {
|
|
unexercised.push(name);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
unexercised.is_empty(),
|
|
"no candidate args construct these by either path, \
|
|
so their names go unchecked: {unexercised:?}"
|
|
);
|
|
}
|
|
|
|
/// A variant mix-up shows up as a **collision**, not a mismatch: the wrong entry
|
|
/// takes its sibling's name and the sibling vanishes. Uniqueness catches it.
|
|
#[test]
|
|
fn registered_names_are_unique() {
|
|
let r = default_registry();
|
|
let mut seen: Vec<&'static str> = Vec::new();
|
|
let mut dupes: Vec<&'static str> = Vec::new();
|
|
for name in r.names() {
|
|
if seen.contains(&name) {
|
|
dupes.push(name);
|
|
}
|
|
seen.push(name);
|
|
}
|
|
assert!(
|
|
dupes.is_empty(),
|
|
"registered twice — one prototype names the wrong variant: {dupes:?}"
|
|
);
|
|
}
|
|
|
|
/// Aliases must resolve to a command that actually exists.
|
|
#[test]
|
|
fn every_alias_resolves_to_a_registered_command() {
|
|
let r = default_registry();
|
|
let names: Vec<&str> = r.names().collect();
|
|
for (alias, canonical) in [
|
|
("add-cat", "add-category"),
|
|
("formula", "add-formula"),
|
|
("add-view", "create-view"),
|
|
("q!", "force-quit"),
|
|
] {
|
|
assert!(
|
|
names.contains(&canonical),
|
|
"alias \"{alias}\" points at unregistered \"{canonical}\""
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A refusal names the word the user typed, not the one the alias table
|
|
/// resolved it to — `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn a_refusal_names_the_command_as_typed_through_an_alias() {
|
|
let err = crate::command::parse_line("formula Product Total = Widget + Gadget")
|
|
.expect_err("an unquoted formula is refused");
|
|
assert!(
|
|
err.starts_with("formula:"),
|
|
"the refusal does not open with the word typed: {err:?}"
|
|
);
|
|
assert!(
|
|
!err.contains("add-formula"),
|
|
"the refusal names a command the user did not type: {err:?}"
|
|
);
|
|
}
|
|
|
|
/// Stating the arity does not tell anyone what to do; stating the shape does.
|
|
#[test]
|
|
fn a_formula_refusal_states_the_shape_and_asks_for_quoting() {
|
|
let err = crate::command::parse_line("formula Product Total = Widget + Gadget")
|
|
.expect_err("an unquoted formula is refused");
|
|
assert!(
|
|
err.contains("quote"),
|
|
"the refusal does not suggest quoting, so it is unactionable: {err:?}"
|
|
);
|
|
assert!(
|
|
err.contains("<Name> = <expr>"),
|
|
"the refusal does not state the shape it wanted: {err:?}"
|
|
);
|
|
}
|
|
|
|
/// Every other refusal is named too, without the alias table in the way.
|
|
#[test]
|
|
fn a_refusal_from_a_canonical_name_is_named_as_typed() {
|
|
let err = crate::command::parse_line("add-item").expect_err("add-item needs arguments");
|
|
assert!(
|
|
err.starts_with("add-item:"),
|
|
"the refusal does not open with the word typed: {err:?}"
|
|
);
|
|
}
|
|
|
|
/// The ex-commands the help pages print are `HelpLine::Literal`/`Accent`
|
|
/// rows, which no derivation checks — `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn every_ex_command_the_help_pages_show_is_one_the_parser_accepts() {
|
|
let mut checked = 0;
|
|
for page in improvise_protocol::help::pages() {
|
|
for line in &page.lines {
|
|
let text = match line {
|
|
improvise_protocol::help::HelpLine::Accent(s) => *s,
|
|
improvise_protocol::help::HelpLine::Literal { keys, .. } => *keys,
|
|
_ => continue,
|
|
};
|
|
let text = text.trim();
|
|
if !text.starts_with(':') {
|
|
continue;
|
|
}
|
|
// One row may show two examples side by side, each opening
|
|
// with its own colon.
|
|
for example in text.split(':').filter(|s| !s.trim().is_empty()) {
|
|
if let Err(e) = crate::command::parse_line(example.trim()) {
|
|
panic!(
|
|
"help page {:?} shows `:{}`, which the parser refuses: {e}",
|
|
page.title,
|
|
example.trim()
|
|
);
|
|
}
|
|
checked += 1;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
checked >= 8,
|
|
"only {checked} ex-command examples were found; the scan has stopped seeing them"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor() {
|
|
let mut app = crate::ui::app::App::new(two_cat_model(), None);
|
|
app.view_state.buffers.insert("edit".into(), "decoy".into());
|
|
let ctx = app.cmd_context(
|
|
crate::command::key::KeyCode::Null,
|
|
crate::command::key::KeyModifiers::NONE,
|
|
);
|
|
let target = CellKey::new(vec![
|
|
("Month".into(), "Feb".into()),
|
|
("Type".into(), "Clothing".into()),
|
|
]);
|
|
assert_ne!(
|
|
ctx.cell_key().as_ref(),
|
|
Some(&target),
|
|
"the cursor is elsewhere"
|
|
);
|
|
let args: Vec<String> = ["editing", "5", "Type/Clothing", "Month/Feb"]
|
|
.map(String::from)
|
|
.into();
|
|
let dbg = format!(
|
|
"{:?}",
|
|
ctx.registry
|
|
.interactive("commit-cell-edit", &args, &ctx)
|
|
.expect("commit-cell-edit resolves")
|
|
);
|
|
assert!(
|
|
dbg.contains(&format!("key: {target:?}")),
|
|
"wrong cell: {dbg}"
|
|
);
|
|
assert!(dbg.contains("value: \"5\""), "wrong value: {dbg}");
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn every_argument_list_the_gate_accepts_the_run_accepts_too() {
|
|
let r = default_registry();
|
|
let mut wb = crate::workbook::Workbook::new("Empty");
|
|
wb.add_category("Foo").expect("a category with no items");
|
|
let app = crate::ui::app::App::new(wb, None);
|
|
let ctx = app.cmd_context(
|
|
crate::command::key::KeyCode::Null,
|
|
crate::command::key::KeyModifiers::NONE,
|
|
);
|
|
assert!(ctx.cell_key().is_none(), "the cursor must sit on no cell");
|
|
let mut accepted = 0;
|
|
for name in r.names().collect::<Vec<_>>() {
|
|
for args in candidate_args() {
|
|
if r.parse(name, &args).is_err() {
|
|
continue;
|
|
}
|
|
accepted += 1;
|
|
if let Err(e) = r.interactive(name, &args, &ctx) {
|
|
panic!("the gate accepts `{name} {args:?}` but the run refuses it: {e}");
|
|
}
|
|
}
|
|
}
|
|
assert!(accepted > 50, "only {accepted} accepted pairs were tried");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod panel_open_tests {
|
|
use super::*;
|
|
use crate::command::cmd::test_helpers::two_cat_model;
|
|
use crate::command::key::{KeyCode, KeyModifiers};
|
|
use crate::ui::app::{App, AppMode};
|
|
|
|
/// Write the raw field each `Panel` names. Spelling the fields here is what
|
|
/// gives the read side a second path — context/test-evidence/command.md.
|
|
fn open_only(app: &mut App, panel: Panel) {
|
|
app.view_state.formula_panel_open = panel == Panel::Formula;
|
|
app.view_state.category_panel_open = panel == Panel::Category;
|
|
app.view_state.view_panel_open = panel == Panel::View;
|
|
}
|
|
|
|
fn panel_mode(panel: Panel) -> AppMode {
|
|
match panel {
|
|
Panel::Formula => AppMode::FormulaPanel,
|
|
Panel::Category => AppMode::CategoryPanel,
|
|
Panel::View => AppMode::ViewPanel,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_context_reports_each_panels_own_open_flag() {
|
|
for opened in Panel::ALL {
|
|
let mut app = App::new(two_cat_model(), None);
|
|
open_only(&mut app, opened);
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
for asked in Panel::ALL {
|
|
assert_eq!(
|
|
ctx.panel_open(asked),
|
|
asked == opened,
|
|
"with only {opened:?} open, the context answers wrongly for {asked:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_panel_visibility_reads_the_panel_it_names() {
|
|
for opened in Panel::ALL {
|
|
let mut app = App::new(two_cat_model(), None);
|
|
open_only(&mut app, opened);
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
for asked in Panel::ALL {
|
|
let args = [asked.name().to_string()];
|
|
let cmd = ctx
|
|
.registry
|
|
.interactive("toggle-panel-visibility", &args, &ctx)
|
|
.expect("toggle-panel-visibility resolves");
|
|
let dbg = format!("{cmd:?}");
|
|
let expected = format!("currently_open: {}", asked == opened);
|
|
assert!(
|
|
dbg.contains(&expected),
|
|
"with only {opened:?} open, toggling {asked:?} read another \
|
|
panel's flag: {dbg}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_focused_open_panel_closes_and_a_closed_one_opens() {
|
|
for panel in Panel::ALL {
|
|
let mut app = App::new(two_cat_model(), None);
|
|
open_only(&mut app, panel);
|
|
app.view_state.mode = panel_mode(panel);
|
|
let args = [panel.name().to_string()];
|
|
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
let dbg = format!(
|
|
"{:?}",
|
|
ctx.registry
|
|
.interactive("toggle-panel-and-focus", &args, &ctx)
|
|
.expect("toggle-panel-and-focus resolves")
|
|
.execute(&ctx)
|
|
);
|
|
assert!(
|
|
dbg.contains("open: false"),
|
|
"{panel:?} is open and focused, so its key must close it: {dbg}"
|
|
);
|
|
|
|
let mut app = App::new(two_cat_model(), None);
|
|
app.view_state.formula_panel_open = false;
|
|
app.view_state.category_panel_open = false;
|
|
app.view_state.view_panel_open = false;
|
|
app.view_state.mode = panel_mode(panel);
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
let dbg = format!(
|
|
"{:?}",
|
|
ctx.registry
|
|
.interactive("toggle-panel-and-focus", &args, &ctx)
|
|
.expect("toggle-panel-and-focus resolves")
|
|
.execute(&ctx)
|
|
);
|
|
assert!(
|
|
dbg.contains("open: true"),
|
|
"{panel:?} is closed, so its own key must open it rather than \
|
|
leave it unopenable: {dbg}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn explicit_open_and_focus_arguments_beat_the_toggle() {
|
|
for panel in Panel::ALL {
|
|
let mut app = App::new(two_cat_model(), None);
|
|
app.view_state.formula_panel_open = false;
|
|
app.view_state.category_panel_open = false;
|
|
app.view_state.view_panel_open = false;
|
|
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
|
let args = [panel.name().to_string(), "false".into(), "false".into()];
|
|
let dbg = format!(
|
|
"{:?}",
|
|
ctx.registry
|
|
.interactive("toggle-panel-and-focus", &args, &ctx)
|
|
.expect("toggle-panel-and-focus resolves")
|
|
.execute(&ctx)
|
|
);
|
|
assert!(
|
|
dbg.contains("open: false") && !dbg.contains("open: true"),
|
|
"`{} false false` on a closed panel toggled it open: {dbg}",
|
|
panel.name()
|
|
);
|
|
}
|
|
}
|
|
}
|