355 lines
12 KiB
Rust
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, ®);
|
|
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<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![]
|
|
}
|
|
}
|
|
}
|