Files
improvise/src/command/cmd/mode.rs
2026-04-15 22:47:51 -07:00

355 lines
12 KiB
Rust

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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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 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<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.
#[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: 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<Box<dyn Effect>> {
// 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<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 {
vec![
Box::new(effect::SetTileCatIdx(0)),
effect::change_mode(AppMode::TileSelect),
]
} else {
vec![]
}
}
}