use crate::ui::app::AppMode; use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; 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); } #[test] fn enter_export_prompt_sets_mode() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); let effects = EnterExportPrompt.execute(&ctx); let dbg = effects_debug(&effects); assert!( dbg.contains("ExportPrompt"), "Expected ExportPrompt mode, 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(), 2); let dbg = effects_debug(&effects); assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); } /// EditOrDrill must trust its `edit_mode` parameter rather than checking /// `ctx.mode` — the records-normal keymap supplies `records-editing`, /// but the command itself never inspects the runtime mode. This is the /// parallel of the (deleted) `enter_edit_mode_produces_editing_mode` /// test for the records branch. #[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(), 2); let dbg = effects_debug(&effects); assert!( dbg.contains("RecordsEditing"), "Expected RecordsEditing mode, got: {dbg}" ); } /// `EnterEditAtCursorCmd` must hand its `target_mode` straight through /// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence /// or commit-and-advance) decides; the command never inspects ctx. #[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(), 1); let dbg = format!("{:?}", effects[0]); 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}" ); } #[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> { let mut effects: Vec> = 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> { 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> { 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> { vec![Box::new(effect::Save), effect::change_mode(AppMode::Quit)] } } // ── Editing entry ─────────────────────────────────────────────────────── /// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell /// (categories on `Axis::None` and the cell is not a synthetic records-mode /// row), drill into it instead of editing. Otherwise pre-fill the edit /// buffer with the displayed cell value and enter `edit_mode`. /// /// `edit_mode` is supplied by the keymap binding — the command itself is /// mode-agnostic, so the records-normal keymap passes `records-editing` /// while the normal keymap passes `editing`. #[derive(Debug)] pub struct EditOrDrill { pub edit_mode: AppMode, } impl Cmd for EditOrDrill { fn name(&self) -> &'static str { "edit-or-drill" } fn execute(&self, ctx: &CmdContext) -> Vec> { // 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) }); // 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::set_status("cannot drill — no cell at cursor")]; }; return DrillIntoCell { key }.execute(ctx); } vec![ Box::new(effect::SetBuffer { name: "edit".to_string(), value: ctx.display_value.clone(), }), effect::change_mode(self.edit_mode.clone()), ] } } /// Thin command wrapper around the `EnterEditAtCursor` effect so it can /// participate in `Binding::Sequence`. `target_mode` is supplied as the /// command argument by the keymap binding. #[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> { vec![Box::new(effect::EnterEditAtCursor { target_mode: self.target_mode.clone(), })] } } /// 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> { 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> { 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> { let count = ctx.model.category_names().len(); if count > 0 { vec![ Box::new(effect::SetTileCatIdx(0)), effect::change_mode(AppMode::TileSelect), ] } else { vec![] } } }