e497aef110
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
471 lines
17 KiB
Rust
471 lines
17 KiB
Rust
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::{self, Effect};
|
|
|
|
use super::core::{Cmd, CmdContext, write_buffer};
|
|
use super::grid::DrillIntoCell;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::cmd::test_helpers::*;
|
|
use crate::workbook::Workbook;
|
|
|
|
#[test]
|
|
fn enter_tile_select_with_categories() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = EnterTileSelect;
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
let dbg = format!("{:?}", effects[1]);
|
|
assert!(
|
|
dbg.contains("TileSelect"),
|
|
"Expected TileSelect mode, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_tile_select_no_categories() {
|
|
let m = Workbook::new("Empty");
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = EnterTileSelect;
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
}
|
|
|
|
/// The `enter-export-prompt` shim is gone (improvise-s13): the generic
|
|
/// `enter-mode` carries the mode name and clears the prompt's buffer.
|
|
#[test]
|
|
fn enter_mode_export_prompt_sets_mode_and_clears_its_buffer() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = EnterMode(AppMode::export_prompt()).execute(&ctx);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("ExportPrompt"),
|
|
"Expected ExportPrompt mode, got: {dbg}"
|
|
);
|
|
assert!(
|
|
dbg.contains("SetBuffer"),
|
|
"Expected buffer clear, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
/// improvise-gdl: `EnterMode` built a `SetBuffer` directly, so a "search"-keyed
|
|
/// minibuffer would clear a map entry nobody reads. See the evidence.
|
|
#[test]
|
|
fn enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer() {
|
|
use crate::ui::app::{MinibufColor, MinibufferConfig};
|
|
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let mode = AppMode::Editing {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "search".into(),
|
|
prompt: "search: ".into(),
|
|
color: MinibufColor::Green,
|
|
},
|
|
};
|
|
let dbg = effects_debug(&EnterMode(mode).execute(&ctx));
|
|
assert!(
|
|
dbg.contains("SetSearchQuery"),
|
|
"a \"search\"-keyed minibuffer must clear the search query, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn force_quit_always_produces_quit_mode() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let mut ctx = make_ctx(&m, &layout, ®);
|
|
ctx.dirty = true;
|
|
let effects = ForceQuit.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn save_and_quit_produces_save_then_quit() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = SaveAndQuit.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
|
assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn edit_or_drill_without_aggregation_enters_edit() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = EditOrDrill {
|
|
edit_mode: AppMode::editing(),
|
|
}
|
|
.execute(&ctx);
|
|
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
|
|
}
|
|
|
|
/// `EditOrDrill` trusts its `edit_mode` parameter rather than checking
|
|
/// `ctx.mode` — the records-normal keymap supplies `records-editing`.
|
|
#[test]
|
|
fn edit_or_drill_passes_records_editing_mode_through() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
// Note: ctx.mode is still Normal here — the command must not look at it.
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = EditOrDrill {
|
|
edit_mode: AppMode::records_editing(),
|
|
}
|
|
.execute(&ctx);
|
|
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("RecordsEditing"),
|
|
"Expected RecordsEditing mode, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
/// `EnterEditAtCursorCmd` hands `target_mode` straight through, and fronts the
|
|
/// pair with `RebuildLayout` so the cursor reads a current layout (improvise-ehz).
|
|
#[test]
|
|
fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = EnterEditAtCursorCmd {
|
|
target_mode: AppMode::records_editing(),
|
|
}
|
|
.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
assert!(
|
|
format!("{:?}", effects[0]).contains("RebuildLayout"),
|
|
"layout must be refreshed first, got: {:?}",
|
|
effects[0]
|
|
);
|
|
let dbg = format!("{:?}", effects[1]);
|
|
assert!(
|
|
dbg.contains("RecordsEditing"),
|
|
"Expected RecordsEditing target_mode, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
/// The edit branch pre-fills the `edit` buffer with the cell's current
|
|
/// display value so the user can modify rather than retype.
|
|
#[test]
|
|
fn edit_or_drill_pre_fills_edit_buffer_with_display_value() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let mut ctx = make_ctx(&m, &layout, ®);
|
|
ctx.display_value = "42".to_string();
|
|
let effects = EditOrDrill {
|
|
edit_mode: AppMode::editing(),
|
|
}
|
|
.execute(&ctx);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("SetBuffer") && dbg.contains("\"edit\"") && dbg.contains("\"42\""),
|
|
"Expected SetBuffer(\"edit\", \"42\"), got: {dbg}"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn edit_or_drill_declines_a_collapsed_groups_row() {
|
|
let mut wb = crate::workbook::Workbook::new("T");
|
|
wb.add_category("Month").unwrap();
|
|
wb.add_category("Type").unwrap();
|
|
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
|
wb.add_item_in_group("Month", month, quarter).unwrap();
|
|
}
|
|
wb.active_view_mut().set_expand_depth("Month", 1);
|
|
wb.add_item("Type", "Food").unwrap();
|
|
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
|
|
let layout = make_layout(&wb);
|
|
let reg = make_registry();
|
|
let mut ctx = make_ctx(&wb, &layout, ®);
|
|
let edit = EditOrDrill {
|
|
edit_mode: AppMode::editing(),
|
|
};
|
|
ctx.selected = (1, 0);
|
|
assert!(
|
|
effects_debug(&edit.execute(&ctx)).contains("Editing"),
|
|
"Feb, beside the fold, still edits"
|
|
);
|
|
|
|
ctx.selected = (2, 0);
|
|
let dbg = effects_debug(&edit.execute(&ctx));
|
|
|
|
assert_eq!(layout.row_label(2), "Q2");
|
|
assert!(
|
|
dbg.contains("Collapsed group") && !dbg.contains("Editing") && !dbg.contains("Drill"),
|
|
"the fold names no record to edit or drill into, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
/// improvise-ir7l: the cell under the cursor is a formula target, so there is
|
|
/// nothing to type into. `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn edit_or_drill_refuses_a_formula_owned_cell() {
|
|
let mut m = two_cat_model();
|
|
m.active_view_mut().set_axis("Type", crate::view::Axis::Row);
|
|
m.active_view_mut()
|
|
.set_axis("_Measure", crate::view::Axis::Column);
|
|
m.model
|
|
.add_formula(crate::formula::parse_formula("Profit = 1", "_Measure").expect("parses"));
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let dbg = effects_debug(
|
|
&EditOrDrill {
|
|
edit_mode: AppMode::editing(),
|
|
}
|
|
.execute(&ctx),
|
|
);
|
|
assert!(
|
|
dbg.contains("computed by a formula") && !dbg.contains("Editing"),
|
|
"a formula cell must refuse rather than open an editor, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_search_mode_sets_flag_and_clears_query() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = EnterSearchMode.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("SetSearchMode(true)"),
|
|
"Expected search mode on, got: {dbg}"
|
|
);
|
|
assert!(
|
|
dbg.contains("SetSearchQuery"),
|
|
"Expected query reset, got: {dbg}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 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 mode's buffer through `write_buffer`, the only code that knows a
|
|
// runtime-named buffer may not be in the buffers map (improvise-gdl).
|
|
if let Some(mb) = self.0.minibuffer() {
|
|
effects.push(write_buffer(&mb.buffer_key, String::new()));
|
|
}
|
|
// An editor opened this way captured no seed, so no earlier one may stand.
|
|
if self.0.is_editing() {
|
|
effects.push(Box::new(effect::SetOpenedEditor(None)));
|
|
}
|
|
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; the `Quit` only lands if the `Save` succeeds (improvise-8tk).
|
|
/// **Not an `AAndB` command** — `wq` is the user's name — see `context/code/command.md`.
|
|
#[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 ───────────────────────────────────────────────────────
|
|
|
|
/// Smart dispatch for i/a: drill an aggregated pivot cell, else pre-fill the buffer
|
|
/// and enter `edit_mode` — which the keymap supplies, so this stays mode-agnostic.
|
|
#[derive(Debug)]
|
|
pub struct EditOrDrill {
|
|
pub edit_mode: AppMode,
|
|
}
|
|
impl EditOrDrill {
|
|
/// What to say when there is no cell under the cursor. It reads `edit_mode`, the
|
|
/// keymap's argument, not `ctx` (improvise-ct5/4ju) — see `context/code/command.md`.
|
|
fn nothing_at_cursor(&self) -> &'static str {
|
|
if self.edit_mode.is_records() {
|
|
"No record at cursor — press o to add a row"
|
|
} else {
|
|
"No cell at cursor"
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Cmd for EditOrDrill {
|
|
fn name(&self) -> &'static str {
|
|
"edit-or-drill"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
// Neither edit nor drill: no record carries a group's name (core.md §14).
|
|
if ctx.layout.folds_a_group(ctx.selected.0, ctx.selected.1) {
|
|
return vec![effect::refuse(
|
|
"Collapsed group — press z to expand it, then edit an item",
|
|
)];
|
|
}
|
|
// Only **regular** categories on None are true aggregation: virtuals like
|
|
// `_Index`/`_Dim` are always None in pivot mode and imply nothing.
|
|
let regular_none = ctx.none_cats().iter().any(|c| {
|
|
ctx.model
|
|
.category(c)
|
|
.map(|cat| cat.kind.is_regular())
|
|
.unwrap_or(false)
|
|
});
|
|
// Synthetic records-mode cells are never aggregated — edit directly.
|
|
// (This is a layout property, not a mode flag.)
|
|
let is_synthetic = ctx.synthetic_record_at_cursor().is_some();
|
|
let is_aggregated = !is_synthetic && regular_none;
|
|
if is_aggregated {
|
|
let Some(key) = ctx.cell_key().clone() else {
|
|
return vec![effect::refuse(self.nothing_at_cursor())];
|
|
};
|
|
return DrillIntoCell { key }.execute(ctx);
|
|
}
|
|
// The same question `cell_id` asks, off the same predicate. In records mode
|
|
// the synthetic key names no measure, so the record's key is resolved.
|
|
if let Some(formula) = ctx.owning_formula() {
|
|
return vec![effect::refuse(super::core::derived_refusal(
|
|
&formula.target,
|
|
))];
|
|
}
|
|
// Refused at open, as the browser refuses it: a commit would only be
|
|
// refused later, under a minibuffer that hides why (improvise-yc9r).
|
|
if let Some(improvise_protocol::CellId::NonItem { category, item }) = ctx.cursor_cell_id() {
|
|
return vec![effect::refuse(improvise_protocol::non_item_refusal(
|
|
&category, &item,
|
|
))];
|
|
}
|
|
vec![
|
|
Box::new(effect::SetBuffer {
|
|
name: "edit".to_string(),
|
|
value: ctx.display_value.clone(),
|
|
}),
|
|
Box::new(effect::SetOpenedEditor(Some(
|
|
improvise_protocol::OpenedEditor::on(ctx.display_value.clone()),
|
|
))),
|
|
effect::change_mode(self.edit_mode.clone()),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can join a
|
|
/// `Binding::Sequence`. `target_mode` is the keymap binding's argument.
|
|
#[derive(Debug)]
|
|
pub struct EnterEditAtCursorCmd {
|
|
pub target_mode: AppMode,
|
|
}
|
|
impl Cmd for EnterEditAtCursorCmd {
|
|
fn name(&self) -> &'static str {
|
|
"enter-edit-at-cursor"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
// Whatever ran ahead of us in the sequence (records `o` adds a row)
|
|
// may have invalidated the layout the cursor is read against.
|
|
vec![
|
|
Box::new(effect::RebuildLayout),
|
|
Box::new(effect::EnterEditAtCursor {
|
|
target_mode: self.target_mode.clone(),
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let tiles = crate::ui::project::tiles_of(ctx.model, ctx.view);
|
|
vec![
|
|
Box::new(effect::SetTileCatIdx(improvise_protocol::opening_tile(
|
|
&tiles,
|
|
))),
|
|
effect::change_mode(AppMode::TileSelect),
|
|
]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|