use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use std::rc::Rc; use ratatui::style::Color; use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; use crate::model::cell::CellValue; use crate::persistence; use crate::ui::grid::{ compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format, }; use crate::view::GridLayout; use crate::workbook::Workbook; /// A saved view+mode pair for the navigation stack. #[derive(Debug, Clone, PartialEq)] pub struct ViewFrame { pub view_name: String, pub mode: AppMode, } /// Drill-down state: frozen record snapshot + pending edits that have not /// yet been applied to the model. #[derive(Debug, Clone, Default)] pub struct DrillState { /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning). pub records: Rc>, /// Pending edits keyed by (record_idx, column_name) → new string value. /// column_name is either "Value" or a category name. pub pending_edits: std::collections::HashMap<(usize, String), String>, } /// Display configuration for the bottom-bar minibuffer. /// Carried structurally by text-entry `AppMode` variants. #[derive(Debug, Clone, PartialEq)] pub struct MinibufferConfig { pub buffer_key: &'static str, pub prompt: String, pub color: Color, } #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, Editing { minibuf: MinibufferConfig, }, FormulaEdit { minibuf: MinibufferConfig, }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. CategoryAdd { minibuf: MinibufferConfig, }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. ItemAdd { category: String, minibuf: MinibufferConfig, }, ViewPanel, TileSelect, ImportWizard, ExportPrompt { minibuf: MinibufferConfig, }, /// Vim-style `:` command line CommandMode { minibuf: MinibufferConfig, }, Help, Quit, /// Records-mode normal: inherits from Normal with records-specific bindings. RecordsNormal, /// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter. RecordsEditing { minibuf: MinibufferConfig, }, } impl AppMode { /// Extract the minibuffer config from text-entry modes, if present. pub fn minibuffer(&self) -> Option<&MinibufferConfig> { match self { Self::Editing { minibuf, .. } | Self::RecordsEditing { minibuf, .. } | Self::FormulaEdit { minibuf, .. } | Self::CommandMode { minibuf, .. } | Self::CategoryAdd { minibuf, .. } | Self::ItemAdd { minibuf, .. } | Self::ExportPrompt { minibuf, .. } => Some(minibuf), _ => None, } } /// True for any cell-editing mode (normal or records). pub fn is_editing(&self) -> bool { matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. }) } pub fn editing() -> Self { Self::Editing { minibuf: MinibufferConfig { buffer_key: "edit", prompt: "edit: ".into(), color: Color::Green, }, } } pub fn records_editing() -> Self { Self::RecordsEditing { minibuf: MinibufferConfig { buffer_key: "edit", prompt: "edit: ".into(), color: Color::Green, }, } } /// True when this mode is a records-mode variant. pub fn is_records(&self) -> bool { matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. }) } pub fn formula_edit() -> Self { Self::FormulaEdit { minibuf: MinibufferConfig { buffer_key: "formula", prompt: "formula: ".into(), color: Color::Cyan, }, } } pub fn command_mode() -> Self { Self::CommandMode { minibuf: MinibufferConfig { buffer_key: "command", prompt: ":".into(), color: Color::Yellow, }, } } pub fn category_add() -> Self { Self::CategoryAdd { minibuf: MinibufferConfig { buffer_key: "category", prompt: "new category: ".into(), color: Color::Yellow, }, } } pub fn item_add(category: String) -> Self { let prompt = format!("add item to {category}: "); Self::ItemAdd { category, minibuf: MinibufferConfig { buffer_key: "item", prompt, color: Color::Green, }, } } pub fn export_prompt() -> Self { Self::ExportPrompt { minibuf: MinibufferConfig { buffer_key: "export", prompt: "export path: ".into(), color: Color::Yellow, }, } } } /// Document state slice: the workbook and its IO bookkeeping. Distinct from /// `Workbook` itself (which is pure document semantics in `improvise-core`) /// because `file_path` and `dirty` are persistence-layer concerns. #[derive(Debug)] pub struct ModelState { pub workbook: Workbook, pub file_path: Option, pub dirty: bool, } impl Default for ModelState { fn default() -> Self { Self { workbook: Workbook::new("Untitled"), file_path: None, dirty: false, } } } /// UI session-state slice: mode, cursors, panels, buffers, navigation stacks, /// and other per-session state that does not persist to disk. #[derive(Debug)] pub struct ViewState { pub mode: AppMode, pub status_msg: String, pub wizard: Option, pub search_query: String, pub search_mode: bool, pub formula_panel_open: bool, pub category_panel_open: bool, pub view_panel_open: bool, pub cat_panel_cursor: usize, pub view_panel_cursor: usize, pub formula_cursor: usize, /// Yanked cell value for `p` paste pub yanked: Option, /// Tile select cursor (which category index is highlighted) pub tile_cat_idx: usize, /// View navigation history: views visited before the current one. /// Pushed on SwitchView, popped by `<` (back). pub view_back_stack: Vec, /// Views that were "back-ed" from, available for forward navigation (`>`). pub view_forward_stack: Vec, /// Frozen records list for the drill view. When present, this is the /// snapshot that records-mode layouts iterate — records don't disappear /// when filters would change. Pending edits are stored alongside and /// applied to the model on commit/navigate-away. pub drill_state: Option, /// Current page index in the Help screen (0-based). pub help_page: usize, /// Categories expanded in the category panel tree view. pub expanded_cats: std::collections::HashSet, /// Named text buffers for text-entry modes pub buffers: HashMap, /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) pub transient_keymap: Option>, } impl Default for ViewState { fn default() -> Self { Self { mode: AppMode::Normal, status_msg: String::new(), wizard: None, search_query: String::new(), search_mode: false, formula_panel_open: false, category_panel_open: false, view_panel_open: false, cat_panel_cursor: 0, view_panel_cursor: 0, formula_cursor: 0, yanked: None, tile_cat_idx: 0, view_back_stack: Vec::new(), view_forward_stack: Vec::new(), drill_state: None, help_page: 0, expanded_cats: std::collections::HashSet::new(), buffers: HashMap::new(), transient_keymap: None, } } } /// Top-level TUI application state. Composed of two named slices and a /// residue of fields that don't belong to either: /// /// - `model_state` — document state (Workbook + IO bookkeeping). Persists. /// - `view_state` — UI session state (mode, cursors, panels, buffers, …). /// Does not persist. /// - The remaining direct fields are runtime / derived / config — see /// per-field tags below. Each of them is documented as belonging to one /// of these residue categories so the slice boundary stays explicit: /// /// - **derived cache**: a pure function of the slices, recomputed on /// demand. Owned by `App` because rebuilding belongs to the host. /// - **runtime metadata**: data the runtime feeds in (terminal dims, /// wall-clock instants). Not part of model or view state. /// - **transient**: a flag whose lifetime is bounded to one method call /// on `App`. Reset on entry, never observed across calls. /// - **config**: data loaded once at startup and read but not written /// during a session. pub struct App { pub model_state: ModelState, pub view_state: ViewState, /// **Runtime metadata**: wall-clock instant of the last autosave. Used /// by `autosave_if_needed` to debounce writes. Not part of either slice /// because it's about IO timing, not document or session state. pub last_autosave: Instant, /// **Runtime metadata**: terminal dimensions, updated on resize events /// and at startup. Fed in by the host loop in `draw.rs`. pub term_width: u16, pub term_height: u16, /// **Derived cache**: pure function of `model_state.workbook`, /// `view_state.drill_state`, and the active view. Rebuilt via /// `rebuild_layout()` after any state change. Owned by `App` because /// it's the host's job to coordinate the rebuild, not the slices'. pub layout: GridLayout, /// **Transient**: when an effect sets this to `true` during /// `apply_effects`, the remaining effects in the batch are skipped. /// Reset to `false` at the start of every `apply_effects` call. Use /// via the `AbortChain` effect — this is the mechanism by which e.g. /// "advance at bottom-right" short-circuits the trailing /// `EnterEditAtCursor` in a `CommitAndAdvance` chain. pub abort_effects: bool, /// **Config**: keymap configuration assembled at startup by /// `KeymapSet::default_keymaps()`. Read every keypress but never /// mutated during a session. keymap_set: KeymapSet, } impl App { pub fn new(mut workbook: Workbook, file_path: Option) -> Self { // Recompute formula cache before building the initial layout so // formula-derived values are available on the first frame. The // cache is keyed by the active view's None-axis categories, so // the caller must gather them explicitly. let none_cats = workbook.active_view().none_cats(); workbook.model.recompute_formulas(&none_cats); let layout = { let view = workbook.active_view(); GridLayout::with_frozen_records(&workbook.model, view, None) }; Self { model_state: ModelState { workbook, file_path, dirty: false, }, view_state: ViewState::default(), last_autosave: Instant::now(), term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80), term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24), layout, abort_effects: false, keymap_set: KeymapSet::default_keymaps(), } } /// Rebuild the grid layout from current workbook, active view, and drill /// state. Note: `with_frozen_records` already handles pruning internally. pub fn rebuild_layout(&mut self) { let none_cats = self.model_state.workbook.active_view().none_cats(); self.model_state.workbook.model.recompute_formulas(&none_cats); let view = self.model_state.workbook.active_view(); let frozen = self.view_state.drill_state.as_ref().map(|s| Rc::clone(&s.records)); self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen); } pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.model_state.workbook.active_view(); let layout = &self.layout; let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model_state.workbook.model, workbook: &self.model_state.workbook, view, layout, registry: self.keymap_set.registry(), mode: &self.view_state.mode, selected: view.selected, row_offset: view.row_offset, col_offset: view.col_offset, search_query: &self.view_state.search_query, yanked: &self.view_state.yanked, dirty: self.model_state.dirty, search_mode: self.view_state.search_mode, formula_panel_open: self.view_state.formula_panel_open, category_panel_open: self.view_state.category_panel_open, view_panel_open: self.view_state.view_panel_open, buffers: &self.view_state.buffers, formula_cursor: self.view_state.formula_cursor, cat_panel_cursor: self.view_state.cat_panel_cursor, view_panel_cursor: self.view_state.view_panel_cursor, tile_cat_idx: self.view_state.tile_cat_idx, view_back_stack: &self.view_state.view_back_stack, view_forward_stack: &self.view_state.view_forward_stack, has_drill_state: self.view_state.drill_state.is_some(), display_value: { let key = layout.cell_key(sel_row, sel_col); if let Some(k) = &key { if let Some((idx, dim)) = crate::view::synthetic_record_info(k) { self.view_state.drill_state .as_ref() .and_then(|s| s.pending_edits.get(&(idx, dim)).cloned()) .or_else(|| layout.resolve_display(k)) .unwrap_or_default() } else { self.model_state.workbook .model .get_cell(k) .map(|v| v.to_string()) .unwrap_or_default() } } else { String::new() } }, visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); let col_widths = compute_col_widths(&self.model_state.workbook.model, layout, fmt_comma, fmt_decimals); let row_header_width = compute_row_header_width(layout); compute_visible_cols( &col_widths, row_header_width, self.term_width, view.col_offset, ) }, expanded_cats: &self.view_state.expanded_cats, key_code: key, } } pub fn apply_effects(&mut self, effects: Vec>) { self.abort_effects = false; for effect in effects { effect.apply(self); if self.abort_effects { // AbortChain (or another abort-setting effect) requested // that the rest of this batch be skipped. Reset the flag so // the next dispatch starts clean. self.abort_effects = false; break; } } self.rebuild_layout(); } /// True when the model has no user-defined categories (show welcome/help). /// Virtual categories (_Index, _Dim, _Measure) are always present and don't count. pub fn is_empty_model(&self) -> bool { use crate::model::category::CategoryKind; self.model_state.workbook.model.categories.values().all(|c| { matches!( c.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim | CategoryKind::VirtualMeasure ) }) } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { self.rebuild_layout(); // Transient keymap (prefix key sequence) takes priority if let Some(transient) = self.view_state.transient_keymap.take() { let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set .dispatch_transient(&transient, &ctx, key.code, key.modifiers) }; if let Some(effects) = effects { self.apply_effects(effects); } // Whether matched or not, transient is consumed return Ok(()); } // Try mode keymap — if a binding matches, apply effects and return let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set.dispatch(&ctx, key.code, key.modifiers) }; if let Some(effects) = effects { self.apply_effects(effects); } Ok(()) } pub fn autosave_if_needed(&mut self) { if self.model_state.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) && let Some(path) = &self.model_state.file_path.clone() { let ap = persistence::autosave_path(path); let _ = persistence::save(&self.model_state.workbook, &ap); self.last_autosave = Instant::now(); } } pub fn start_import_wizard(&mut self, json: serde_json::Value) { self.view_state.wizard = Some(ImportWizard::new(json)); self.view_state.mode = AppMode::ImportWizard; } /// Hint text for the status bar (context-sensitive) pub fn hint_text(&self) -> &'static str { match &self.view_state.mode { AppMode::Normal => { "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd" } AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => { "Enter:commit Tab:commit+right Esc:cancel" } AppMode::RecordsNormal => { "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd" } AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::CategoryPanel => { "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back" } AppMode::CategoryAdd { .. } => { "Enter:add & continue Tab:same Esc:done — type a category name" } AppMode::ItemAdd { .. } => { "Enter:add & continue Tab:same Esc:done — type an item name" } AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back", AppMode::CommandMode { .. } => { ":q quit :w save :import :add-cat :formula :show-item :help" } AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", AppMode::Help => "h/l:pages q/Esc:close", _ => "", } } } #[cfg(test)] mod tests { use super::*; /// improvise-3vr: ModelState and ViewState are the named slices of App /// state introduced by the vb4 refactor. Step 1 only requires that the /// types exist and are constructible; subsequent steps move fields in. #[test] fn model_state_and_view_state_are_constructible() { let _: ModelState = ModelState::default(); let _: ViewState = ViewState::default(); } /// improvise-x2c: ModelState owns the document slice — workbook, /// file_path, and dirty. App accesses them through model_state. #[test] fn app_model_state_owns_workbook_file_path_and_dirty() { let app = App::new(Workbook::new("T"), Some(PathBuf::from("/tmp/x"))); let _: &Workbook = &app.model_state.workbook; let _: &Option = &app.model_state.file_path; let _: bool = app.model_state.dirty; } /// improvise-ew0: ViewState owns the UI session slice — mode, status, /// search, panels, navigation, drill, yanked, buffers, etc. App /// accesses them through view_state. #[test] fn app_view_state_owns_ui_session_fields() { let app = App::new(Workbook::new("T"), None); let _: &AppMode = &app.view_state.mode; let _: &str = &app.view_state.status_msg; let _: &Option = &app.view_state.wizard; let _: &str = &app.view_state.search_query; let _: bool = app.view_state.search_mode; let _: bool = app.view_state.formula_panel_open; let _: bool = app.view_state.category_panel_open; let _: bool = app.view_state.view_panel_open; let _: usize = app.view_state.cat_panel_cursor; let _: usize = app.view_state.view_panel_cursor; let _: usize = app.view_state.formula_cursor; let _: &Option = &app.view_state.yanked; let _: usize = app.view_state.tile_cat_idx; let _: &Vec = &app.view_state.view_back_stack; let _: &Vec = &app.view_state.view_forward_stack; let _: &Option = &app.view_state.drill_state; let _: usize = app.view_state.help_page; let _: &std::collections::HashSet = &app.view_state.expanded_cats; let _: &HashMap = &app.view_state.buffers; let _: &Option> = &app.view_state.transient_keymap; } fn two_col_model() -> App { let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); // → Row axis wb.add_category("Col").unwrap(); // → Column axis wb.model.category_mut("Row").unwrap().add_item("A"); wb.model.category_mut("Row").unwrap().add_item("B"); wb.model.category_mut("Row").unwrap().add_item("C"); wb.model.category_mut("Col").unwrap().add_item("X"); wb.model.category_mut("Col").unwrap().add_item("Y"); App::new(wb, None) } fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) { let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); let effects = cmd.execute(&ctx); drop(ctx); app.apply_effects(effects); } fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance { use crate::command::cmd::navigation::CursorState; let view = app.model_state.workbook.active_view(); let cursor = CursorState { row: view.selected.0, col: view.selected.1, row_count: 3, col_count: 2, row_offset: 0, col_offset: 0, visible_rows: 20, visible_cols: 8, }; crate::command::cmd::navigation::EnterAdvance { cursor } } #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); app.model_state.workbook.active_view_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model_state.workbook.active_view().selected, (1, 0)); } #[test] fn enter_advance_wraps_to_top_of_next_column() { let mut app = two_col_model(); // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1) app.model_state.workbook.active_view_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model_state.workbook.active_view().selected, (0, 1)); } #[test] fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); app.model_state.workbook.active_view_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model_state.workbook.active_view().selected, (2, 1)); } #[test] fn import_command_switches_to_import_wizard_mode() { // Regression: execute_command was resetting mode to Normal after // :import set it to ImportWizard, so the wizard never appeared. let mut app = two_col_model(); let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]); app.start_import_wizard(json); assert!( matches!(app.view_state.mode, AppMode::ImportWizard), "mode should be ImportWizard after start_import_wizard" ); } #[test] fn execute_import_command_leaves_mode_as_import_wizard() { let mut app = two_col_model(); // Inject JSON via start_import_wizard to simulate what :import does app.start_import_wizard(serde_json::json!([{"x": 1}])); // After the command the mode must NOT be reset to Normal assert!( !matches!(app.view_state.mode, AppMode::Normal), "mode must not be Normal after import wizard is opened" ); } #[test] fn command_mode_typing_appends_to_buffer() { use crossterm::event::KeyEvent; let mut app = two_col_model(); // Enter command mode with ':' app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. })); assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("")); // Type 'q' app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q")); } #[test] fn col_offset_scrolls_when_cursor_moves_past_visible_columns() { use crate::model::cell::{CellKey, CellValue}; // Create a model with 8 wide columns. Column item names are 30 chars // each → column widths ~31 chars. With term_width=80, row header ~4, // data area ~76 → only ~2 columns actually fit. But the rough estimate // (80−30)/12 = 4 over-counts, so viewport_effects never scrolls. let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); wb.add_category("Col").unwrap(); wb.model.category_mut("Row").unwrap().add_item("R1"); for i in 0..8 { let name = format!("VeryLongColumnItemName_{i:03}"); wb.model.category_mut("Col").unwrap().add_item(&name); } // Populate a value so the workbook isn't empty let key = CellKey::new(vec![ ("Row".to_string(), "R1".to_string()), ("Col".to_string(), "VeryLongColumnItemName_000".to_string()), ]); wb.model.set_cell(key, CellValue::Number(1.0)); let mut app = App::new(wb, None); app.term_width = 80; // Press 'l' (right) 3 times to move cursor to column 3. // Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide), // so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4 // thinks 4 columns fit, so it won't scroll until col 4. for _ in 0..3 { app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); } assert_eq!( app.model_state.workbook.active_view().selected.1, 3, "cursor should be at column 3" ); assert!( app.model_state.workbook.active_view().col_offset > 0, "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \ in 80-char terminal with 26-char-wide columns), but col_offset is {}", app.model_state.workbook.active_view().col_offset ); } #[test] fn home_jumps_to_first_col() { let mut app = two_col_model(); app.model_state.workbook.active_view_mut().selected = (1, 1); app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected, (1, 0)); } #[test] fn end_jumps_to_last_col() { let mut app = two_col_model(); app.model_state.workbook.active_view_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected, (1, 1)); } #[test] fn page_down_scrolls_by_three_quarters_visible() { let mut app = two_col_model(); // Add enough rows for i in 0..30 { app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; // ~20 visible rows → delta = 15 app.model_state.workbook.active_view_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected.1, 0, "column preserved"); assert!( app.model_state.workbook.active_view().selected.0 > 0, "row should advance on PageDown" ); // 3/4 of ~20 = 15 assert_eq!(app.model_state.workbook.active_view().selected.0, 15); } #[test] fn page_up_scrolls_backward() { let mut app = two_col_model(); for i in 0..30 { app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; app.model_state.workbook.active_view_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected.0, 5); } #[test] fn jump_last_row_scrolls_with_small_terminal() { let mut app = two_col_model(); // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12. for i in 0..10 { app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows app.model_state.workbook.active_view_mut().selected = (0, 0); // G jumps to last row (row 12) app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)) .unwrap(); let last = app.model_state.workbook.active_view().selected.0; assert_eq!(last, 12, "should be at last row"); // With only ~5 visible rows and 13 rows, offset should scroll. // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll. let offset = app.model_state.workbook.active_view().row_offset; assert!( offset > 0, "row_offset should scroll when last row is beyond visible area, but is {offset}" ); } #[test] fn ctrl_d_scrolls_viewport_with_small_terminal() { let mut app = two_col_model(); for i in 0..30 { app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows app.model_state.workbook.active_view_mut().selected = (0, 0); // Ctrl+d scrolls by 5 rows app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected.0, 5); // Press Ctrl+d again — now at row 10 with only 5 visible rows, // row_offset should have scrolled (not stay at 0 due to hardcoded 20) app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.model_state.workbook.active_view().selected.0, 10); assert!( app.model_state.workbook.active_view().row_offset > 0, "row_offset should scroll with small terminal, but is {}", app.model_state.workbook.active_view().row_offset ); } #[test] fn tab_in_edit_mode_commits_and_moves_right() { let mut app = two_col_model(); app.model_state.workbook.active_view_mut().selected = (0, 0); // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); // Type a digit app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); // Press Tab — should commit, move right, re-enter edit mode app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); // Should be in edit mode on column 1 assert!( matches!(app.view_state.mode, AppMode::Editing { .. }), "should be in edit mode after Tab, but mode is {:?}", app.view_state.mode ); assert_eq!( app.model_state.workbook.active_view().selected.1, 1, "should have moved to column 1" ); } /// Regression: pressing `o` in an empty records view should create the /// Pressing R to enter records mode should sort existing data by CellKey /// so display order is deterministic regardless of insertion order. #[test] fn entering_records_mode_sorts_existing_data() { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.model.category_mut("Region").unwrap().add_item("North"); wb.model.category_mut("Region").unwrap().add_item("East"); // Insert in reverse-alphabetical order wb.model.set_cell( CellKey::new(vec![("Region".into(), "North".into())]), CellValue::Number(1.0), ); wb.model.set_cell( CellKey::new(vec![("Region".into(), "East".into())]), CellValue::Number(2.0), ); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode()); let region_col = (0..app.layout.col_count()) .find(|&c| app.layout.col_label(c) == "Region") .unwrap(); let row0 = app.layout.records_display(0, region_col).unwrap(); let row1 = app.layout.records_display(1, region_col).unwrap(); assert_eq!( row0, "East", "R should sort existing data: first row should be East" ); assert_eq!( row1, "North", "R should sort existing data: second row should be North" ); } /// first synthetic row instead of only entering edit mode on empty space. #[test] fn add_record_row_in_empty_records_view_creates_first_row() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.model.category_mut("Region").unwrap().add_item("East"); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!(app.layout.is_records_mode(), "R should enter records mode"); assert_eq!(app.layout.row_count(), 0, "fresh records view starts empty"); app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.layout.row_count(), 1, "o should create the first record row in an empty records view" ); assert!( app.view_state.mode.is_editing(), "o should leave the app in edit mode, got {:?}", app.view_state.mode ); } /// Regression: editing the first row in a blank model's records view /// should persist the typed value even though plain records mode does not /// use drill state. With _Measure as the first column, `o` lands on it; /// type a measure name, Tab to Value, type the number, Enter to commit. #[test] fn edit_record_row_in_blank_model_persists_value() { use crate::model::cell::CellKey; let mut app = App::new(Workbook::new("T"), None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); // `o` adds a record row and enters edit at (0, 0) = _Measure column app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)) .unwrap(); // Type a measure name app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)) .unwrap(); // Tab to commit _Measure and move to Value column app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); // Type the value app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); // Enter to commit app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.model_state.workbook.model.get_cell(&CellKey::new(vec![( "_Measure".to_string(), "Rev".to_string(), )])), Some(&crate::model::cell::CellValue::Number(5.0)), "editing a synthetic row in plain records mode should write the value" ); } /// Build a records-mode app with two data rows for testing Tab/Enter /// behavior at boundaries. Row 0 has _Measure=meas2, row 1 has _Measure=meas1. fn records_model_with_two_rows() -> App { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.model.category_mut("Region").unwrap().add_item("North"); wb.model.category_mut("_Measure").unwrap().add_item("meas1"); wb.model.category_mut("_Measure").unwrap().add_item("meas2"); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "meas2".into()), ]), CellValue::Number(10.0), ); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "meas1".into()), ]), CellValue::Number(20.0), ); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!( app.layout.is_records_mode(), "setup: should be records mode" ); assert_eq!(app.layout.row_count(), 2, "setup: should have 2 records"); let cols: Vec = (0..app.layout.col_count()) .map(|i| app.layout.col_label(i)) .collect(); assert!( cols.contains(&"Region".to_string()), "setup: should have Region column; got {:?}", cols ); assert!( cols.contains(&"_Measure".to_string()), "setup: should have _Measure column; got {:?}", cols ); assert_eq!( cols.last().unwrap(), "Value", "setup: Value must be last column; got {:?}", cols ); app } /// improvise-3zq (bug #2): `AddRecordRow` creates a cell with an empty /// `CellKey` when no Page-axis categories supply coords — that cell /// serialises as ` = 0` in .improv and re-appears on every records /// toggle. Leaving records mode must clean up any such meaningless /// records (inverse of the `SortData` that runs on entry). #[test] fn leaving_records_mode_cleans_empty_key_cells() { use crate::model::cell::{CellKey, CellValue}; let mut app = records_model_with_two_rows(); // Simulate Tab-at-bottom-right having produced an empty-key cell. app.model_state.workbook .model .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); assert!( app.model_state.workbook .model .data .iter_cells() .any(|(k, _)| k.0.is_empty()), "setup: empty-key cell should be present" ); // Leave records mode via R. app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); assert!( !app.layout.is_records_mode(), "setup: should have left records mode" ); assert!( !app.model_state.workbook .model .data .iter_cells() .any(|(k, _)| k.0.is_empty()), "empty-key records should be cleaned when leaving records mode" ); } /// improvise-3zq (bug #1): Enter on the bottom-right cell of records /// view should commit and leave edit mode. Previously `CommitAndAdvance` /// pushed an `EnterEditAtCursor` effect unconditionally, so the cursor /// stayed put and we re-entered editing on the same cell. #[test] fn enter_at_bottom_right_of_records_view_exits_editing() { let mut app = records_model_with_two_rows(); let last_row = app.layout.row_count() - 1; let last_col = app.layout.col_count() - 1; app.model_state.workbook.active_view_mut().selected = (last_row, last_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(app.view_state.mode.is_editing(), "setup: should be editing"); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( !app.view_state.mode.is_editing(), "Enter at bottom-right should exit editing, got {:?}", app.view_state.mode ); assert!( matches!(app.view_state.mode, AppMode::RecordsNormal), "should return to RecordsNormal, got {:?}", app.view_state.mode ); } /// improvise-hmu: TAB on the bottom-right cell of records view should /// insert a new record below and move to the first cell of the new row /// in edit mode. #[test] fn tab_on_bottom_right_of_records_inserts_below() { let mut app = records_model_with_two_rows(); let initial_rows = app.layout.row_count(); assert!(initial_rows >= 1, "setup: need at least 1 record"); let last_row = initial_rows - 1; let last_col = app.layout.col_count() - 1; app.model_state.workbook.active_view_mut().selected = (last_row, last_col); // Enter edit mode on the bottom-right cell app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(app.view_state.mode.is_editing(), "setup: should be editing"); // TAB should commit, insert below, move to first cell of new row app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.layout.row_count(), initial_rows + 1, "TAB on bottom-right should insert a record below" ); assert_eq!( app.model_state.workbook.active_view().selected, (initial_rows, 0), "TAB should move to first cell of the new row" ); assert!( app.view_state.mode.is_editing(), "should enter edit mode on the new cell, got {:?}", app.view_state.mode ); } /// Drill-view edits should stay staged in drill state until the user /// navigates back, at which point ApplyAndClearDrill writes them through. #[test] fn drill_edit_is_staged_until_view_back() { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); wb.model.category_mut("Region").unwrap().add_item("East"); wb.model.category_mut("Month").unwrap().add_item("Jan"); let record_key = CellKey::new(vec![ ("Month".to_string(), "Jan".to_string()), ("Region".to_string(), "East".to_string()), ]); wb.model .set_cell(record_key.clone(), CellValue::Number(1.0)); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE)) .unwrap(); assert!(app.view_state.drill_state.is_some(), "drill should create drill state"); let value_col = (0..app.layout.col_count()) .find(|&col| app.layout.col_label(col) == "Value") .expect("drill view should include a Value column"); app.model_state.workbook.active_view_mut().selected = (0, value_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert_eq!( app.model_state.workbook.model.get_cell(&record_key), Some(&CellValue::Number(1.0)), "drill edit should remain staged until leaving the drill view" ); assert_eq!( app.view_state.drill_state .as_ref() .and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))), Some(&"9".to_string()), "drill edit should be recorded in pending_edits" ); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.model_state.workbook.model.get_cell(&record_key), Some(&CellValue::Number(9.0)), "leaving drill view should apply the staged edit" ); } /// Suspected bug: blanking a records-mode category coordinate should not /// create an item with an empty name. #[test] fn blanking_records_category_does_not_create_empty_item() { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.model.category_mut("Region").unwrap().add_item("East"); wb.model.set_cell( CellKey::new(vec![("Region".to_string(), "East".to_string())]), CellValue::Number(1.0), ); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); for _ in 0..4 { app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) .unwrap(); } app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( !app.model_state.workbook .model .category("Region") .unwrap() .items .contains_key(""), "records-mode edits should not create empty category items" ); } #[test] fn command_mode_buffer_cleared_on_reentry() { use crossterm::event::KeyEvent; let mut app = two_col_model(); // Enter command mode, type something, escape app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x")); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); // Re-enter command mode — buffer should be cleared app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("")); } // ── is_empty_model ────────────────────────────────────────────────── #[test] fn fresh_model_is_empty() { let app = App::new(Workbook::new("T"), None); assert!( app.is_empty_model(), "a brand-new model with only virtual categories should be empty" ); } #[test] fn model_with_user_category_is_not_empty() { let mut wb = Workbook::new("T"); wb.add_category("Sales").unwrap(); let app = App::new(wb, None); assert!( !app.is_empty_model(), "a model with a user-defined category should not be empty" ); } // ── Help mode navigation ──────────────────────────────────────────── #[test] fn help_page_next_advances_page() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 1, "l should advance to page 1"); } #[test] fn help_page_prev_goes_back() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 2; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 1, "h should go back to page 1"); } #[test] fn help_page_clamps_at_zero() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.view_state.help_page, 0, "page should not go below 0"); } #[test] fn help_page_clamps_at_max() { use crate::ui::help::HELP_PAGE_COUNT; let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.view_state.help_page = HELP_PAGE_COUNT - 1; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.view_state.help_page, HELP_PAGE_COUNT - 1, "page should not exceed the last page" ); } // ── Help mode exits ───────────────────────────────────────────────── #[test] fn help_q_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::Normal), "q should return to Normal mode" ); } #[test] fn help_esc_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::Normal), "Esc should return to Normal mode" ); } #[test] fn help_colon_enters_command_mode() { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in Help mode should enter CommandMode, got {:?}", app.view_state.mode ); } // ── Effect error feedback ─────────────────────────────────────────── #[test] fn add_item_to_nonexistent_category_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddItem { category: "Nonexistent".to_string(), item: "x".to_string(), }; effect.apply(&mut app); assert!( app.view_state.status_msg.contains("Unknown category"), "should report unknown category, got: {:?}", app.view_state.status_msg ); } #[test] fn add_formula_with_bad_syntax_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddFormula { raw: "!!!invalid".to_string(), target_category: "X".to_string(), }; effect.apply(&mut app); assert!( app.view_state.status_msg.contains("Formula error"), "should report formula error, got: {:?}", app.view_state.status_msg ); } // ── Tile select stays in mode ─────────────────────────────────────── #[test] fn tile_axis_change_stays_in_tile_select() { let mut app = two_col_model(); app.view_state.mode = AppMode::TileSelect; app.view_state.tile_cat_idx = 0; // Press 'r' to set axis to Row — should stay in TileSelect app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::TileSelect), "should stay in TileSelect after axis change, got {:?}", app.view_state.mode ); assert!( !app.view_state.status_msg.is_empty(), "should show status feedback after axis change" ); } // ── Panel colon bindings ──────────────────────────────────────────── #[test] fn category_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::CategoryPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in CategoryPanel should enter CommandMode, got {:?}", app.view_state.mode ); } #[test] fn view_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::ViewPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in ViewPanel should enter CommandMode, got {:?}", app.view_state.mode ); } #[test] fn tile_select_colon_enters_command_mode() { let mut app = two_col_model(); app.view_state.mode = AppMode::TileSelect; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in TileSelect should enter CommandMode, got {:?}", app.view_state.mode ); } }