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 crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; use crate::model::cell::CellValue; use crate::model::Model; use crate::persistence; use crate::view::GridLayout; /// 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. pub records: Vec<( crate::model::cell::CellKey, crate::model::cell::CellValue, )>, /// 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>, } #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, Editing { buffer: String, }, FormulaEdit { buffer: String, }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. CategoryAdd { buffer: String, }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. ItemAdd { category: String, buffer: String, }, ViewPanel, TileSelect, ImportWizard, ExportPrompt { buffer: String, }, /// Vim-style `:` command line CommandMode { buffer: String, }, Help, Quit, } pub struct App { pub model: Model, pub file_path: Option, pub mode: AppMode, pub status_msg: String, pub wizard: Option, pub last_autosave: Instant, 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, pub dirty: bool, /// 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, /// Terminal dimensions (updated on resize and at startup). pub term_width: u16, pub term_height: u16, /// 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>, keymap_set: KeymapSet, } impl App { pub fn new(model: Model, file_path: Option) -> Self { Self { model, file_path, mode: AppMode::Normal, status_msg: String::new(), wizard: None, last_autosave: Instant::now(), 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, dirty: false, yanked: None, tile_cat_idx: 0, view_back_stack: Vec::new(), view_forward_stack: Vec::new(), drill_state: None, term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80), term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24), expanded_cats: std::collections::HashSet::new(), buffers: HashMap::new(), transient_keymap: None, keymap_set: KeymapSet::default_keymaps(), } } pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.model.active_view(); let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone()); let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records); let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model, mode: &self.mode, selected: view.selected, row_offset: view.row_offset, col_offset: view.col_offset, search_query: &self.search_query, yanked: &self.yanked, dirty: self.dirty, search_mode: self.search_mode, formula_panel_open: self.formula_panel_open, category_panel_open: self.category_panel_open, view_panel_open: self.view_panel_open, buffers: &self.buffers, formula_cursor: self.formula_cursor, cat_panel_cursor: self.cat_panel_cursor, view_panel_cursor: self.view_panel_cursor, tile_cat_idx: self.tile_cat_idx, cell_key: layout.cell_key(sel_row, sel_col), row_count: layout.row_count(), col_count: layout.col_count(), none_cats: layout.none_cats.clone(), view_back_stack: self.view_back_stack.clone(), view_forward_stack: self.view_forward_stack.clone(), records_col: if layout.is_records_mode() { Some(layout.col_label(sel_col)) } else { None }, records_value: if layout.is_records_mode() { // Check pending edits first, then fall back to original let col_name = layout.col_label(sel_col); let pending = self.drill_state.as_ref().and_then(|s| { s.pending_edits.get(&(sel_row, col_name.clone())).cloned() }); pending.or_else(|| layout.records_display(sel_row, sel_col)) } else { None }, // Approximate visible rows/cols from terminal size. // Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1) // + tile_bar(1) + status_bar(1) = ~8 rows of chrome. visible_rows: (self.term_height as usize).saturating_sub(8), // Visible cols depends on column widths — use a rough estimate. // The grid renderer does the precise calculation. visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1), expanded_cats: &self.expanded_cats, key_code: key, } } pub fn apply_effects(&mut self, effects: Vec>) { for effect in effects { effect.apply(self); } } /// True when the model has no categories yet (show welcome screen) pub fn is_empty_model(&self) -> bool { self.model.categories.is_empty() } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { // Transient keymap (prefix key sequence) takes priority if let Some(transient) = self.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.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) { if let Some(path) = &self.file_path.clone() { let ap = persistence::autosave_path(path); let _ = persistence::save(&self.model, &ap); self.last_autosave = Instant::now(); } } } pub fn start_import_wizard(&mut self, json: serde_json::Value) { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } /// Hint text for the status bar (context-sensitive) pub fn hint_text(&self) -> &'static str { match &self.mode { AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd", AppMode::Editing { .. } => "Enter:commit Esc:cancel", 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", _ => "", } } } #[cfg(test)] mod tests { use super::*; use crate::model::Model; fn two_col_model() -> App { let mut m = Model::new("T"); m.add_category("Row").unwrap(); // → Row axis m.add_category("Col").unwrap(); // → Column axis m.category_mut("Row").unwrap().add_item("A"); m.category_mut("Row").unwrap().add_item("B"); m.category_mut("Row").unwrap().add_item("C"); m.category_mut("Col").unwrap().add_item("X"); m.category_mut("Col").unwrap().add_item("Y"); App::new(m, 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::EnterAdvance { use crate::command::cmd::CursorState; let view = app.model.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::EnterAdvance { cursor } } #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); app.model.active_view_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.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.active_view_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (0, 1)); } #[test] fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); app.model.active_view_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.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.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.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.mode, AppMode::CommandMode { .. })); assert_eq!(app.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.buffers.get("command").map(|s| s.as_str()), Some("q")); } #[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.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.buffers.get("command").map(|s| s.as_str()), Some("")); } }