diff --git a/src/draw.rs b/src/draw.rs index 326ce25..660a3e3 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -188,8 +188,9 @@ fn draw(f: &mut Frame, app: &App) { } fn draw_title(f: &mut Frame, area: Rect, app: &App) { - let dirty = if app.dirty { " [+]" } else { "" }; + let dirty = if app.model_state.dirty { " [+]" } else { "" }; let file = app + .model_state .file_path .as_ref() .and_then(|p| p.file_name()) @@ -198,7 +199,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) { .unwrap_or_default(); let title = format!( " improvise · {}{}{} ", - app.workbook.model.name, file, dirty + app.model_state.workbook.model.name, file, dirty ); let right = " ?:help :q quit "; let line = fill_line(title, right, area.width); @@ -240,15 +241,15 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { if app.formula_panel_open { let a = Rect::new(side.x, y, side.width, ph); - let content = FormulaContent::new(&app.workbook.model, &app.mode); + let content = FormulaContent::new(&app.model_state.workbook.model, &app.mode); f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a); y += ph; } if app.category_panel_open { let a = Rect::new(side.x, y, side.width, ph); let content = CategoryContent::new( - &app.workbook.model, - app.workbook.active_view(), + &app.model_state.workbook.model, + app.model_state.workbook.active_view(), &app.expanded_cats, ); f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a); @@ -256,7 +257,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { } if app.view_panel_open { let a = Rect::new(side.x, y, side.width, ph); - let content = ViewContent::new(&app.workbook); + let content = ViewContent::new(&app.model_state.workbook); f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a); } } else { @@ -265,9 +266,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { f.render_widget( GridWidget::new( - &app.workbook.model, - app.workbook.active_view(), - &app.workbook.active_view, + &app.model_state.workbook.model, + app.model_state.workbook.active_view(), + &app.model_state.workbook.active_view, &app.layout, &app.mode, &app.search_query, @@ -281,8 +282,8 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { f.render_widget( TileBar::new( - &app.workbook.model, - app.workbook.active_view(), + &app.model_state.workbook.model, + app.model_state.workbook.active_view(), &app.mode, app.tile_cat_idx, ), @@ -326,7 +327,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { }; let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" }; - let view_badge = format!(" {}{} ", app.workbook.active_view, yank_indicator); + let view_badge = format!(" {}{} ", app.model_state.workbook.active_view, yank_indicator); let left = format!(" {}{search_part} {msg}", mode_name(&app.mode)); let line = fill_line(left, &view_badge, area.width); diff --git a/src/main.rs b/src/main.rs index 7f6339f..fadaf18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -353,7 +353,7 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> } if let Some(path) = file { - persistence::save(&app.workbook, path)?; + persistence::save(&app.model_state.workbook, path)?; } std::process::exit(exit_code); diff --git a/src/ui/app.rs b/src/ui/app.rs index dd063a5..c455c2e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -187,10 +187,23 @@ impl AppMode { /// 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. Filled in -/// by improvise-x2c (vb4 step 2). -#[derive(Debug, Default)] -pub struct ModelState {} +/// 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. Filled in by @@ -199,8 +212,7 @@ pub struct ModelState {} pub struct ViewState {} pub struct App { - pub workbook: Workbook, - pub file_path: Option, + pub model_state: ModelState, pub mode: AppMode, pub status_msg: String, pub wizard: Option, @@ -213,7 +225,6 @@ pub struct App { 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) @@ -264,8 +275,11 @@ impl App { GridLayout::with_frozen_records(&workbook.model, view, None) }; Self { - workbook, - file_path, + model_state: ModelState { + workbook, + file_path, + dirty: false, + }, mode: AppMode::Normal, status_msg: String::new(), wizard: None, @@ -278,7 +292,6 @@ impl App { cat_panel_cursor: 0, view_panel_cursor: 0, formula_cursor: 0, - dirty: false, yanked: None, tile_cat_idx: 0, view_back_stack: Vec::new(), @@ -299,20 +312,20 @@ impl App { /// 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.workbook.active_view().none_cats(); - self.workbook.model.recompute_formulas(&none_cats); - let view = self.workbook.active_view(); + 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)); - self.layout = GridLayout::with_frozen_records(&self.workbook.model, view, frozen); + 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.workbook.active_view(); + let view = self.model_state.workbook.active_view(); let layout = &self.layout; let (sel_row, sel_col) = view.selected; CmdContext { - model: &self.workbook.model, - workbook: &self.workbook, + model: &self.model_state.workbook.model, + workbook: &self.model_state.workbook, view, layout, registry: self.keymap_set.registry(), @@ -322,7 +335,7 @@ impl App { col_offset: view.col_offset, search_query: &self.search_query, yanked: &self.yanked, - dirty: self.dirty, + dirty: self.model_state.dirty, search_mode: self.search_mode, formula_panel_open: self.formula_panel_open, category_panel_open: self.category_panel_open, @@ -345,7 +358,7 @@ impl App { .or_else(|| layout.resolve_display(k)) .unwrap_or_default() } else { - self.workbook + self.model_state.workbook .model .get_cell(k) .map(|v| v.to_string()) @@ -359,7 +372,7 @@ impl App { visible_cols: { let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); let col_widths = - compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals); + 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, @@ -392,7 +405,7 @@ impl App { /// 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.workbook.model.categories.values().all(|c| { + self.model_state.workbook.model.categories.values().all(|c| { matches!( c.kind, CategoryKind::VirtualIndex @@ -431,12 +444,12 @@ impl App { } pub fn autosave_if_needed(&mut self) { - if self.dirty + if self.model_state.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) - && let Some(path) = &self.file_path.clone() + && let Some(path) = &self.model_state.file_path.clone() { let ap = persistence::autosave_path(path); - let _ = persistence::save(&self.workbook, &ap); + let _ = persistence::save(&self.model_state.workbook, &ap); self.last_autosave = Instant::now(); } } @@ -494,6 +507,16 @@ mod tests { 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; + } + fn two_col_model() -> App { let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); // → Row axis @@ -515,7 +538,7 @@ mod tests { fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance { use crate::command::cmd::navigation::CursorState; - let view = app.workbook.active_view(); + let view = app.model_state.workbook.active_view(); let cursor = CursorState { row: view.selected.0, col: view.selected.1, @@ -532,29 +555,29 @@ mod tests { #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); - app.workbook.active_view_mut().selected = (0, 0); + app.model_state.workbook.active_view_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.workbook.active_view().selected, (1, 0)); + 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.workbook.active_view_mut().selected = (2, 0); + app.model_state.workbook.active_view_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.workbook.active_view().selected, (0, 1)); + 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.workbook.active_view_mut().selected = (2, 1); + app.model_state.workbook.active_view_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.workbook.active_view().selected, (2, 1)); + assert_eq!(app.model_state.workbook.active_view().selected, (2, 1)); } #[test] @@ -633,34 +656,34 @@ mod tests { } assert_eq!( - app.workbook.active_view().selected.1, + app.model_state.workbook.active_view().selected.1, 3, "cursor should be at column 3" ); assert!( - app.workbook.active_view().col_offset > 0, + 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.workbook.active_view().col_offset + app.model_state.workbook.active_view().col_offset ); } #[test] fn home_jumps_to_first_col() { let mut app = two_col_model(); - app.workbook.active_view_mut().selected = (1, 1); + app.model_state.workbook.active_view_mut().selected = (1, 1); app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.workbook.active_view().selected, (1, 0)); + 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.workbook.active_view_mut().selected = (1, 0); + app.model_state.workbook.active_view_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.workbook.active_view().selected, (1, 1)); + assert_eq!(app.model_state.workbook.active_view().selected, (1, 1)); } #[test] @@ -668,40 +691,40 @@ mod tests { let mut app = two_col_model(); // Add enough rows for i in 0..30 { - app.workbook + app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; // ~20 visible rows → delta = 15 - app.workbook.active_view_mut().selected = (0, 0); + app.model_state.workbook.active_view_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.workbook.active_view().selected.1, 0, "column preserved"); + assert_eq!(app.model_state.workbook.active_view().selected.1, 0, "column preserved"); assert!( - app.workbook.active_view().selected.0 > 0, + app.model_state.workbook.active_view().selected.0 > 0, "row should advance on PageDown" ); // 3/4 of ~20 = 15 - assert_eq!(app.workbook.active_view().selected.0, 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.workbook + app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; - app.workbook.active_view_mut().selected = (20, 0); + app.model_state.workbook.active_view_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.workbook.active_view().selected.0, 5); + assert_eq!(app.model_state.workbook.active_view().selected.0, 5); } #[test] @@ -709,22 +732,22 @@ mod tests { let mut app = two_col_model(); // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12. for i in 0..10 { - app.workbook + app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows - app.workbook.active_view_mut().selected = (0, 0); + 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.workbook.active_view().selected.0; + 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.workbook.active_view().row_offset; + 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}" @@ -735,34 +758,34 @@ mod tests { fn ctrl_d_scrolls_viewport_with_small_terminal() { let mut app = two_col_model(); for i in 0..30 { - app.workbook + app.model_state.workbook .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows - app.workbook.active_view_mut().selected = (0, 0); + 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.workbook.active_view().selected.0, 5); + 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.workbook.active_view().selected.0, 10); + assert_eq!(app.model_state.workbook.active_view().selected.0, 10); assert!( - app.workbook.active_view().row_offset > 0, + app.model_state.workbook.active_view().row_offset > 0, "row_offset should scroll with small terminal, but is {}", - app.workbook.active_view().row_offset + 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.workbook.active_view_mut().selected = (0, 0); + app.model_state.workbook.active_view_mut().selected = (0, 0); // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); @@ -780,7 +803,7 @@ mod tests { app.mode ); assert_eq!( - app.workbook.active_view().selected.1, + app.model_state.workbook.active_view().selected.1, 1, "should have moved to column 1" ); @@ -885,7 +908,7 @@ mod tests { .unwrap(); assert_eq!( - app.workbook.model.get_cell(&CellKey::new(vec![( + app.model_state.workbook.model.get_cell(&CellKey::new(vec![( "_Measure".to_string(), "Rev".to_string(), )])), @@ -957,11 +980,11 @@ mod tests { 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.workbook + app.model_state.workbook .model .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); assert!( - app.workbook + app.model_state.workbook .model .data .iter_cells() @@ -976,7 +999,7 @@ mod tests { "setup: should have left records mode" ); assert!( - !app.workbook + !app.model_state.workbook .model .data .iter_cells() @@ -994,7 +1017,7 @@ mod tests { 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.workbook.active_view_mut().selected = (last_row, last_col); + app.model_state.workbook.active_view_mut().selected = (last_row, last_col); app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); @@ -1025,7 +1048,7 @@ mod tests { let last_row = initial_rows - 1; let last_col = app.layout.col_count() - 1; - app.workbook.active_view_mut().selected = (last_row, last_col); + 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)) @@ -1042,7 +1065,7 @@ mod tests { "TAB on bottom-right should insert a record below" ); assert_eq!( - app.workbook.active_view().selected, + app.model_state.workbook.active_view().selected, (initial_rows, 0), "TAB should move to first cell of the new row" ); @@ -1078,7 +1101,7 @@ mod tests { 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.workbook.active_view_mut().selected = (0, value_col); + 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)) @@ -1089,7 +1112,7 @@ mod tests { .unwrap(); assert_eq!( - app.workbook.model.get_cell(&record_key), + app.model_state.workbook.model.get_cell(&record_key), Some(&CellValue::Number(1.0)), "drill edit should remain staged until leaving the drill view" ); @@ -1107,7 +1130,7 @@ mod tests { .unwrap(); assert_eq!( - app.workbook.model.get_cell(&record_key), + app.model_state.workbook.model.get_cell(&record_key), Some(&CellValue::Number(9.0)), "leaving drill view should apply the staged edit" ); @@ -1140,7 +1163,7 @@ mod tests { .unwrap(); assert!( - !app.workbook + !app.model_state.workbook .model .category("Region") .unwrap() diff --git a/src/ui/effect.rs b/src/ui/effect.rs index bdf7285..208209a 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -24,7 +24,7 @@ pub trait Effect: Debug { pub struct AddCategory(pub String); impl Effect for AddCategory { fn apply(&self, app: &mut App) { - let _ = app.workbook.add_category(&self.0); + let _ = app.model_state.workbook.add_category(&self.0); } } @@ -35,7 +35,7 @@ pub struct AddItem { } impl Effect for AddItem { fn apply(&self, app: &mut App) { - if let Some(cat) = app.workbook.model.category_mut(&self.category) { + 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); @@ -51,7 +51,7 @@ pub struct AddItemInGroup { } impl Effect for AddItemInGroup { fn apply(&self, app: &mut App) { - if let Some(cat) = app.workbook.model.category_mut(&self.category) { + 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); @@ -63,7 +63,7 @@ impl Effect for AddItemInGroup { pub struct SortData; impl Effect for SortData { fn apply(&self, app: &mut App) { - app.workbook.model.data.sort_by_key(); + app.model_state.workbook.model.data.sort_by_key(); } } @@ -71,7 +71,7 @@ impl Effect for SortData { pub struct SetCell(pub CellKey, pub CellValue); impl Effect for SetCell { fn apply(&self, app: &mut App) { - app.workbook.model.set_cell(self.0.clone(), self.1.clone()); + app.model_state.workbook.model.set_cell(self.0.clone(), self.1.clone()); } } @@ -79,7 +79,7 @@ impl Effect for SetCell { pub struct ClearCell(pub CellKey); impl Effect for ClearCell { fn apply(&self, app: &mut App) { - app.workbook.model.clear_cell(&self.0); + app.model_state.workbook.model.clear_cell(&self.0); } } @@ -96,11 +96,11 @@ impl Effect for AddFormula { // appears in the grid. _Measure targets are dynamically included // via Model::measure_item_names(). if formula.target_category != "_Measure" - && let Some(cat) = app.workbook.model.category_mut(&formula.target_category) + && let Some(cat) = app.model_state.workbook.model.category_mut(&formula.target_category) { cat.add_item(&formula.target); } - app.workbook.model.add_formula(formula); + app.model_state.workbook.model.add_formula(formula); } Err(e) => { app.status_msg = format!("Formula error: {e}"); @@ -116,7 +116,7 @@ pub struct RemoveFormula { } impl Effect for RemoveFormula { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .model .remove_formula(&self.target, &self.target_category); } @@ -153,7 +153,7 @@ impl Effect for EnterEditAtCursor { pub struct TogglePruneEmpty; impl Effect for TogglePruneEmpty { fn apply(&self, app: &mut App) { - let v = app.workbook.active_view_mut(); + let v = app.model_state.workbook.active_view_mut(); v.prune_empty = !v.prune_empty; } } @@ -175,7 +175,7 @@ pub struct RemoveItem { } impl Effect for RemoveItem { fn apply(&self, app: &mut App) { - app.workbook.model.remove_item(&self.category, &self.item); + app.model_state.workbook.model.remove_item(&self.category, &self.item); } } @@ -183,7 +183,7 @@ impl Effect for RemoveItem { pub struct RemoveCategory(pub String); impl Effect for RemoveCategory { fn apply(&self, app: &mut App) { - app.workbook.remove_category(&self.0); + app.model_state.workbook.remove_category(&self.0); } } @@ -193,7 +193,7 @@ impl Effect for RemoveCategory { pub struct CreateView(pub String); impl Effect for CreateView { fn apply(&self, app: &mut App) { - app.workbook.create_view(&self.0); + app.model_state.workbook.create_view(&self.0); } } @@ -201,7 +201,7 @@ impl Effect for CreateView { pub struct DeleteView(pub String); impl Effect for DeleteView { fn apply(&self, app: &mut App) { - let _ = app.workbook.delete_view(&self.0); + let _ = app.model_state.workbook.delete_view(&self.0); } } @@ -209,7 +209,7 @@ impl Effect for DeleteView { pub struct SwitchView(pub String); impl Effect for SwitchView { fn apply(&self, app: &mut App) { - let current = app.workbook.active_view.clone(); + let current = app.model_state.workbook.active_view.clone(); if current != self.0 { app.view_back_stack.push(ViewFrame { view_name: current, @@ -217,7 +217,7 @@ impl Effect for SwitchView { }); app.view_forward_stack.clear(); } - let _ = app.workbook.switch_view(&self.0); + let _ = app.model_state.workbook.switch_view(&self.0); } } @@ -227,12 +227,12 @@ pub struct ViewBack; impl Effect for ViewBack { fn apply(&self, app: &mut App) { if let Some(frame) = app.view_back_stack.pop() { - let current = app.workbook.active_view.clone(); + let current = app.model_state.workbook.active_view.clone(); app.view_forward_stack.push(ViewFrame { view_name: current, mode: app.mode.clone(), }); - let _ = app.workbook.switch_view(&frame.view_name); + let _ = app.model_state.workbook.switch_view(&frame.view_name); app.mode = frame.mode; } } @@ -244,12 +244,12 @@ pub struct ViewForward; impl Effect for ViewForward { fn apply(&self, app: &mut App) { if let Some(frame) = app.view_forward_stack.pop() { - let current = app.workbook.active_view.clone(); + let current = app.model_state.workbook.active_view.clone(); app.view_back_stack.push(ViewFrame { view_name: current, mode: app.mode.clone(), }); - let _ = app.workbook.switch_view(&frame.view_name); + let _ = app.model_state.workbook.switch_view(&frame.view_name); app.mode = frame.mode; } } @@ -262,7 +262,7 @@ pub struct SetAxis { } impl Effect for SetAxis { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .active_view_mut() .set_axis(&self.category, self.axis); } @@ -275,7 +275,7 @@ pub struct SetPageSelection { } impl Effect for SetPageSelection { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .active_view_mut() .set_page_selection(&self.category, &self.item); } @@ -288,7 +288,7 @@ pub struct ToggleGroup { } impl Effect for ToggleGroup { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .active_view_mut() .toggle_group_collapse(&self.category, &self.group); } @@ -301,7 +301,7 @@ pub struct HideItem { } impl Effect for HideItem { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .active_view_mut() .hide_item(&self.category, &self.item); } @@ -314,7 +314,7 @@ pub struct ShowItem { } impl Effect for ShowItem { fn apply(&self, app: &mut App) { - app.workbook + app.model_state.workbook .active_view_mut() .show_item(&self.category, &self.item); } @@ -324,7 +324,7 @@ impl Effect for ShowItem { pub struct TransposeAxes; impl Effect for TransposeAxes { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().transpose_axes(); + app.model_state.workbook.active_view_mut().transpose_axes(); } } @@ -332,7 +332,7 @@ impl Effect for TransposeAxes { pub struct CycleAxis(pub String); impl Effect for CycleAxis { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().cycle_axis(&self.0); + app.model_state.workbook.active_view_mut().cycle_axis(&self.0); } } @@ -340,7 +340,7 @@ impl Effect for CycleAxis { pub struct SetNumberFormat(pub String); impl Effect for SetNumberFormat { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().number_format = self.0.clone(); + app.model_state.workbook.active_view_mut().number_format = self.0.clone(); } } @@ -350,7 +350,7 @@ impl Effect for SetNumberFormat { pub struct SetSelected(pub usize, pub usize); impl Effect for SetSelected { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().selected = (self.0, self.1); + app.model_state.workbook.active_view_mut().selected = (self.0, self.1); } } @@ -358,7 +358,7 @@ impl Effect for SetSelected { pub struct SetRowOffset(pub usize); impl Effect for SetRowOffset { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().row_offset = self.0; + app.model_state.workbook.active_view_mut().row_offset = self.0; } } @@ -366,7 +366,7 @@ impl Effect for SetRowOffset { pub struct SetColOffset(pub usize); impl Effect for SetColOffset { fn apply(&self, app: &mut App) { - app.workbook.active_view_mut().col_offset = self.0; + app.model_state.workbook.active_view_mut().col_offset = self.0; } } @@ -395,7 +395,7 @@ impl Effect for SetStatus { pub struct MarkDirty; impl Effect for MarkDirty { fn apply(&self, app: &mut App) { - app.dirty = true; + app.model_state.dirty = true; } } @@ -480,25 +480,25 @@ impl Effect for ApplyAndClearDrill { if col_name == "Value" { // Update the cell's value let value = if new_value.is_empty() { - app.workbook.model.clear_cell(orig_key); + app.model_state.workbook.model.clear_cell(orig_key); continue; } else if let Ok(n) = new_value.parse::() { CellValue::Number(n) } else { CellValue::Text(new_value.clone()) }; - app.workbook.model.set_cell(orig_key.clone(), value); + 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(); continue; } // Rename a coordinate: remove old cell, insert new with updated coord - let value = match app.workbook.model.get_cell(orig_key) { + let value = match app.model_state.workbook.model.get_cell(orig_key) { Some(v) => v.clone(), None => continue, }; - app.workbook.model.clear_cell(orig_key); + app.model_state.workbook.model.clear_cell(orig_key); // Build new key by replacing the coord let new_coords: Vec<(String, String)> = orig_key .0 @@ -513,13 +513,13 @@ impl Effect for ApplyAndClearDrill { .collect(); let new_key = CellKey::new(new_coords); // Ensure the new item exists in that category - if let Some(cat) = app.workbook.model.category_mut(col_name) { + if let Some(cat) = app.model_state.workbook.model.category_mut(col_name) { cat.add_item(new_value.clone()); } - app.workbook.model.set_cell(new_key, value); + app.model_state.workbook.model.set_cell(new_key, value); } } - app.dirty = true; + app.model_state.dirty = true; } } @@ -547,10 +547,10 @@ impl Effect for SetDrillPendingEdit { pub struct Save; impl Effect for Save { fn apply(&self, app: &mut App) { - if let Some(ref path) = app.file_path { - match crate::persistence::save(&app.workbook, path) { + if let Some(ref path) = app.model_state.file_path { + match crate::persistence::save(&app.model_state.workbook, path) { Ok(()) => { - app.dirty = false; + app.model_state.dirty = false; app.status_msg = format!("Saved to {}", path.display()); } Err(e) => { @@ -567,10 +567,10 @@ impl Effect for Save { pub struct SaveAs(pub PathBuf); impl Effect for SaveAs { fn apply(&self, app: &mut App) { - match crate::persistence::save(&app.workbook, &self.0) { + match crate::persistence::save(&app.model_state.workbook, &self.0) { Ok(()) => { - app.file_path = Some(self.0.clone()); - app.dirty = false; + app.model_state.file_path = Some(self.0.clone()); + app.model_state.dirty = false; app.status_msg = format!("Saved to {}", self.0.display()); } Err(e) => { @@ -685,9 +685,9 @@ impl Effect for WizardKey { crossterm::event::KeyCode::Enter => match wizard.build_model() { Ok(mut workbook) => { workbook.normalize_view_state(); - app.workbook = workbook; + app.model_state.workbook = workbook; app.formula_cursor = 0; - app.dirty = true; + app.model_state.dirty = true; app.status_msg = "Import successful! Press :w to save.".to_string(); app.mode = AppMode::Normal; app.wizard = None; @@ -738,8 +738,8 @@ impl Effect for StartImportWizard { pub struct ExportCsv(pub PathBuf); impl Effect for ExportCsv { fn apply(&self, app: &mut App) { - let view_name = app.workbook.active_view.clone(); - match crate::persistence::export_csv(&app.workbook, &view_name, &self.0) { + 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()); } @@ -758,7 +758,7 @@ impl Effect for LoadModel { match crate::persistence::load(&self.0) { Ok(mut loaded) => { loaded.normalize_view_state(); - app.workbook = loaded; + app.model_state.workbook = loaded; app.status_msg = format!("Loaded from {}", self.0.display()); } Err(e) => { @@ -869,7 +869,7 @@ impl Effect for ImportJsonHeadless { match pipeline.build_model() { Ok(new_workbook) => { - app.workbook = new_workbook; + app.model_state.workbook = new_workbook; app.status_msg = "Imported successfully".to_string(); } Err(e) => { @@ -953,6 +953,8 @@ pub struct CleanEmptyRecords; impl Effect for CleanEmptyRecords { fn apply(&self, app: &mut App) { let empties: Vec = app + .model_state + .workbook .model .data @@ -960,7 +962,7 @@ impl Effect for CleanEmptyRecords { .filter_map(|(k, _)| if k.0.is_empty() { Some(k) } else { None }) .collect(); for key in empties { - app.workbook.model.clear_cell(&key); + app.model_state.workbook.model.clear_cell(&key); } } } @@ -1045,7 +1047,7 @@ mod tests { fn add_category_effect() { let mut app = test_app(); AddCategory("Region".to_string()).apply(&mut app); - assert!(app.workbook.model.category("Region").is_some()); + assert!(app.model_state.workbook.model.category("Region").is_some()); } #[test] @@ -1057,6 +1059,8 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .model_state + .workbook .model .category("Type") @@ -1087,12 +1091,12 @@ mod tests { ]); SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app); assert_eq!( - app.workbook.model.get_cell(&key), + app.model_state.workbook.model.get_cell(&key), Some(&CellValue::Number(42.0)) ); ClearCell(key.clone()).apply(&mut app); - assert_eq!(app.workbook.model.get_cell(&key), None); + assert_eq!(app.model_state.workbook.model.get_cell(&key), None); } #[test] @@ -1103,7 +1107,7 @@ mod tests { target_category: "Type".to_string(), } .apply(&mut app); - assert!(!app.workbook.model.formulas().is_empty()); + assert!(!app.model_state.workbook.model.formulas().is_empty()); } /// Regression: AddFormula must add the target item to the target category @@ -1114,7 +1118,7 @@ mod tests { let mut app = test_app(); // "Margin" does not exist as an item in "Type" before adding the formula assert!( - !app.workbook + !app.model_state.workbook .model .category("Type") .unwrap() @@ -1127,6 +1131,8 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .model_state + .workbook .model .category("Type") @@ -1152,7 +1158,7 @@ mod tests { } .apply(&mut app); // Should appear in effective_item_names (used by layout) - let effective = app.workbook.model.effective_item_names("_Measure"); + let effective = app.model_state.workbook.model.effective_item_names("_Measure"); assert!( effective.contains(&"Margin".to_string()), "formula target 'Margin' should appear in effective _Measure items, got: {:?}", @@ -1160,7 +1166,7 @@ mod tests { ); // Should NOT be in the category's own items assert!( - !app.workbook + !app.model_state.workbook .model .category("_Measure") .unwrap() @@ -1189,13 +1195,13 @@ mod tests { target_category: "Type".to_string(), } .apply(&mut app); - assert!(!app.workbook.model.formulas().is_empty()); + assert!(!app.model_state.workbook.model.formulas().is_empty()); RemoveFormula { target: "Clothing".to_string(), target_category: "Type".to_string(), } .apply(&mut app); - assert!(app.workbook.model.formulas().is_empty()); + assert!(app.model_state.workbook.model.formulas().is_empty()); } // ── View effects ──────────────────────────────────────────────────── @@ -1203,11 +1209,11 @@ mod tests { #[test] fn switch_view_pushes_to_back_stack() { let mut app = test_app(); - app.workbook.create_view("View 2"); + app.model_state.workbook.create_view("View 2"); assert!(app.view_back_stack.is_empty()); SwitchView("View 2".to_string()).apply(&mut app); - assert_eq!(app.workbook.active_view.as_str(), "View 2"); + 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"); // Forward stack should be cleared @@ -1224,20 +1230,20 @@ mod tests { #[test] fn view_back_and_forward() { let mut app = test_app(); - app.workbook.create_view("View 2"); + app.model_state.workbook.create_view("View 2"); SwitchView("View 2".to_string()).apply(&mut app); - assert_eq!(app.workbook.active_view.as_str(), "View 2"); + assert_eq!(app.model_state.workbook.active_view.as_str(), "View 2"); // Go back ViewBack.apply(&mut app); - assert_eq!(app.workbook.active_view.as_str(), "Default"); + 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()); // Go forward ViewForward.apply(&mut app); - assert_eq!(app.workbook.active_view.as_str(), "View 2"); + 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()); @@ -1246,19 +1252,19 @@ mod tests { #[test] fn view_back_with_empty_stack_is_noop() { let mut app = test_app(); - let before = app.workbook.active_view.clone(); + let before = app.model_state.workbook.active_view.clone(); ViewBack.apply(&mut app); - assert_eq!(app.workbook.active_view, before); + assert_eq!(app.model_state.workbook.active_view, before); } #[test] fn create_and_delete_view() { let mut app = test_app(); CreateView("View 2".to_string()).apply(&mut app); - assert!(app.workbook.views.contains_key("View 2")); + assert!(app.model_state.workbook.views.contains_key("View 2")); DeleteView("View 2".to_string()).apply(&mut app); - assert!(!app.workbook.views.contains_key("View 2")); + assert!(!app.model_state.workbook.views.contains_key("View 2")); } #[test] @@ -1269,13 +1275,15 @@ mod tests { axis: Axis::Page, } .apply(&mut app); - assert_eq!(app.workbook.active_view().axis_of("Type"), Axis::Page); + assert_eq!(app.model_state.workbook.active_view().axis_of("Type"), Axis::Page); } #[test] fn transpose_axes_effect() { let mut app = test_app(); let row_before: Vec = app + .model_state + .workbook .active_view() .categories_on(Axis::Row) @@ -1283,6 +1291,8 @@ mod tests { .map(String::from) .collect(); let col_before: Vec = app + .model_state + .workbook .active_view() .categories_on(Axis::Column) @@ -1291,6 +1301,8 @@ mod tests { .collect(); TransposeAxes.apply(&mut app); let row_after: Vec = app + .model_state + .workbook .active_view() .categories_on(Axis::Row) @@ -1298,6 +1310,8 @@ mod tests { .map(String::from) .collect(); let col_after: Vec = app + .model_state + .workbook .active_view() .categories_on(Axis::Column) @@ -1314,7 +1328,7 @@ mod tests { fn set_selected_effect() { let mut app = test_app(); SetSelected(3, 5).apply(&mut app); - assert_eq!(app.workbook.active_view().selected, (3, 5)); + assert_eq!(app.model_state.workbook.active_view().selected, (3, 5)); } #[test] @@ -1322,8 +1336,8 @@ mod tests { let mut app = test_app(); SetRowOffset(10).apply(&mut app); SetColOffset(5).apply(&mut app); - assert_eq!(app.workbook.active_view().row_offset, 10); - assert_eq!(app.workbook.active_view().col_offset, 5); + assert_eq!(app.model_state.workbook.active_view().row_offset, 10); + assert_eq!(app.model_state.workbook.active_view().col_offset, 5); } // ── App state effects ─────────────────────────────────────────────── @@ -1369,7 +1383,7 @@ mod tests { let mut app = test_app(); // An empty-key cell (the bug: produced by AddRecordRow when no page // filters are set). - app.workbook + app.model_state.workbook .model .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); // Plus a well-formed cell that must survive. @@ -1377,15 +1391,15 @@ mod tests { ("Type".to_string(), "Food".to_string()), ("Month".to_string(), "Jan".to_string()), ]); - app.workbook + app.model_state.workbook .model .set_cell(valid.clone(), CellValue::Number(42.0)); - assert_eq!(app.workbook.model.data.iter_cells().count(), 2); + assert_eq!(app.model_state.workbook.model.data.iter_cells().count(), 2); CleanEmptyRecords.apply(&mut app); assert!( - !app.workbook + !app.model_state.workbook .model .data .iter_cells() @@ -1393,7 +1407,7 @@ mod tests { "empty-key cell should be gone" ); assert_eq!( - app.workbook.model.get_cell(&valid), + app.model_state.workbook.model.get_cell(&valid), Some(&CellValue::Number(42.0)), "valid cell must survive" ); @@ -1459,9 +1473,9 @@ mod tests { #[test] fn mark_dirty_effect() { let mut app = test_app(); - assert!(!app.dirty); + assert!(!app.model_state.dirty); MarkDirty.apply(&mut app); - assert!(app.dirty); + assert!(app.model_state.dirty); } #[test] @@ -1587,7 +1601,7 @@ mod tests { // Apply with no pending edits — should just clear state ApplyAndClearDrill.apply(&mut app); assert!(app.drill_state.is_none()); - assert!(!app.dirty); // no edits → not dirty + assert!(!app.model_state.dirty); // no edits → not dirty } #[test] @@ -1598,7 +1612,7 @@ mod tests { ("Month".into(), "Jan".into()), ]); // Set original cell - app.workbook + app.model_state.workbook .model .set_cell(key.clone(), CellValue::Number(42.0)); @@ -1615,9 +1629,9 @@ mod tests { ApplyAndClearDrill.apply(&mut app); assert!(app.drill_state.is_none()); - assert!(app.dirty); + assert!(app.model_state.dirty); assert_eq!( - app.workbook.model.get_cell(&key), + app.model_state.workbook.model.get_cell(&key), Some(&CellValue::Number(99.0)) ); } @@ -1629,7 +1643,7 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.workbook + app.model_state.workbook .model .set_cell(key.clone(), CellValue::Number(42.0)); @@ -1645,20 +1659,22 @@ mod tests { .apply(&mut app); ApplyAndClearDrill.apply(&mut app); - assert!(app.dirty); + assert!(app.model_state.dirty); // Old cell should be gone - assert_eq!(app.workbook.model.get_cell(&key), None); + assert_eq!(app.model_state.workbook.model.get_cell(&key), None); // New cell should exist let new_key = CellKey::new(vec![ ("Type".into(), "Drink".into()), ("Month".into(), "Jan".into()), ]); assert_eq!( - app.workbook.model.get_cell(&new_key), + app.model_state.workbook.model.get_cell(&new_key), Some(&CellValue::Number(42.0)) ); // "Drink" should have been added as an item let items: Vec<&str> = app + .model_state + .workbook .model .category("Type") @@ -1676,7 +1692,7 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.workbook + app.model_state.workbook .model .set_cell(key.clone(), CellValue::Number(42.0)); @@ -1692,7 +1708,7 @@ mod tests { .apply(&mut app); ApplyAndClearDrill.apply(&mut app); - assert_eq!(app.workbook.model.get_cell(&key), None); + assert_eq!(app.model_state.workbook.model.get_cell(&key), None); } // ── Toggle effects ────────────────────────────────────────────────── @@ -1700,11 +1716,11 @@ mod tests { #[test] fn toggle_prune_empty_effect() { let mut app = test_app(); - let before = app.workbook.active_view().prune_empty; + let before = app.model_state.workbook.active_view().prune_empty; TogglePruneEmpty.apply(&mut app); - assert_ne!(app.workbook.active_view().prune_empty, before); + assert_ne!(app.model_state.workbook.active_view().prune_empty, before); TogglePruneEmpty.apply(&mut app); - assert_eq!(app.workbook.active_view().prune_empty, before); + assert_eq!(app.model_state.workbook.active_view().prune_empty, before); } #[test] @@ -1726,6 +1742,8 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .model_state + .workbook .model .category("Type") @@ -1736,7 +1754,7 @@ mod tests { assert!(!items.contains(&"Food")); RemoveCategory("Month".to_string()).apply(&mut app); - assert!(app.workbook.model.category("Month").is_none()); + assert!(app.model_state.workbook.model.category("Month").is_none()); } // ── Number format ─────────────────────────────────────────────────── @@ -1745,7 +1763,7 @@ mod tests { fn set_number_format_effect() { let mut app = test_app(); SetNumberFormat(",.2f".to_string()).apply(&mut app); - assert_eq!(app.workbook.active_view().number_format, ",.2f"); + assert_eq!(app.model_state.workbook.active_view().number_format, ",.2f"); } // ── Page selection ────────────────────────────────────────────────── @@ -1759,7 +1777,7 @@ mod tests { } .apply(&mut app); assert_eq!( - app.workbook.active_view().page_selection("Type"), + app.model_state.workbook.active_view().page_selection("Type"), Some("Food") ); } @@ -1774,14 +1792,14 @@ mod tests { item: "Food".to_string(), } .apply(&mut app); - assert!(app.workbook.active_view().is_hidden("Type", "Food")); + assert!(app.model_state.workbook.active_view().is_hidden("Type", "Food")); ShowItem { category: "Type".to_string(), item: "Food".to_string(), } .apply(&mut app); - assert!(!app.workbook.active_view().is_hidden("Type", "Food")); + assert!(!app.model_state.workbook.active_view().is_hidden("Type", "Food")); } // ── Toggle group ──────────────────────────────────────────────────── @@ -1795,7 +1813,7 @@ mod tests { } .apply(&mut app); assert!( - app.workbook + app.model_state.workbook .active_view() .is_group_collapsed("Type", "MyGroup") ); @@ -1805,7 +1823,7 @@ mod tests { } .apply(&mut app); assert!( - !app.workbook + !app.model_state.workbook .active_view() .is_group_collapsed("Type", "MyGroup") ); @@ -1816,9 +1834,9 @@ mod tests { #[test] fn cycle_axis_effect() { let mut app = test_app(); - let before = app.workbook.active_view().axis_of("Type"); + let before = app.model_state.workbook.active_view().axis_of("Type"); CycleAxis("Type".to_string()).apply(&mut app); - let after = app.workbook.active_view().axis_of("Type"); + let after = app.model_state.workbook.active_view().axis_of("Type"); assert_ne!(before, after); }