From 9ad8abd8a52e35c473408e49f910b0891ce1b20c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 28 Apr 2026 23:19:31 -0700 Subject: [PATCH] refactor(ui): move UI session fields into ViewState (improvise-ew0) Step 3 of vb4. Populates ViewState with the 20 UI session fields (mode, status_msg, wizard, search_query, search_mode, three panel-open flags, three panel cursors, formula_cursor, yanked, tile_cat_idx, two view nav stacks, drill_state, help_page, expanded_cats, buffers, transient_keymap) and routes every read/write site through app.view_state.X. App now contains only model_state, view_state, and the runtime/derived residue (term dims, layout, last_autosave, abort_effects, keymap_set). ViewState gets a manual Default impl mirroring the previous App::new field initialisers; AppMode has no Default of its own so AppMode::Normal is the explicit baseline. Effect::apply still takes &mut App; narrowing remains step 5 (improvise-drg). A structural test (app_view_state_owns_ui_session_fields) locks in the 20-field layout. 624 tests pass workspace-wide (+1 new). cargo clippy --workspace --tests clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/command/keymap.rs | 2 +- src/draw.rs | 69 +++++------ src/ui/app.rs | 261 ++++++++++++++++++++++------------------ src/ui/effect.rs | 270 +++++++++++++++++++++--------------------- 4 files changed, 320 insertions(+), 282 deletions(-) diff --git a/src/command/keymap.rs b/src/command/keymap.rs index 92d5870..59c8385 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -276,7 +276,7 @@ pub struct SetTransientKeymap(pub Arc); impl Effect for SetTransientKeymap { fn apply(&self, app: &mut crate::ui::app::App) { - app.transient_keymap = Some(self.0.clone()); + app.view_state.transient_keymap = Some(self.0.clone()); } } diff --git a/src/draw.rs b/src/draw.rs index 660a3e3..97aaef8 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -61,7 +61,7 @@ pub fn run_tui( if let Some(json) = import_value { app.start_import_wizard(json); } else if app.is_empty_model() { - app.mode = AppMode::Help; + app.view_state.mode = AppMode::Help; } loop { @@ -82,7 +82,7 @@ pub fn run_tui( app.autosave_if_needed(); - if matches!(app.mode, AppMode::Quit) { + if matches!(app.view_state.mode, AppMode::Quit) { break; } } @@ -167,21 +167,21 @@ fn draw(f: &mut Frame, app: &App) { draw_bottom_bar(f, main_chunks[3], app); // Overlays (rendered last so they appear on top) - if matches!(app.mode, AppMode::Help) { - f.render_widget(HelpWidget::new(app.help_page), size); + if matches!(app.view_state.mode, AppMode::Help) { + f.render_widget(HelpWidget::new(app.view_state.help_page), size); } - if matches!(app.mode, AppMode::ImportWizard) - && let Some(wizard) = &app.wizard + if matches!(app.view_state.mode, AppMode::ImportWizard) + && let Some(wizard) = &app.view_state.wizard { f.render_widget(ImportWizardWidget::new(wizard), size); } // ExportPrompt now uses the minibuffer at the bottom bar. - if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) { + if app.is_empty_model() && matches!(app.view_state.mode, AppMode::Normal | AppMode::CommandMode { .. }) { draw_welcome(f, main_chunks[1]); } // Which-key popup: show available completions after a prefix key - if let Some(ref km) = app.transient_keymap { + if let Some(ref km) = app.view_state.transient_keymap { let hints = km.binding_hints(); f.render_widget(WhichKeyWidget::new(&hints), size); } @@ -215,7 +215,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) { } fn draw_content(f: &mut Frame, area: Rect, app: &App) { - let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open; + let side_open = app.view_state.formula_panel_open || app.view_state.category_panel_open || app.view_state.view_panel_open; let grid_area; if side_open { @@ -229,9 +229,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { let side = chunks[1]; let panel_count = [ - app.formula_panel_open, - app.category_panel_open, - app.view_panel_open, + app.view_state.formula_panel_open, + app.view_state.category_panel_open, + app.view_state.view_panel_open, ] .iter() .filter(|&&b| b) @@ -239,26 +239,26 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { let ph = side.height / panel_count.max(1); let mut y = side.y; - if app.formula_panel_open { + if app.view_state.formula_panel_open { let a = Rect::new(side.x, y, side.width, ph); - let content = FormulaContent::new(&app.model_state.workbook.model, &app.mode); - f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a); + let content = FormulaContent::new(&app.model_state.workbook.model, &app.view_state.mode); + f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.formula_cursor), a); y += ph; } - if app.category_panel_open { + if app.view_state.category_panel_open { let a = Rect::new(side.x, y, side.width, ph); let content = CategoryContent::new( &app.model_state.workbook.model, app.model_state.workbook.active_view(), - &app.expanded_cats, + &app.view_state.expanded_cats, ); - f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a); + f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.cat_panel_cursor), a); y += ph; } - if app.view_panel_open { + if app.view_state.view_panel_open { let a = Rect::new(side.x, y, side.width, ph); let content = ViewContent::new(&app.model_state.workbook); - f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a); + f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.view_panel_cursor), a); } } else { grid_area = area; @@ -270,10 +270,10 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { app.model_state.workbook.active_view(), &app.model_state.workbook.active_view, &app.layout, - &app.mode, - &app.search_query, - &app.buffers, - app.drill_state.as_ref(), + &app.view_state.mode, + &app.view_state.search_query, + &app.view_state.buffers, + app.view_state.drill_state.as_ref(), ), grid_area, ); @@ -284,16 +284,17 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { TileBar::new( &app.model_state.workbook.model, app.model_state.workbook.active_view(), - &app.mode, - app.tile_cat_idx, + &app.view_state.mode, + app.view_state.tile_cat_idx, ), area, ); } fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { - if let Some(mb) = app.mode.minibuffer() { + if let Some(mb) = app.view_state.mode.minibuffer() { let buf = app + .view_state .buffers .get(mb.buffer_key) .map(|s| s.as_str()) @@ -314,25 +315,25 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { } fn draw_status(f: &mut Frame, area: Rect, app: &App) { - let search_part = if app.search_mode { - format!(" /{}▌", app.search_query) + let search_part = if app.view_state.search_mode { + format!(" /{}▌", app.view_state.search_query) } else { String::new() }; - let msg = if !app.status_msg.is_empty() { - app.status_msg.as_str() + let msg = if !app.view_state.status_msg.is_empty() { + app.view_state.status_msg.as_str() } else { app.hint_text() }; - let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" }; + let yank_indicator = if app.view_state.yanked.is_some() { " [yank]" } else { "" }; let view_badge = format!(" {}{} ", app.model_state.workbook.active_view, yank_indicator); - let left = format!(" {}{search_part} {msg}", mode_name(&app.mode)); + let left = format!(" {}{search_part} {msg}", mode_name(&app.view_state.mode)); let line = fill_line(left, &view_badge, area.width); - f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area); + f.render_widget(Paragraph::new(line).style(mode_style(&app.view_state.mode)), area); } fn draw_welcome(f: &mut Frame, area: Rect) { diff --git a/src/ui/app.rs b/src/ui/app.rs index c455c2e..8498d01 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -206,17 +206,12 @@ impl Default for ModelState { } /// UI session-state slice: mode, cursors, panels, buffers, navigation stacks, -/// and other per-session state that does not persist to disk. Filled in by -/// improvise-ew0 (vb4 step 3). -#[derive(Debug, Default)] -pub struct ViewState {} - -pub struct App { - pub model_state: ModelState, +/// 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 last_autosave: Instant, pub search_query: String, pub search_mode: bool, pub formula_panel_open: bool, @@ -241,15 +236,48 @@ pub struct App { pub drill_state: Option, /// Current page index in the Help screen (0-based). pub help_page: usize, - /// 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>, +} + +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, + } + } +} + +pub struct App { + pub model_state: ModelState, + pub view_state: ViewState, + pub last_autosave: Instant, + /// Terminal dimensions (updated on resize and at startup). + pub term_width: u16, + pub term_height: u16, /// Current grid layout, derived from model + view + drill_state. /// Rebuilt via `rebuild_layout()` after state changes. pub layout: GridLayout, @@ -280,29 +308,10 @@ impl App { file_path, dirty: false, }, - mode: AppMode::Normal, - status_msg: String::new(), - wizard: None, + view_state: ViewState::default(), 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, - yanked: None, - tile_cat_idx: 0, - view_back_stack: Vec::new(), - view_forward_stack: Vec::new(), - drill_state: None, - help_page: 0, 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, layout, abort_effects: false, keymap_set: KeymapSet::default_keymaps(), @@ -315,7 +324,7 @@ impl App { 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.drill_state.as_ref().map(|s| Rc::clone(&s.records)); + 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); } @@ -329,30 +338,30 @@ impl App { view, layout, registry: self.keymap_set.registry(), - mode: &self.mode, + mode: &self.view_state.mode, selected: view.selected, row_offset: view.row_offset, col_offset: view.col_offset, - search_query: &self.search_query, - yanked: &self.yanked, + search_query: &self.view_state.search_query, + yanked: &self.view_state.yanked, dirty: self.model_state.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, - view_back_stack: &self.view_back_stack, - view_forward_stack: &self.view_forward_stack, - has_drill_state: self.drill_state.is_some(), + 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.drill_state + self.view_state.drill_state .as_ref() .and_then(|s| s.pending_edits.get(&(idx, dim)).cloned()) .or_else(|| layout.resolve_display(k)) @@ -381,7 +390,7 @@ impl App { view.col_offset, ) }, - expanded_cats: &self.expanded_cats, + expanded_cats: &self.view_state.expanded_cats, key_code: key, } } @@ -419,7 +428,7 @@ impl App { self.rebuild_layout(); // Transient keymap (prefix key sequence) takes priority - if let Some(transient) = self.transient_keymap.take() { + if let Some(transient) = self.view_state.transient_keymap.take() { let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set @@ -455,13 +464,13 @@ impl App { } pub fn start_import_wizard(&mut self, json: serde_json::Value) { - self.wizard = Some(ImportWizard::new(json)); - self.mode = AppMode::ImportWizard; + 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.mode { + match &self.view_state.mode { AppMode::Normal => { "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd" } @@ -517,6 +526,34 @@ mod tests { 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 @@ -588,7 +625,7 @@ mod tests { let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]); app.start_import_wizard(json); assert!( - matches!(app.mode, AppMode::ImportWizard), + matches!(app.view_state.mode, AppMode::ImportWizard), "mode should be ImportWizard after start_import_wizard" ); } @@ -600,7 +637,7 @@ mod tests { 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), + !matches!(app.view_state.mode, AppMode::Normal), "mode must not be Normal after import wizard is opened" ); } @@ -612,13 +649,13 @@ mod tests { // 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("")); + 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.buffers.get("command").map(|s| s.as_str()), Some("q")); + assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q")); } #[test] @@ -789,7 +826,7 @@ mod tests { // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); - assert!(matches!(app.mode, AppMode::Editing { .. })); + assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); // Type a digit app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); @@ -798,9 +835,9 @@ mod tests { .unwrap(); // Should be in edit mode on column 1 assert!( - matches!(app.mode, AppMode::Editing { .. }), + matches!(app.view_state.mode, AppMode::Editing { .. }), "should be in edit mode after Tab, but mode is {:?}", - app.mode + app.view_state.mode ); assert_eq!( app.model_state.workbook.active_view().selected.1, @@ -869,9 +906,9 @@ mod tests { "o should create the first record row in an empty records view" ); assert!( - app.mode.is_editing(), + app.view_state.mode.is_editing(), "o should leave the app in edit mode, got {:?}", - app.mode + app.view_state.mode ); } @@ -1021,19 +1058,19 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); - assert!(app.mode.is_editing(), "setup: should be editing"); + assert!(app.view_state.mode.is_editing(), "setup: should be editing"); app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .unwrap(); assert!( - !app.mode.is_editing(), + !app.view_state.mode.is_editing(), "Enter at bottom-right should exit editing, got {:?}", - app.mode + app.view_state.mode ); assert!( - matches!(app.mode, AppMode::RecordsNormal), + matches!(app.view_state.mode, AppMode::RecordsNormal), "should return to RecordsNormal, got {:?}", - app.mode + app.view_state.mode ); } @@ -1053,7 +1090,7 @@ mod tests { // Enter edit mode on the bottom-right cell app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); - assert!(app.mode.is_editing(), "setup: should be editing"); + 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)) @@ -1070,9 +1107,9 @@ mod tests { "TAB should move to first cell of the new row" ); assert!( - app.mode.is_editing(), + app.view_state.mode.is_editing(), "should enter edit mode on the new cell, got {:?}", - app.mode + app.view_state.mode ); } @@ -1097,7 +1134,7 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE)) .unwrap(); - assert!(app.drill_state.is_some(), "drill should create drill state"); + 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"); @@ -1117,7 +1154,7 @@ mod tests { "drill edit should remain staged until leaving the drill view" ); assert_eq!( - app.drill_state + app.view_state.drill_state .as_ref() .and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))), Some(&"9".to_string()), @@ -1182,14 +1219,14 @@ mod tests { .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")); + 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.buffers.get("command").map(|s| s.as_str()), Some("")); + assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("")); } // ── is_empty_model ────────────────────────────────────────────────── @@ -1219,34 +1256,34 @@ mod tests { #[test] fn help_page_next_advances_page() { let mut app = App::new(Workbook::new("T"), None); - app.mode = AppMode::Help; - app.help_page = 0; + 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.help_page, 1, "l should advance to page 1"); + 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.mode = AppMode::Help; - app.help_page = 2; + 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.help_page, 1, "h should go back to page 1"); + 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.mode = AppMode::Help; - app.help_page = 0; + 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.help_page, 0, "page should not go below 0"); + assert_eq!(app.view_state.help_page, 0, "page should not go below 0"); } #[test] @@ -1254,13 +1291,13 @@ mod tests { use crate::ui::help::HELP_PAGE_COUNT; let mut app = App::new(Workbook::new("T"), None); - app.mode = AppMode::Help; - app.help_page = HELP_PAGE_COUNT - 1; + 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.help_page, + app.view_state.help_page, HELP_PAGE_COUNT - 1, "page should not exceed the last page" ); @@ -1271,12 +1308,12 @@ mod tests { #[test] fn help_q_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); - app.mode = AppMode::Help; + app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::Normal), + matches!(app.view_state.mode, AppMode::Normal), "q should return to Normal mode" ); } @@ -1284,12 +1321,12 @@ mod tests { #[test] fn help_esc_returns_to_normal() { let mut app = App::new(Workbook::new("T"), None); - app.mode = AppMode::Help; + app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::Normal), + matches!(app.view_state.mode, AppMode::Normal), "Esc should return to Normal mode" ); } @@ -1297,14 +1334,14 @@ mod tests { #[test] fn help_colon_enters_command_mode() { let mut app = App::new(Workbook::new("T"), None); - app.mode = AppMode::Help; + app.view_state.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::CommandMode { .. }), + matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in Help mode should enter CommandMode, got {:?}", - app.mode + app.view_state.mode ); } @@ -1320,9 +1357,9 @@ mod tests { }; effect.apply(&mut app); assert!( - app.status_msg.contains("Unknown category"), + app.view_state.status_msg.contains("Unknown category"), "should report unknown category, got: {:?}", - app.status_msg + app.view_state.status_msg ); } @@ -1336,9 +1373,9 @@ mod tests { }; effect.apply(&mut app); assert!( - app.status_msg.contains("Formula error"), + app.view_state.status_msg.contains("Formula error"), "should report formula error, got: {:?}", - app.status_msg + app.view_state.status_msg ); } @@ -1347,19 +1384,19 @@ mod tests { #[test] fn tile_axis_change_stays_in_tile_select() { let mut app = two_col_model(); - app.mode = AppMode::TileSelect; - app.tile_cat_idx = 0; + 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.mode, AppMode::TileSelect), + matches!(app.view_state.mode, AppMode::TileSelect), "should stay in TileSelect after axis change, got {:?}", - app.mode + app.view_state.mode ); assert!( - !app.status_msg.is_empty(), + !app.view_state.status_msg.is_empty(), "should show status feedback after axis change" ); } @@ -1369,42 +1406,42 @@ mod tests { #[test] fn category_panel_colon_enters_command_mode() { let mut app = two_col_model(); - app.mode = AppMode::CategoryPanel; + app.view_state.mode = AppMode::CategoryPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::CommandMode { .. }), + matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in CategoryPanel should enter CommandMode, got {:?}", - app.mode + app.view_state.mode ); } #[test] fn view_panel_colon_enters_command_mode() { let mut app = two_col_model(); - app.mode = AppMode::ViewPanel; + app.view_state.mode = AppMode::ViewPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::CommandMode { .. }), + matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in ViewPanel should enter CommandMode, got {:?}", - app.mode + app.view_state.mode ); } #[test] fn tile_select_colon_enters_command_mode() { let mut app = two_col_model(); - app.mode = AppMode::TileSelect; + app.view_state.mode = AppMode::TileSelect; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( - matches!(app.mode, AppMode::CommandMode { .. }), + matches!(app.view_state.mode, AppMode::CommandMode { .. }), "colon in TileSelect should enter CommandMode, got {:?}", - app.mode + app.view_state.mode ); } } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 208209a..8873311 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -38,7 +38,7 @@ impl Effect for AddItem { if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) { cat.add_item(&self.item); } else { - app.status_msg = format!("Unknown category '{}'", self.category); + app.view_state.status_msg = format!("Unknown category '{}'", self.category); } } } @@ -54,7 +54,7 @@ impl Effect for AddItemInGroup { if let Some(cat) = app.model_state.workbook.model.category_mut(&self.category) { cat.add_item_in_group(&self.item, &self.group); } else { - app.status_msg = format!("Unknown category '{}'", self.category); + app.view_state.status_msg = format!("Unknown category '{}'", self.category); } } } @@ -103,7 +103,7 @@ impl Effect for AddFormula { app.model_state.workbook.model.add_formula(formula); } Err(e) => { - app.status_msg = format!("Formula error: {e}"); + app.view_state.status_msg = format!("Formula error: {e}"); } } } @@ -127,7 +127,7 @@ impl Effect for RemoveFormula { /// /// `target_mode` is supplied by the caller (keymap binding via /// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode` -/// field). The effect itself never inspects `app.mode` — the mode is decided +/// field). The effect itself never inspects `app.view_state.mode` — the mode is decided /// statically by whoever invoked us. #[derive(Debug)] pub struct EnterEditAtCursor { @@ -144,8 +144,8 @@ impl Effect for EnterEditAtCursor { ); let value = ctx.display_value.clone(); drop(ctx); - app.buffers.insert("edit".to_string(), value); - app.mode = self.target_mode.clone(); + app.view_state.buffers.insert("edit".to_string(), value); + app.view_state.mode = self.target_mode.clone(); } } @@ -162,8 +162,8 @@ impl Effect for TogglePruneEmpty { pub struct ToggleCatExpand(pub String); impl Effect for ToggleCatExpand { fn apply(&self, app: &mut App) { - if !app.expanded_cats.remove(&self.0) { - app.expanded_cats.insert(self.0.clone()); + if !app.view_state.expanded_cats.remove(&self.0) { + app.view_state.expanded_cats.insert(self.0.clone()); } } } @@ -211,11 +211,11 @@ impl Effect for SwitchView { fn apply(&self, app: &mut App) { let current = app.model_state.workbook.active_view.clone(); if current != self.0 { - app.view_back_stack.push(ViewFrame { + app.view_state.view_back_stack.push(ViewFrame { view_name: current, - mode: app.mode.clone(), + mode: app.view_state.mode.clone(), }); - app.view_forward_stack.clear(); + app.view_state.view_forward_stack.clear(); } let _ = app.model_state.workbook.switch_view(&self.0); } @@ -226,14 +226,14 @@ impl Effect for SwitchView { pub struct ViewBack; impl Effect for ViewBack { fn apply(&self, app: &mut App) { - if let Some(frame) = app.view_back_stack.pop() { + if let Some(frame) = app.view_state.view_back_stack.pop() { let current = app.model_state.workbook.active_view.clone(); - app.view_forward_stack.push(ViewFrame { + app.view_state.view_forward_stack.push(ViewFrame { view_name: current, - mode: app.mode.clone(), + mode: app.view_state.mode.clone(), }); let _ = app.model_state.workbook.switch_view(&frame.view_name); - app.mode = frame.mode; + app.view_state.mode = frame.mode; } } } @@ -243,14 +243,14 @@ impl Effect for ViewBack { pub struct ViewForward; impl Effect for ViewForward { fn apply(&self, app: &mut App) { - if let Some(frame) = app.view_forward_stack.pop() { + if let Some(frame) = app.view_state.view_forward_stack.pop() { let current = app.model_state.workbook.active_view.clone(); - app.view_back_stack.push(ViewFrame { + app.view_state.view_back_stack.push(ViewFrame { view_name: current, - mode: app.mode.clone(), + mode: app.view_state.mode.clone(), }); let _ = app.model_state.workbook.switch_view(&frame.view_name); - app.mode = frame.mode; + app.view_state.mode = frame.mode; } } } @@ -376,7 +376,7 @@ impl Effect for SetColOffset { pub struct ChangeMode(pub AppMode); impl Effect for ChangeMode { fn apply(&self, app: &mut App) { - app.mode = self.0.clone(); + app.view_state.mode = self.0.clone(); } fn changes_mode(&self) -> bool { true @@ -387,7 +387,7 @@ impl Effect for ChangeMode { pub struct SetStatus(pub String); impl Effect for SetStatus { fn apply(&self, app: &mut App) { - app.status_msg = self.0.clone(); + app.view_state.status_msg = self.0.clone(); } } @@ -403,7 +403,7 @@ impl Effect for MarkDirty { pub struct SetYanked(pub Option); impl Effect for SetYanked { fn apply(&self, app: &mut App) { - app.yanked = self.0.clone(); + app.view_state.yanked = self.0.clone(); } } @@ -411,7 +411,7 @@ impl Effect for SetYanked { pub struct SetSearchQuery(pub String); impl Effect for SetSearchQuery { fn apply(&self, app: &mut App) { - app.search_query = self.0.clone(); + app.view_state.search_query = self.0.clone(); } } @@ -419,7 +419,7 @@ impl Effect for SetSearchQuery { pub struct SetSearchMode(pub bool); impl Effect for SetSearchMode { fn apply(&self, app: &mut App) { - app.search_mode = self.0; + app.view_state.search_mode = self.0; } } @@ -433,9 +433,9 @@ impl Effect for SetBuffer { fn apply(&self, app: &mut App) { // "search" is special — it writes to search_query for backward compat if self.name == "search" { - app.search_query = self.value.clone(); + app.view_state.search_query = self.value.clone(); } else { - app.buffers.insert(self.name.clone(), self.value.clone()); + app.view_state.buffers.insert(self.name.clone(), self.value.clone()); } } } @@ -444,7 +444,7 @@ impl Effect for SetBuffer { pub struct SetTileCatIdx(pub usize); impl Effect for SetTileCatIdx { fn apply(&self, app: &mut App) { - app.tile_cat_idx = self.0; + app.view_state.tile_cat_idx = self.0; } } @@ -454,7 +454,7 @@ impl Effect for SetTileCatIdx { pub struct StartDrill(pub Vec<(CellKey, CellValue)>); impl Effect for StartDrill { fn apply(&self, app: &mut App) { - app.drill_state = Some(super::app::DrillState { + app.view_state.drill_state = Some(super::app::DrillState { records: std::rc::Rc::new(self.0.clone()), pending_edits: std::collections::HashMap::new(), }); @@ -466,7 +466,7 @@ impl Effect for StartDrill { pub struct ApplyAndClearDrill; impl Effect for ApplyAndClearDrill { fn apply(&self, app: &mut App) { - let Some(drill) = app.drill_state.take() else { + let Some(drill) = app.view_state.drill_state.take() else { return; }; if drill.pending_edits.is_empty() { @@ -490,7 +490,7 @@ impl Effect for ApplyAndClearDrill { app.model_state.workbook.model.set_cell(orig_key.clone(), value); } else { if new_value.is_empty() { - app.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string(); + app.view_state.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string(); continue; } // Rename a coordinate: remove old cell, insert new with updated coord @@ -532,7 +532,7 @@ pub struct SetDrillPendingEdit { } impl Effect for SetDrillPendingEdit { fn apply(&self, app: &mut App) { - if let Some(drill) = &mut app.drill_state { + if let Some(drill) = &mut app.view_state.drill_state { drill.pending_edits.insert( (self.record_idx, self.col_name.clone()), self.new_value.clone(), @@ -551,14 +551,14 @@ impl Effect for Save { match crate::persistence::save(&app.model_state.workbook, path) { Ok(()) => { app.model_state.dirty = false; - app.status_msg = format!("Saved to {}", path.display()); + app.view_state.status_msg = format!("Saved to {}", path.display()); } Err(e) => { - app.status_msg = format!("Save error: {e}"); + app.view_state.status_msg = format!("Save error: {e}"); } } } else { - app.status_msg = "No file path — use :w ".to_string(); + app.view_state.status_msg = "No file path — use :w ".to_string(); } } } @@ -571,10 +571,10 @@ impl Effect for SaveAs { Ok(()) => { app.model_state.file_path = Some(self.0.clone()); app.model_state.dirty = false; - app.status_msg = format!("Saved to {}", self.0.display()); + app.view_state.status_msg = format!("Saved to {}", self.0.display()); } Err(e) => { - app.status_msg = format!("Save error: {e}"); + app.view_state.status_msg = format!("Save error: {e}"); } } } @@ -591,7 +591,7 @@ impl Effect for WizardKey { fn apply(&self, app: &mut App) { use crate::import::wizard::WizardStep; - let Some(wizard) = &mut app.wizard else { + let Some(wizard) = &mut app.view_state.wizard else { return; }; @@ -601,8 +601,8 @@ impl Effect for WizardKey { wizard.advance() } crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} }, @@ -615,8 +615,8 @@ impl Effect for WizardKey { } crossterm::event::KeyCode::Enter => wizard.confirm_path(), crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} }, @@ -631,8 +631,8 @@ impl Effect for WizardKey { crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(), crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} }, @@ -646,8 +646,8 @@ impl Effect for WizardKey { crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(), crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} }, @@ -672,8 +672,8 @@ impl Effect for WizardKey { } crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} } @@ -686,27 +686,27 @@ impl Effect for WizardKey { Ok(mut workbook) => { workbook.normalize_view_state(); app.model_state.workbook = workbook; - app.formula_cursor = 0; + app.view_state.formula_cursor = 0; app.model_state.dirty = true; - app.status_msg = "Import successful! Press :w to save.".to_string(); - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.status_msg = "Import successful! Press :w to save.".to_string(); + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } Err(e) => { - if let Some(w) = &mut app.wizard { + if let Some(w) = &mut app.view_state.wizard { w.message = Some(format!("Error: {e}")); } } }, crossterm::event::KeyCode::Esc => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } _ => {} }, WizardStep::Done => { - app.mode = AppMode::Normal; - app.wizard = None; + app.view_state.mode = AppMode::Normal; + app.view_state.wizard = None; } } } @@ -720,15 +720,15 @@ impl Effect for StartImportWizard { match std::fs::read_to_string(&self.0) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => { - app.wizard = Some(crate::import::wizard::ImportWizard::new(json)); - app.mode = AppMode::ImportWizard; + app.view_state.wizard = Some(crate::import::wizard::ImportWizard::new(json)); + app.view_state.mode = AppMode::ImportWizard; } Err(e) => { - app.status_msg = format!("JSON parse error: {e}"); + app.view_state.status_msg = format!("JSON parse error: {e}"); } }, Err(e) => { - app.status_msg = format!("Cannot read file: {e}"); + app.view_state.status_msg = format!("Cannot read file: {e}"); } } } @@ -741,10 +741,10 @@ impl Effect for ExportCsv { let view_name = app.model_state.workbook.active_view.clone(); match crate::persistence::export_csv(&app.model_state.workbook, &view_name, &self.0) { Ok(()) => { - app.status_msg = format!("Exported to {}", self.0.display()); + app.view_state.status_msg = format!("Exported to {}", self.0.display()); } Err(e) => { - app.status_msg = format!("Export error: {e}"); + app.view_state.status_msg = format!("Export error: {e}"); } } } @@ -759,10 +759,10 @@ impl Effect for LoadModel { Ok(mut loaded) => { loaded.normalize_view_state(); app.model_state.workbook = loaded; - app.status_msg = format!("Loaded from {}", self.0.display()); + app.view_state.status_msg = format!("Loaded from {}", self.0.display()); } Err(e) => { - app.status_msg = format!("Load error: {e}"); + app.view_state.status_msg = format!("Load error: {e}"); } } } @@ -791,7 +791,7 @@ impl Effect for ImportJsonHeadless { match crate::import::csv_parser::parse_csv(&self.path) { Ok(recs) => recs, Err(e) => { - app.status_msg = format!("CSV error: {e}"); + app.view_state.status_msg = format!("CSV error: {e}"); return; } } @@ -799,14 +799,14 @@ impl Effect for ImportJsonHeadless { let content = match std::fs::read_to_string(&self.path) { Ok(c) => c, Err(e) => { - app.status_msg = format!("Cannot read '{}': {e}", self.path.display()); + app.view_state.status_msg = format!("Cannot read '{}': {e}", self.path.display()); return; } }; let value: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { - app.status_msg = format!("JSON parse error: {e}"); + app.view_state.status_msg = format!("JSON parse error: {e}"); return; } }; @@ -815,7 +815,7 @@ impl Effect for ImportJsonHeadless { match extract_array_at_path(&value, ap) { Some(arr) => arr.clone(), None => { - app.status_msg = format!("No array at path '{ap}'"); + app.view_state.status_msg = format!("No array at path '{ap}'"); return; } } @@ -827,12 +827,12 @@ impl Effect for ImportJsonHeadless { match extract_array_at_path(&value, first) { Some(arr) => arr.clone(), None => { - app.status_msg = "Could not extract records array".to_string(); + app.view_state.status_msg = "Could not extract records array".to_string(); return; } } } else { - app.status_msg = "No array found in JSON".to_string(); + app.view_state.status_msg = "No array found in JSON".to_string(); return; } } @@ -870,10 +870,10 @@ impl Effect for ImportJsonHeadless { match pipeline.build_model() { Ok(new_workbook) => { app.model_state.workbook = new_workbook; - app.status_msg = "Imported successfully".to_string(); + app.view_state.status_msg = "Imported successfully".to_string(); } Err(e) => { - app.status_msg = format!("Import error: {e}"); + app.view_state.status_msg = format!("Import error: {e}"); } } } @@ -905,9 +905,9 @@ impl Panel { impl Effect for SetPanelOpen { fn apply(&self, app: &mut App) { match self.panel { - Panel::Formula => app.formula_panel_open = self.open, - Panel::Category => app.category_panel_open = self.open, - Panel::View => app.view_panel_open = self.open, + Panel::Formula => app.view_state.formula_panel_open = self.open, + Panel::Category => app.view_state.category_panel_open = self.open, + Panel::View => app.view_state.view_panel_open = self.open, } } } @@ -920,9 +920,9 @@ pub struct SetPanelCursor { impl Effect for SetPanelCursor { fn apply(&self, app: &mut App) { match self.panel { - Panel::Formula => app.formula_cursor = self.cursor, - Panel::Category => app.cat_panel_cursor = self.cursor, - Panel::View => app.view_panel_cursor = self.cursor, + Panel::Formula => app.view_state.formula_cursor = self.cursor, + Panel::Category => app.view_state.cat_panel_cursor = self.cursor, + Panel::View => app.view_state.view_panel_cursor = self.cursor, } } } @@ -992,7 +992,7 @@ pub struct HelpPageNext; impl Effect for HelpPageNext { fn apply(&self, app: &mut App) { let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1); - app.help_page = app.help_page.saturating_add(1).min(max); + app.view_state.help_page = app.view_state.help_page.saturating_add(1).min(max); } } @@ -1000,7 +1000,7 @@ impl Effect for HelpPageNext { pub struct HelpPagePrev; impl Effect for HelpPagePrev { fn apply(&self, app: &mut App) { - app.help_page = app.help_page.saturating_sub(1); + app.view_state.help_page = app.view_state.help_page.saturating_sub(1); } } @@ -1008,7 +1008,7 @@ impl Effect for HelpPagePrev { pub struct HelpPageSet(pub usize); impl Effect for HelpPageSet { fn apply(&self, app: &mut App) { - app.help_page = self.0; + app.view_state.help_page = self.0; } } @@ -1079,7 +1079,7 @@ mod tests { item: "X".to_string(), } .apply(&mut app); - assert!(app.status_msg.contains("Unknown category")); + assert!(app.view_state.status_msg.contains("Unknown category")); } #[test] @@ -1184,7 +1184,7 @@ mod tests { target_category: "Type".to_string(), } .apply(&mut app); - assert!(app.status_msg.contains("Formula error")); + assert!(app.view_state.status_msg.contains("Formula error")); } #[test] @@ -1210,21 +1210,21 @@ mod tests { fn switch_view_pushes_to_back_stack() { let mut app = test_app(); app.model_state.workbook.create_view("View 2"); - assert!(app.view_back_stack.is_empty()); + assert!(app.view_state.view_back_stack.is_empty()); SwitchView("View 2".to_string()).apply(&mut app); assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2"); - assert_eq!(app.view_back_stack.len(), 1); - assert_eq!(app.view_back_stack[0].view_name, "Default"); + assert_eq!(app.view_state.view_back_stack.len(), 1); + assert_eq!(app.view_state.view_back_stack[0].view_name, "Default"); // Forward stack should be cleared - assert!(app.view_forward_stack.is_empty()); + assert!(app.view_state.view_forward_stack.is_empty()); } #[test] fn switch_view_to_same_does_not_push_stack() { let mut app = test_app(); SwitchView("Default".to_string()).apply(&mut app); - assert!(app.view_back_stack.is_empty()); + assert!(app.view_state.view_back_stack.is_empty()); } #[test] @@ -1237,16 +1237,16 @@ mod tests { // Go back ViewBack.apply(&mut app); assert_eq!(app.model_state.workbook.active_view.as_str(), "Default"); - assert_eq!(app.view_forward_stack.len(), 1); - assert_eq!(app.view_forward_stack[0].view_name, "View 2"); - assert!(app.view_back_stack.is_empty()); + assert_eq!(app.view_state.view_forward_stack.len(), 1); + assert_eq!(app.view_state.view_forward_stack[0].view_name, "View 2"); + assert!(app.view_state.view_back_stack.is_empty()); // Go forward ViewForward.apply(&mut app); assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2"); - assert_eq!(app.view_back_stack.len(), 1); - assert_eq!(app.view_back_stack[0].view_name, "Default"); - assert!(app.view_forward_stack.is_empty()); + assert_eq!(app.view_state.view_back_stack.len(), 1); + assert_eq!(app.view_state.view_back_stack[0].view_name, "Default"); + assert!(app.view_state.view_forward_stack.is_empty()); } #[test] @@ -1347,7 +1347,7 @@ mod tests { let mut app = test_app(); assert!(ChangeMode(AppMode::Help).changes_mode()); ChangeMode(AppMode::Help).apply(&mut app); - assert_eq!(app.mode, AppMode::Help); + assert_eq!(app.view_state.mode, AppMode::Help); } /// `AbortChain` must cause subsequent effects in the same @@ -1356,7 +1356,7 @@ mod tests { #[test] fn abort_chain_short_circuits_apply_effects() { let mut app = test_app(); - app.status_msg = String::new(); + app.view_state.status_msg = String::new(); let effects: Vec> = vec![ Box::new(SetStatus("before".into())), Box::new(AbortChain), @@ -1364,7 +1364,7 @@ mod tests { ]; app.apply_effects(effects); assert_eq!( - app.status_msg, "before", + app.view_state.status_msg, "before", "effects after AbortChain must not apply" ); assert!( @@ -1373,7 +1373,7 @@ mod tests { ); // A subsequent batch must not be affected by the prior abort. app.apply_effects(vec![Box::new(SetStatus("next-batch".into()))]); - assert_eq!(app.status_msg, "next-batch"); + assert_eq!(app.view_state.status_msg, "next-batch"); } /// `CleanEmptyRecords` removes cells whose `CellKey` has no @@ -1414,37 +1414,37 @@ mod tests { } /// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever - /// `app.mode` happens to be when applied. Previous implementation - /// branched on `app.mode.is_records()` — the parameterized version + /// `app.view_state.mode` happens to be when applied. Previous implementation + /// branched on `app.view_state.mode.is_records()` — the parameterized version /// trusts the caller (keymap or composing command). #[test] fn enter_edit_at_cursor_uses_target_mode_not_app_mode() { let mut app = test_app(); // App starts in Normal mode — but caller has decided we want // RecordsEditing (e.g. records-mode `o` sequence). - assert_eq!(app.mode, AppMode::Normal); + assert_eq!(app.view_state.mode, AppMode::Normal); EnterEditAtCursor { target_mode: AppMode::records_editing(), } .apply(&mut app); assert!( - matches!(app.mode, AppMode::RecordsEditing { .. }), + matches!(app.view_state.mode, AppMode::RecordsEditing { .. }), "Expected RecordsEditing, got {:?}", - app.mode + app.view_state.mode ); // Same effect with editing target — should land in plain Editing - // even if app.mode was something else. + // even if app.view_state.mode was something else. let mut app2 = test_app(); - app2.mode = AppMode::RecordsNormal; + app2.view_state.mode = AppMode::RecordsNormal; EnterEditAtCursor { target_mode: AppMode::editing(), } .apply(&mut app2); assert!( - matches!(app2.mode, AppMode::Editing { .. }), + matches!(app2.view_state.mode, AppMode::Editing { .. }), "Expected Editing, got {:?}", - app2.mode + app2.view_state.mode ); } @@ -1453,21 +1453,21 @@ mod tests { #[test] fn set_buffer_empty_clears() { let mut app = test_app(); - app.buffers + app.view_state.buffers .insert("formula".to_string(), "old text".to_string()); SetBuffer { name: "formula".to_string(), value: String::new(), } .apply(&mut app); - assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),); + assert_eq!(app.view_state.buffers.get("formula").map(|s| s.as_str()), Some(""),); } #[test] fn set_status_effect() { let mut app = test_app(); SetStatus("hello".to_string()).apply(&mut app); - assert_eq!(app.status_msg, "hello"); + assert_eq!(app.view_state.status_msg, "hello"); } #[test] @@ -1482,18 +1482,18 @@ mod tests { fn set_yanked_effect() { let mut app = test_app(); SetYanked(Some(CellValue::Number(42.0))).apply(&mut app); - assert_eq!(app.yanked, Some(CellValue::Number(42.0))); + assert_eq!(app.view_state.yanked, Some(CellValue::Number(42.0))); } #[test] fn set_search_query_and_mode() { let mut app = test_app(); SetSearchQuery("foo".to_string()).apply(&mut app); - assert_eq!(app.search_query, "foo"); + assert_eq!(app.view_state.search_query, "foo"); SetSearchMode(true).apply(&mut app); - assert!(app.search_mode); + assert!(app.view_state.search_mode); SetSearchMode(false).apply(&mut app); - assert!(!app.search_mode); + assert!(!app.view_state.search_mode); } // ── SetBuffer special behavior ────────────────────────────────────── @@ -1506,7 +1506,7 @@ mod tests { value: "hello".to_string(), } .apply(&mut app); - assert_eq!(app.buffers.get("edit").unwrap(), "hello"); + assert_eq!(app.view_state.buffers.get("edit").unwrap(), "hello"); } #[test] @@ -1517,8 +1517,8 @@ mod tests { value: "query".to_string(), } .apply(&mut app); - // "search" buffer is special — writes to app.search_query - assert_eq!(app.search_query, "query"); + // "search" buffer is special — writes to app.view_state.search_query + assert_eq!(app.view_state.search_query, "query"); } // ── Panel effects ─────────────────────────────────────────────────── @@ -1531,35 +1531,35 @@ mod tests { open: true, } .apply(&mut app); - assert!(app.formula_panel_open); + assert!(app.view_state.formula_panel_open); SetPanelCursor { panel: Panel::Formula, cursor: 3, } .apply(&mut app); - assert_eq!(app.formula_cursor, 3); + assert_eq!(app.view_state.formula_cursor, 3); SetPanelOpen { panel: Panel::Category, open: true, } .apply(&mut app); - assert!(app.category_panel_open); + assert!(app.view_state.category_panel_open); SetPanelOpen { panel: Panel::View, open: true, } .apply(&mut app); - assert!(app.view_panel_open); + assert!(app.view_state.view_panel_open); } #[test] fn set_tile_cat_idx_effect() { let mut app = test_app(); SetTileCatIdx(2).apply(&mut app); - assert_eq!(app.tile_cat_idx, 2); + assert_eq!(app.view_state.tile_cat_idx, 2); } // ── Help page effects ─────────────────────────────────────────────── @@ -1567,22 +1567,22 @@ mod tests { #[test] fn help_page_navigation() { let mut app = test_app(); - assert_eq!(app.help_page, 0); + assert_eq!(app.view_state.help_page, 0); HelpPageNext.apply(&mut app); - assert_eq!(app.help_page, 1); + assert_eq!(app.view_state.help_page, 1); HelpPageNext.apply(&mut app); - assert_eq!(app.help_page, 2); + assert_eq!(app.view_state.help_page, 2); HelpPagePrev.apply(&mut app); - assert_eq!(app.help_page, 1); + assert_eq!(app.view_state.help_page, 1); HelpPageSet(0).apply(&mut app); - assert_eq!(app.help_page, 0); + assert_eq!(app.view_state.help_page, 0); } #[test] fn help_page_prev_clamps_at_zero() { let mut app = test_app(); HelpPagePrev.apply(&mut app); - assert_eq!(app.help_page, 0); + assert_eq!(app.view_state.help_page, 0); } // ── Drill effects ─────────────────────────────────────────────────── @@ -1596,11 +1596,11 @@ mod tests { ]); let records = vec![(key, CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); - assert!(app.drill_state.is_some()); + assert!(app.view_state.drill_state.is_some()); // Apply with no pending edits — should just clear state ApplyAndClearDrill.apply(&mut app); - assert!(app.drill_state.is_none()); + assert!(app.view_state.drill_state.is_none()); assert!(!app.model_state.dirty); // no edits → not dirty } @@ -1628,7 +1628,7 @@ mod tests { .apply(&mut app); ApplyAndClearDrill.apply(&mut app); - assert!(app.drill_state.is_none()); + assert!(app.view_state.drill_state.is_none()); assert!(app.model_state.dirty); assert_eq!( app.model_state.workbook.model.get_cell(&key), @@ -1726,11 +1726,11 @@ mod tests { #[test] fn toggle_cat_expand_effect() { let mut app = test_app(); - assert!(!app.expanded_cats.contains("Type")); + assert!(!app.view_state.expanded_cats.contains("Type")); ToggleCatExpand("Type".to_string()).apply(&mut app); - assert!(app.expanded_cats.contains("Type")); + assert!(app.view_state.expanded_cats.contains("Type")); ToggleCatExpand("Type".to_string()).apply(&mut app); - assert!(!app.expanded_cats.contains("Type")); + assert!(!app.view_state.expanded_cats.contains("Type")); } #[test] @@ -1846,7 +1846,7 @@ mod tests { fn save_without_file_path_shows_status() { let mut app = test_app(); Save.apply(&mut app); - assert!(app.status_msg.contains("No file path")); + assert!(app.view_state.status_msg.contains("No file path")); } // ── Panel mode helper ───────────────────────────────────────────────