From 3fbf56ec8b637cb35e70982dcd74507d4fe3b337 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 21:08:11 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20break=20Model=E2=86=94View=20cycle,?= =?UTF-8?q?=20introduce=20Workbook=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model is now pure data (categories, cells, formulas, measure_agg) with no references to view/. The Workbook struct owns the Model together with views and the active view name, and is responsible for cross-slice operations (add/remove category → notify views, view management). - New: src/workbook.rs with Workbook wrapper and cross-slice helpers (add_category, add_label_category, remove_category, create_view, switch_view, delete_view, normalize_view_state). - Model: strip view state and view-touching methods. recompute_formulas remains on Model as a primitive; the view-derived none_cats list is gathered at each call site (App::rebuild_layout, persistence::load) so the view dependency is explicit, not hidden behind a wrapper. - View: add View::none_cats() helper. - CmdContext: add workbook and view fields so commands can reach both slices without threading Model + View through every call. - App: rename `model` field to `workbook`. - Persistence (save/load/format_md/parse_md/export_csv): take/return Workbook so the on-disk format carries model + views together. - Widgets (GridWidget, TileBar, CategoryContent, ViewContent): take explicit &Model + &View instead of routing through Model. Tests updated throughout to reflect the new shape. View-management tests that previously lived on Model continue to cover the same behaviour via a build_workbook() helper in model/types.rs. All 573 tests pass; clippy is clean. This is Phase A of improvise-36h. Phase B will mechanically extract crates/improvise-core/ containing model/, view/, format.rs, workbook.rs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/command/cmd/cell.rs | 6 +- src/command/cmd/commit.rs | 4 +- src/command/cmd/core.rs | 9 +- src/command/cmd/grid.rs | 20 +- src/command/cmd/mod.rs | 64 ++-- src/command/cmd/mode.rs | 4 +- src/command/cmd/navigation.rs | 2 +- src/command/cmd/panel.rs | 8 +- src/command/cmd/registry.rs | 2 +- src/command/cmd/search.rs | 4 +- src/command/cmd/tile.rs | 2 +- src/draw.rs | 33 ++- src/import/wizard.rs | 80 ++--- src/lib.rs | 1 + src/main.rs | 41 ++- src/model/types.rs | 283 ++++++------------ src/persistence/mod.rs | 535 +++++++++++++++++++--------------- src/ui/app.rs | 192 ++++++------ src/ui/category_panel.rs | 14 +- src/ui/effect.rs | 207 ++++++------- src/ui/grid.rs | 135 +++++---- src/ui/tile_bar.rs | 13 +- src/ui/view_panel.rs | 14 +- src/view/layout.rs | 301 ++++++++++--------- src/view/types.rs | 10 + src/workbook.rs | 259 ++++++++++++++++ 26 files changed, 1271 insertions(+), 972 deletions(-) create mode 100644 src/workbook.rs diff --git a/src/command/cmd/cell.rs b/src/command/cmd/cell.rs index 794e2f4..4bdaecc 100644 --- a/src/command/cmd/cell.rs +++ b/src/command/cmd/cell.rs @@ -15,7 +15,7 @@ mod tests { ("Type".to_string(), "Food".to_string()), ("Month".to_string(), "Jan".to_string()), ]); - m.set_cell(key, CellValue::Number(42.0)); + m.model.set_cell(key, CellValue::Number(42.0)); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); @@ -33,7 +33,7 @@ mod tests { ("Type".to_string(), "Food".to_string()), ("Month".to_string(), "Jan".to_string()), ]); - m.set_cell(key, CellValue::Number(99.0)); + m.model.set_cell(key, CellValue::Number(99.0)); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); @@ -47,7 +47,7 @@ mod tests { #[test] fn paste_with_yanked_value_produces_set_cell() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( CellKey::new(vec![ ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs index fa78d4c..0e17592 100644 --- a/src/command/cmd/commit.rs +++ b/src/command/cmd/commit.rs @@ -11,7 +11,7 @@ mod tests { use super::*; use crate::command::cmd::test_helpers::*; - use crate::model::Model; + use crate::workbook::Workbook; #[test] fn commit_formula_with_categories_adds_formula() { @@ -38,7 +38,7 @@ mod tests { /// categories exist. _Measure is a virtual category that always exists. #[test] fn commit_formula_without_regular_categories_targets_measure() { - let m = Model::new("Empty"); + let m = Workbook::new("Empty"); let layout = make_layout(&m); let reg = make_registry(); let mut bufs = HashMap::new(); diff --git a/src/command/cmd/core.rs b/src/command/cmd/core.rs index 049d7dc..08fbad5 100644 --- a/src/command/cmd/core.rs +++ b/src/command/cmd/core.rs @@ -7,11 +7,18 @@ use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::AppMode; use crate::ui::effect::{Effect, Panel}; -use crate::view::{Axis, GridLayout}; +use crate::view::{Axis, GridLayout, View}; +use crate::workbook::Workbook; /// Read-only context available to commands for decision-making. +/// +/// Commands receive a `&Model` (pure data) and a `&View` (the active view). +/// The full `&Workbook` is also available for the rare command that needs +/// to enumerate all views (e.g. the view panel). pub struct CmdContext<'a> { pub model: &'a Model, + pub workbook: &'a Workbook, + pub view: &'a View, pub layout: &'a GridLayout, pub registry: &'a CmdRegistry, pub mode: &'a AppMode, diff --git a/src/command/cmd/grid.rs b/src/command/cmd/grid.rs index cce6573..95b945a 100644 --- a/src/command/cmd/grid.rs +++ b/src/command/cmd/grid.rs @@ -119,29 +119,29 @@ mod tests { #[test] fn drill_into_formula_cell_returns_data_records() { use crate::formula::parse_formula; - use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; + use crate::workbook::Workbook; - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Region").unwrap(); - m.category_mut("Region").unwrap().add_item("East"); - m.category_mut("_Measure").unwrap().add_item("Revenue"); - m.category_mut("_Measure").unwrap().add_item("Cost"); - m.set_cell( + m.model.category_mut("Region").unwrap().add_item("East"); + m.model.category_mut("_Measure").unwrap().add_item("Revenue"); + m.model.category_mut("_Measure").unwrap().add_item("Cost"); + m.model.set_cell( CellKey::new(vec![ ("_Measure".into(), "Revenue".into()), ("Region".into(), "East".into()), ]), CellValue::Number(1000.0), ); - m.set_cell( + m.model.set_cell( CellKey::new(vec![ ("_Measure".into(), "Cost".into()), ("Region".into(), "East".into()), ]), CellValue::Number(600.0), ); - m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); + m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); let layout = make_layout(&m); let reg = make_registry(); @@ -376,7 +376,7 @@ impl Cmd for TogglePruneEmpty { "toggle-prune-empty" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let currently_on = ctx.model.active_view().prune_empty; + let currently_on = ctx.view.prune_empty; vec![ Box::new(effect::TogglePruneEmpty), effect::set_status(if currently_on { @@ -454,7 +454,7 @@ impl Cmd for AddRecordRow { )]; } // Build a CellKey from the current page filters - let view = ctx.model.active_view(); + let view = ctx.view; let page_cats: Vec = view .categories_on(crate::view::Axis::Page) .into_iter() diff --git a/src/command/cmd/mod.rs b/src/command/cmd/mod.rs index aa0daf3..d9993f0 100644 --- a/src/command/cmd/mod.rs +++ b/src/command/cmd/mod.rs @@ -21,10 +21,10 @@ pub(super) mod test_helpers { use crossterm::event::KeyCode; - use crate::model::Model; use crate::ui::app::AppMode; use crate::ui::effect::Effect; use crate::view::GridLayout; + use crate::workbook::Workbook; use super::core::CmdContext; use super::registry::default_registry; @@ -36,19 +36,21 @@ pub(super) mod test_helpers { pub static EMPTY_EXPANDED: std::sync::LazyLock> = std::sync::LazyLock::new(std::collections::HashSet::new); - pub fn make_layout(model: &Model) -> GridLayout { - GridLayout::new(model, model.active_view()) + pub fn make_layout(workbook: &Workbook) -> GridLayout { + GridLayout::new(&workbook.model, workbook.active_view()) } pub fn make_ctx<'a>( - model: &'a Model, + workbook: &'a Workbook, layout: &'a GridLayout, registry: &'a CmdRegistry, ) -> CmdContext<'a> { - let view = model.active_view(); + let view = workbook.active_view(); let (sr, sc) = view.selected; CmdContext { - model, + model: &workbook.model, + workbook, + view, layout, registry, mode: &AppMode::Normal, @@ -72,7 +74,7 @@ pub(super) mod test_helpers { display_value: { let key = layout.cell_key(sr, sc); key.as_ref() - .and_then(|k| model.get_cell(k).cloned()) + .and_then(|k| workbook.model.get_cell(k).cloned()) .map(|v| v.to_string()) .unwrap_or_default() }, @@ -83,32 +85,32 @@ pub(super) mod test_helpers { } } - pub fn two_cat_model() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m + pub fn two_cat_model() -> Workbook { + let mut wb = Workbook::new("Test"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Type").unwrap().add_item("Clothing"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + wb.model.category_mut("Month").unwrap().add_item("Feb"); + wb } - pub fn three_cat_model_with_page() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m.category_mut("Region").unwrap().add_item("North"); - m.category_mut("Region").unwrap().add_item("South"); - m.category_mut("Region").unwrap().add_item("East"); - let view = m.active_view_mut(); - view.set_axis("Region", crate::view::Axis::Page); - m + pub fn three_cat_model_with_page() -> Workbook { + let mut wb = Workbook::new("Test"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_category("Region").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Type").unwrap().add_item("Clothing"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + wb.model.category_mut("Month").unwrap().add_item("Feb"); + wb.model.category_mut("Region").unwrap().add_item("North"); + wb.model.category_mut("Region").unwrap().add_item("South"); + wb.model.category_mut("Region").unwrap().add_item("East"); + wb.active_view_mut() + .set_axis("Region", crate::view::Axis::Page); + wb } pub fn effects_debug(effects: &[Box]) -> String { diff --git a/src/command/cmd/mode.rs b/src/command/cmd/mode.rs index db269ad..741f7d3 100644 --- a/src/command/cmd/mode.rs +++ b/src/command/cmd/mode.rs @@ -8,7 +8,7 @@ use super::grid::DrillIntoCell; mod tests { use super::*; use crate::command::cmd::test_helpers::*; - use crate::model::Model; + use crate::workbook::Workbook; #[test] fn enter_edit_mode_produces_editing_mode() { @@ -43,7 +43,7 @@ mod tests { #[test] fn enter_tile_select_no_categories() { - let m = Model::new("Empty"); + let m = Workbook::new("Empty"); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); diff --git a/src/command/cmd/navigation.rs b/src/command/cmd/navigation.rs index d9a69b4..d0187f7 100644 --- a/src/command/cmd/navigation.rs +++ b/src/command/cmd/navigation.rs @@ -245,7 +245,7 @@ impl Cmd for PagePrev { /// Gather (cat_name, items, current_idx) for page-axis categories. pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { - let view = ctx.model.active_view(); + let view = ctx.view; let page_cats: Vec = view .categories_on(Axis::Page) .into_iter() diff --git a/src/command/cmd/panel.rs b/src/command/cmd/panel.rs index 82300b7..aaed3e0 100644 --- a/src/command/cmd/panel.rs +++ b/src/command/cmd/panel.rs @@ -158,7 +158,7 @@ mod tests { #[test] fn delete_formula_at_cursor_with_formulas() { let mut m = two_cat_model(); - m.add_formula(crate::formula::ast::Formula { + m.model.add_formula(crate::formula::ast::Formula { raw: "Profit = Revenue - Cost".to_string(), target: "Profit".to_string(), target_category: "Type".to_string(), @@ -529,7 +529,7 @@ impl Cmd for SwitchViewAtCursor { "switch-view-at-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let view_names: Vec = ctx.model.views.keys().cloned().collect(); + let view_names: Vec = ctx.workbook.views.keys().cloned().collect(); if let Some(name) = view_names.get(ctx.view_panel_cursor) { vec![ Box::new(effect::SwitchView(name.clone())), @@ -549,7 +549,7 @@ impl Cmd for CreateAndSwitchView { "create-and-switch-view" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let name = format!("View {}", ctx.model.views.len() + 1); + let name = format!("View {}", ctx.workbook.views.len() + 1); vec![ Box::new(effect::CreateView(name.clone())), Box::new(effect::SwitchView(name)), @@ -567,7 +567,7 @@ impl Cmd for DeleteViewAtCursor { "delete-view-at-cursor" } fn execute(&self, ctx: &CmdContext) -> Vec> { - let view_names: Vec = ctx.model.views.keys().cloned().collect(); + let view_names: Vec = ctx.workbook.views.keys().cloned().collect(); if let Some(name) = view_names.get(ctx.view_panel_cursor) { let mut effects: Vec> = vec![ Box::new(effect::DeleteView(name.clone())), diff --git a/src/command/cmd/registry.rs b/src/command/cmd/registry.rs index 7e61141..d4d2fa4 100644 --- a/src/command/cmd/registry.rs +++ b/src/command/cmd/registry.rs @@ -459,7 +459,7 @@ pub fn default_registry() -> CmdRegistry { let (current, max) = match panel { Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()), Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()), - Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()), + Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()), }; Ok(Box::new(MovePanelCursor { panel, diff --git a/src/command/cmd/search.rs b/src/command/cmd/search.rs index 345bd36..278a644 100644 --- a/src/command/cmd/search.rs +++ b/src/command/cmd/search.rs @@ -55,14 +55,14 @@ mod tests { #[test] fn search_navigate_forward_with_matching_value() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( CellKey::new(vec![ ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]), CellValue::Number(42.0), ); - m.set_cell( + m.model.set_cell( CellKey::new(vec![ ("Type".into(), "Clothing".into()), ("Month".into(), "Feb".into()), diff --git a/src/command/cmd/tile.rs b/src/command/cmd/tile.rs index 699992a..d62e59f 100644 --- a/src/command/cmd/tile.rs +++ b/src/command/cmd/tile.rs @@ -131,7 +131,7 @@ impl Cmd for TileAxisOp { let new_axis = match self.axis { Some(axis) => axis, None => { - let current = ctx.model.active_view().axis_of(name); + let current = ctx.view.axis_of(name); match current { Axis::Row => Axis::Column, Axis::Column => Axis::Page, diff --git a/src/draw.rs b/src/draw.rs index 1d8bcd3..3ec504c 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -16,7 +16,6 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph}, }; -use crate::model::Model; use crate::ui::app::{App, AppMode}; use crate::ui::category_panel::CategoryContent; use crate::ui::formula_panel::FormulaContent; @@ -51,13 +50,13 @@ impl<'a> Drop for TuiContext<'a> { } pub fn run_tui( - model: Model, + workbook: crate::workbook::Workbook, file_path: Option, import_value: Option, ) -> Result<()> { let mut stdout = io::stdout(); let mut tui_context = TuiContext::enter(&mut stdout)?; - let mut app = App::new(model, file_path); + let mut app = App::new(workbook, file_path); if let Some(json) = import_value { app.start_import_wizard(json); @@ -193,7 +192,10 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) { .and_then(|n| n.to_str()) .map(|n| format!(" ({n})")) .unwrap_or_default(); - let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty); + let title = format!( + " improvise · {}{}{} ", + app.workbook.model.name, file, dirty + ); let right = " ?:help :q quit "; let line = fill_line(title, right, area.width); f.render_widget( @@ -234,19 +236,20 @@ 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.model, &app.mode); + let content = FormulaContent::new(&app.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.model, &app.expanded_cats); + let content = + CategoryContent::new(&app.workbook.model, app.workbook.active_view(), &app.expanded_cats); f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a); y += ph; } if app.view_panel_open { let a = Rect::new(side.x, y, side.width, ph); - let content = ViewContent::new(&app.model); + let content = ViewContent::new(&app.workbook); f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a); } } else { @@ -255,7 +258,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { f.render_widget( GridWidget::new( - &app.model, + &app.workbook.model, + app.workbook.active_view(), + &app.workbook.active_view, &app.layout, &app.mode, &app.search_query, @@ -267,7 +272,15 @@ 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.model, &app.mode, app.tile_cat_idx), area); + f.render_widget( + TileBar::new( + &app.workbook.model, + app.workbook.active_view(), + &app.mode, + app.tile_cat_idx, + ), + area, + ); } fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { @@ -306,7 +319,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.model.active_view, yank_indicator); + let view_badge = format!(" {}{} ", app.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/import/wizard.rs b/src/import/wizard.rs index fffca79..9fdee5b 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -6,8 +6,8 @@ use super::analyzer::{ extract_date_component, find_array_paths, }; use crate::formula::parse_formula; -use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; +use crate::workbook::Workbook; // ── Pipeline (no UI state) ──────────────────────────────────────────────────── @@ -80,8 +80,8 @@ impl ImportPipeline { } } - /// Build a Model from the current proposals. Pure — no side effects. - pub fn build_model(&self) -> Result { + /// Build a Workbook from the current proposals. Pure — no side effects. + pub fn build_model(&self) -> Result { let categories: Vec<&FieldProposal> = self .proposals .iter() @@ -128,11 +128,11 @@ impl ImportPipeline { }) .collect(); - let mut model = Model::new(&self.model_name); + let mut wb = Workbook::new(&self.model_name); for cat_proposal in &categories { - model.add_category(&cat_proposal.field)?; - if let Some(cat) = model.category_mut(&cat_proposal.field) { + wb.add_category(&cat_proposal.field)?; + if let Some(cat) = wb.model.category_mut(&cat_proposal.field) { for val in &cat_proposal.distinct_values { cat.add_item(val); } @@ -141,16 +141,16 @@ impl ImportPipeline { // Create derived date-component categories for (_, _, _, derived_name) in &date_extractions { - model.add_category(derived_name)?; + wb.add_category(derived_name)?; } // Create label categories (stored but not pivoted by default) for lab in &labels { - model.add_label_category(&lab.field)?; + wb.add_label_category(&lab.field)?; } if !measures.is_empty() - && let Some(cat) = model.category_mut("_Measure") + && let Some(cat) = wb.model.category_mut("_Measure") { for m in &measures { cat.add_item(&m.field); @@ -170,7 +170,7 @@ impl ImportPipeline { .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string())); if let Some(v) = val { - if let Some(cat) = model.category_mut(&cat_proposal.field) { + if let Some(cat) = wb.model.category_mut(&cat_proposal.field) { cat.add_item(&v); } coords.push((cat_proposal.field.clone(), v.clone())); @@ -180,7 +180,7 @@ impl ImportPipeline { if *field == cat_proposal.field && let Some(derived_val) = extract_date_component(&v, fmt, *comp) { - if let Some(cat) = model.category_mut(derived_name) { + if let Some(cat) = wb.model.category_mut(derived_name) { cat.add_item(&derived_val); } coords.push((derived_name.clone(), derived_val)); @@ -212,7 +212,7 @@ impl ImportPipeline { }) }) .unwrap_or_default(); - if let Some(cat) = model.category_mut(&lab.field) { + if let Some(cat) = wb.model.category_mut(&lab.field) { cat.add_item(&val); } coords.push((lab.field.clone(), val)); @@ -222,7 +222,7 @@ impl ImportPipeline { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { let mut cell_coords = coords.clone(); cell_coords.push(("_Measure".to_string(), measure.field.clone())); - model.set_cell(CellKey::new(cell_coords), CellValue::Number(val)); + wb.model.set_cell(CellKey::new(cell_coords), CellValue::Number(val)); } } } @@ -233,11 +233,11 @@ impl ImportPipeline { let formula_cat: String = "_Measure".to_string(); for raw in &self.formulas { if let Ok(formula) = parse_formula(raw, &formula_cat) { - model.add_formula(formula); + wb.model.add_formula(formula); } } - Ok(model) + Ok(wb) } } @@ -521,7 +521,7 @@ impl ImportWizard { // ── Delegate build to pipeline ──────────────────────────────────────────── - pub fn build_model(&self) -> Result { + pub fn build_model(&self) -> Result { self.pipeline.build_model() } } @@ -616,9 +616,9 @@ mod tests { {"region": "West", "revenue": 200.0}, ]); let p = ImportPipeline::new(raw); - let model = p.build_model().unwrap(); - assert!(model.category("region").is_some()); - assert!(model.category("_Measure").is_some()); + let wb = p.build_model().unwrap(); + assert!(wb.model.category("region").is_some()); + assert!(wb.model.category("_Measure").is_some()); } #[test] @@ -634,9 +634,9 @@ mod tests { assert_eq!(desc.kind, FieldKind::Label); assert!(desc.accepted, "labels should default to accepted"); - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); // Label field exists as a category with Label kind - let cat = model.category("desc").expect("desc category exists"); + let cat = wb.model.category("desc").expect("desc category exists"); assert_eq!(cat.kind, CategoryKind::Label); // Each record's cell key carries the desc label coord use crate::model::cell::CellKey; @@ -645,7 +645,7 @@ mod tests { ("desc".to_string(), "row-7".to_string()), ("region".to_string(), "East".to_string()), ]); - assert_eq!(model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0)); + assert_eq!(wb.model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0)); } #[test] @@ -656,8 +656,8 @@ mod tests { .collect(); let raw = serde_json::Value::Array(records); let p = ImportPipeline::new(raw); - let model = p.build_model().unwrap(); - let v = model.active_view(); + let wb = p.build_model().unwrap(); + let v = wb.active_view(); assert_eq!(v.axis_of("desc"), Axis::None); } @@ -668,7 +668,7 @@ mod tests { {"region": "West", "revenue": 200.0}, ]); let p = ImportPipeline::new(raw); - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); use crate::model::cell::CellKey; let k_east = CellKey::new(vec![ ("_Measure".to_string(), "revenue".to_string()), @@ -679,11 +679,11 @@ mod tests { ("region".to_string(), "West".to_string()), ]); assert_eq!( - model.get_cell(&k_east).and_then(|v| v.as_f64()), + wb.model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0) ); assert_eq!( - model.get_cell(&k_west).and_then(|v| v.as_f64()), + wb.model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0) ); } @@ -703,14 +703,14 @@ mod tests { ]); let mut p = ImportPipeline::new(raw); p.formulas.push("Profit = revenue - cost".to_string()); - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); // The formula should produce Profit = 60 for East (100-40) use crate::model::cell::CellKey; let key = CellKey::new(vec![ ("_Measure".to_string(), "Profit".to_string()), ("region".to_string(), "East".to_string()), ]); - let val = model.evaluate(&key).and_then(|v| v.as_f64()); + let val = wb.model.evaluate(&key).and_then(|v| v.as_f64()); assert_eq!(val, Some(60.0)); } @@ -730,9 +730,9 @@ mod tests { prop.date_components.push(DateComponent::Month); } } - let model = p.build_model().unwrap(); - assert!(model.category("Date_Month").is_some()); - let cat = model.category("Date_Month").unwrap(); + let wb = p.build_model().unwrap(); + assert!(wb.model.category("Date_Month").is_some()); + let cat = wb.model.category("Date_Month").unwrap(); let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect(); assert!(items.contains(&"2025-01")); assert!(items.contains(&"2025-02")); @@ -1046,14 +1046,14 @@ mod tests { {"revenue": 200.0}, // missing "region" ]); let p = ImportPipeline::new(raw); - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); // Only one cell should exist (the East record) use crate::model::cell::CellKey; let k = CellKey::new(vec![ ("_Measure".to_string(), "revenue".to_string()), ("region".to_string(), "East".to_string()), ]); - assert!(model.get_cell(&k).is_some()); + assert!(wb.model.get_cell(&k).is_some()); } #[test] @@ -1067,8 +1067,8 @@ mod tests { {"id": "A", "type": "y", "value": 150.0}, ]); let p = ImportPipeline::new(raw); - let model = p.build_model().unwrap(); - let cat = model.category("id").expect("id should be a category"); + let wb = p.build_model().unwrap(); + let cat = wb.model.category("id").expect("id should be a category"); let items: Vec<&str> = cat.ordered_item_names().into_iter().collect(); assert!(items.contains(&"A")); assert!(items.contains(&"B")); @@ -1085,11 +1085,11 @@ mod tests { ]); let mut p = ImportPipeline::new(raw); p.formulas.push("Test = A + B".to_string()); - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); // Formula should still be added (even if target category is suboptimal) // The formula may fail to parse against a non-measure category, which is OK // Just ensure build_model doesn't panic - assert!(model.category("region").is_some()); + assert!(wb.model.category("region").is_some()); } #[test] @@ -1106,12 +1106,12 @@ mod tests { prop.date_components.push(DateComponent::Month); } } - let model = p.build_model().unwrap(); + let wb = p.build_model().unwrap(); let key = CellKey::new(vec![ ("Date".to_string(), "03/31/2026".to_string()), ("Date_Month".to_string(), "2026-03".to_string()), ("_Measure".to_string(), "Amount".to_string()), ]); - assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0)); + assert_eq!(wb.model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0)); } } diff --git a/src/lib.rs b/src/lib.rs index a96fce3..19986e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,4 @@ pub mod model; pub mod persistence; pub mod ui; pub mod view; +pub mod workbook; diff --git a/src/main.rs b/src/main.rs index b08ee08..7f6339f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ use improvise::command; use improvise::draw; use improvise::import; -use improvise::model; use improvise::persistence; use improvise::ui; use improvise::view; +use improvise::workbook::Workbook; use improvise::import::csv_parser::csv_path_p; @@ -15,7 +15,6 @@ use clap::{Parser, Subcommand}; use enum_dispatch::enum_dispatch; use draw::run_tui; -use model::Model; use serde_json::Value; fn main() -> Result<()> { @@ -122,8 +121,8 @@ struct ScriptArgs { struct OpenTui; impl Runnable for OpenTui { fn run(self, model_file: Option) -> Result<()> { - let model = get_initial_model(&model_file)?; - run_tui(model, model_file, None) + let workbook = get_initial_workbook(&model_file)?; + run_tui(workbook, model_file, None) } } @@ -231,9 +230,9 @@ fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, confi } } -fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) { +fn apply_axis_overrides(wb: &mut Workbook, axes: &[(String, String)]) { use view::Axis; - let view = model.active_view_mut(); + let view = wb.active_view_mut(); for (cat, axis_str) in axes { let axis = match axis_str.to_lowercase().as_str() { "row" => Axis::Row, @@ -254,12 +253,12 @@ fn run_headless_import( ) -> Result<()> { let mut pipeline = import::wizard::ImportPipeline::new(import_value); apply_config_to_pipeline(&mut pipeline, config); - let mut model = pipeline.build_model()?; - model.normalize_view_state(); - apply_axis_overrides(&mut model, &config.axes); + let mut wb = pipeline.build_model()?; + wb.normalize_view_state(); + apply_axis_overrides(&mut wb, &config.axes); if let Some(path) = output.or(model_file) { - persistence::save(&model, &path)?; + persistence::save(&wb, &path)?; eprintln!("Saved to {}", path.display()); } else { eprintln!("No output path specified; use -o or provide a model file"); @@ -272,11 +271,11 @@ fn run_wizard_import( _config: &ImportConfig, model_file: Option, ) -> Result<()> { - let model = get_initial_model(&model_file)?; + let workbook = get_initial_workbook(&model_file)?; // Pre-configure will happen inside the TUI via the wizard // For now, pass import_value and let the wizard handle it // TODO: pass config to wizard for pre-population - run_tui(model, model_file, Some(import_value)) + run_tui(workbook, model_file, Some(import_value)) } // ── Import data loading ────────────────────────────────────────────────────── @@ -331,8 +330,8 @@ fn get_import_data(paths: &[PathBuf]) -> Option { fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> { use crossterm::event::{KeyCode, KeyModifiers}; - let model = get_initial_model(file)?; - let mut app = ui::app::App::new(model, file.clone()); + let workbook = get_initial_workbook(file)?; + let mut app = ui::app::App::new(workbook, file.clone()); let mut exit_code = 0; for line in cmds { @@ -354,7 +353,7 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> } if let Some(path) = file { - persistence::save(&app.model, path)?; + persistence::save(&app.workbook, path)?; } std::process::exit(exit_code); @@ -368,22 +367,22 @@ fn run_headless_script(script_path: &PathBuf, file: &Option) -> Result< // ── Helpers ────────────────────────────────────────────────────────────────── -fn get_initial_model(file_path: &Option) -> Result { +fn get_initial_workbook(file_path: &Option) -> Result { if let Some(path) = file_path { if path.exists() { - let mut m = persistence::load(path) + let mut wb = persistence::load(path) .with_context(|| format!("Failed to load {}", path.display()))?; - m.normalize_view_state(); - Ok(m) + wb.normalize_view_state(); + Ok(wb) } else { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("New Model") .to_string(); - Ok(Model::new(name)) + Ok(Workbook::new(name)) } } else { - Ok(Model::new("New Model")) + Ok(Workbook::new("New Model")) } } diff --git a/src/model/types.rs b/src/model/types.rs index 4c44360..96787e2 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -7,18 +7,24 @@ use serde::{Deserialize, Serialize}; use super::category::{Category, CategoryId}; use super::cell::{CellKey, CellValue, DataStore}; use crate::formula::{AggFunc, Formula}; -use crate::view::View; const MAX_CATEGORIES: usize = 12; +/// Pure-data document model: categories, cells, and formulas. +/// +/// `Model` intentionally does **not** know about views. The view axes and +/// per-view state live in [`crate::workbook::Workbook`], which wraps a +/// `Model` with the view ensemble. Cross-slice operations — adding a +/// category and registering it on every view, for example — are therefore +/// methods on `Workbook`, not `Model`. This breaks the former `Model ↔ View` +/// cycle so the `model/` and `view/` modules can be lifted into a shared +/// `improvise-core` crate without pulling view code into pure data types. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Model { pub name: String, pub categories: IndexMap, pub data: DataStore, formulas: Vec, - pub views: IndexMap, - pub active_view: String, next_category_id: CategoryId, /// Per-measure aggregation function (measure item name → agg func). /// Used when collapsing categories on `Axis::None`. Defaults to SUM. @@ -33,11 +39,8 @@ impl Model { pub fn new(name: impl Into) -> Self { use crate::model::category::CategoryKind; let name = name.into(); - let default_view = View::new("Default"); - let mut views = IndexMap::new(); - views.insert("Default".to_string(), default_view); let mut categories = IndexMap::new(); - // Virtual categories — always present, default to Axis::None + // Virtual categories — always present. categories.insert( "_Index".to_string(), Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex), @@ -50,30 +53,20 @@ impl Model { "_Measure".to_string(), Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure), ); - let mut m = Self { + Self { name, categories, data: DataStore::new(), formulas: Vec::new(), - views, - active_view: "Default".to_string(), next_category_id: 3, measure_agg: HashMap::new(), formula_cache: HashMap::new(), - }; - // Add virtuals to existing views (default view). - // Start in records mode; on_category_added will reclaim Row/Column - // for the first two regular categories. - for view in m.views.values_mut() { - view.on_category_added("_Index"); - view.on_category_added("_Dim"); - view.on_category_added("_Measure"); - view.set_axis("_Index", crate::view::Axis::Row); - view.set_axis("_Dim", crate::view::Axis::Column); } - m } + /// Add a pivot category. Enforces the `MAX_CATEGORIES` limit for regular + /// categories. The caller (typically [`crate::workbook::Workbook`]) is + /// responsible for registering the new category on every view. pub fn add_category(&mut self, name: impl Into) -> Result { let name = name.into(); // Only regular pivot categories count against the limit. @@ -92,19 +85,15 @@ impl Model { self.next_category_id += 1; self.categories .insert(name.clone(), Category::new(id, name.clone())); - // Add to all views - for view in self.views.values_mut() { - view.on_category_added(&name); - } Ok(id) } /// Add a Label-kind category: stored alongside regular categories so - /// records views can display it, but default to `Axis::None` and - /// excluded from the pivot-category count limit. + /// records views can display it, but excluded from the pivot-category + /// count limit. The caller is responsible for setting the view axis + /// (typically to `Axis::None`). pub fn add_label_category(&mut self, name: impl Into) -> Result { use crate::model::category::CategoryKind; - use crate::view::Axis; let name = name.into(); if self.categories.contains_key(&name) { return Ok(self.categories[&name].id); @@ -113,23 +102,17 @@ impl Model { self.next_category_id += 1; let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label); self.categories.insert(name.clone(), cat); - for view in self.views.values_mut() { - view.on_category_added(&name); - view.set_axis(&name, Axis::None); - } Ok(id) } - /// Remove a category and all cells that reference it. + /// Remove a category and all cells that reference it. The caller is + /// responsible for removing the category from any views that referenced + /// it. pub fn remove_category(&mut self, name: &str) { if !self.categories.contains_key(name) { return; } self.categories.shift_remove(name); - // Remove from all views - for view in self.views.values_mut() { - view.on_category_removed(name); - } // Remove cells that have a coord in this category let to_remove: Vec = self .data @@ -208,59 +191,6 @@ impl Model { &self.formulas } - pub fn active_view(&self) -> &View { - self.views - .get(&self.active_view) - .expect("active_view always names an existing view") - } - - pub fn active_view_mut(&mut self) -> &mut View { - self.views - .get_mut(&self.active_view) - .expect("active_view always names an existing view") - } - - pub fn create_view(&mut self, name: impl Into) -> &mut View { - let name = name.into(); - let mut view = View::new(name.clone()); - // Copy category assignments from default if any - for cat_name in self.categories.keys() { - view.on_category_added(cat_name); - } - self.views.insert(name.clone(), view); - self.views.get_mut(&name).unwrap() - } - - pub fn switch_view(&mut self, name: &str) -> Result<()> { - if self.views.contains_key(name) { - self.active_view = name.to_string(); - Ok(()) - } else { - Err(anyhow!("View '{name}' not found")) - } - } - - pub fn delete_view(&mut self, name: &str) -> Result<()> { - if self.views.len() <= 1 { - return Err(anyhow!("Cannot delete the last view")); - } - self.views.shift_remove(name); - if self.active_view == name { - self.active_view = self.views.keys().next().unwrap().clone(); - } - Ok(()) - } - - /// Reset all view scroll offsets to zero. - /// Call this after loading or replacing a model so stale offsets don't - /// cause the grid to render an empty area. - pub fn normalize_view_state(&mut self) { - for view in self.views.values_mut() { - view.row_offset = 0; - view.col_offset = 0; - } - } - /// Return all category names /// Names of all categories (including virtual ones). pub fn category_names(&self) -> Vec<&str> { @@ -848,7 +778,6 @@ impl Model { mod model_tests { use super::Model; use crate::model::cell::{CellKey, CellValue}; - use crate::view::Axis; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( @@ -859,13 +788,6 @@ mod model_tests { ) } - #[test] - fn new_model_has_default_view() { - let m = Model::new("Test"); - // active_view() panics if missing; this test just ensures it doesn't panic - let _ = m.active_view(); - } - #[test] fn add_category_creates_entry() { let mut m = Model::new("Test"); @@ -892,14 +814,6 @@ mod model_tests { assert!(m.add_category("TooMany").is_err()); } - #[test] - fn add_category_notifies_existing_views() { - let mut m = Model::new("Test"); - m.add_category("Region").unwrap(); - // axis_of panics for unknown categories; not panicking here confirms it was registered - let _ = m.active_view().axis_of("Region"); - } - #[test] fn set_and_get_cell_roundtrip() { let mut m = Model::new("Test"); @@ -981,80 +895,6 @@ mod model_tests { 0, "all cells with Region coord should be removed" ); - // Views should no longer know about Region - // (axis_of would panic for unknown category, so check categories_on) - let v = m.active_view(); - assert!(v.categories_on(crate::view::Axis::Row).is_empty()); - } - - #[test] - fn create_view_copies_category_structure() { - let mut m = Model::new("Test"); - m.add_category("Region").unwrap(); - m.add_category("Product").unwrap(); - m.create_view("Secondary"); - let v = m.views.get("Secondary").unwrap(); - // axis_of panics for unknown categories; not panicking confirms categories were registered - let _ = v.axis_of("Region"); - let _ = v.axis_of("Product"); - } - - #[test] - fn switch_view_changes_active_view() { - let mut m = Model::new("Test"); - m.create_view("Other"); - m.switch_view("Other").unwrap(); - assert_eq!(m.active_view, "Other"); - } - - #[test] - fn switch_view_unknown_returns_error() { - let mut m = Model::new("Test"); - assert!(m.switch_view("NoSuchView").is_err()); - } - - #[test] - fn delete_view_removes_it() { - let mut m = Model::new("Test"); - m.create_view("Extra"); - m.delete_view("Extra").unwrap(); - assert!(!m.views.contains_key("Extra")); - } - - #[test] - fn delete_last_view_returns_error() { - let mut m = Model::new("Test"); - assert!(m.delete_view("Default").is_err()); - } - - #[test] - fn delete_active_view_switches_to_another() { - let mut m = Model::new("Test"); - m.create_view("Other"); - m.switch_view("Other").unwrap(); - m.delete_view("Other").unwrap(); - assert_ne!(m.active_view, "Other"); - } - - #[test] - fn first_category_goes_to_row_second_to_column_rest_to_page() { - let mut m = Model::new("Test"); - m.add_category("Region").unwrap(); - m.add_category("Product").unwrap(); - m.add_category("Time").unwrap(); - let v = m.active_view(); - assert_eq!(v.axis_of("Region"), Axis::Row); - assert_eq!(v.axis_of("Product"), Axis::Column); - assert_eq!(v.axis_of("Time"), Axis::Page); - } - - #[test] - fn data_is_shared_across_views() { - let mut m = Model::new("Test"); - m.create_view("Second"); - let k = coord(&[("Region", "East")]); - m.set_cell(k.clone(), CellValue::Number(77.0)); - assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0))); } #[test] @@ -1702,6 +1542,7 @@ mod five_category { use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; + use crate::workbook::Workbook; const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[ ("East", "Shirts", "Online", "Q1", 1_000.0, 600.0), @@ -1772,6 +1613,52 @@ mod five_category { m } + /// Build a Workbook whose model matches `build_model()`. Used by the + /// view-management tests in this module: view state lives on Workbook, + /// not Model, so those tests need the wrapper. + fn build_workbook() -> Workbook { + let mut wb = Workbook::new("Sales"); + for cat in ["Region", "Product", "Channel", "Time", "_Measure"] { + wb.add_category(cat).unwrap(); + } + for cat in ["Region", "Product", "Channel", "Time"] { + let items: &[&str] = match cat { + "Region" => &["East", "West"], + "Product" => &["Shirts", "Pants"], + "Channel" => &["Online", "Retail"], + "Time" => &["Q1", "Q2"], + _ => &[], + }; + if let Some(c) = wb.model.category_mut(cat) { + for &item in items { + c.add_item(item); + } + } + } + if let Some(c) = wb.model.category_mut("_Measure") { + for &item in &["Revenue", "Cost", "Profit", "Margin", "Total"] { + c.add_item(item); + } + } + for &(region, product, channel, time, rev, cost) in DATA { + wb.model.set_cell( + coord(region, product, channel, time, "Revenue"), + CellValue::Number(rev), + ); + wb.model.set_cell( + coord(region, product, channel, time, "Cost"), + CellValue::Number(cost), + ); + } + wb.model + .add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); + wb.model + .add_formula(parse_formula("Margin = Profit / Revenue", "_Measure").unwrap()); + wb.model + .add_formula(parse_formula("Total = SUM(Revenue)", "_Measure").unwrap()); + wb + } + fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } @@ -1938,8 +1825,8 @@ mod five_category { #[test] fn default_view_first_two_on_axes_rest_on_page() { - let m = build_model(); - let v = m.active_view(); + let wb = build_workbook(); + let v = wb.active_view(); assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); assert_eq!(v.axis_of("Channel"), Axis::Page); @@ -1949,9 +1836,9 @@ mod five_category { #[test] fn rearranging_axes_does_not_affect_data() { - let mut m = build_model(); + let mut wb = build_workbook(); { - let v = m.active_view_mut(); + let v = wb.active_view_mut(); v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("Channel", Axis::Row); @@ -1959,44 +1846,48 @@ mod five_category { v.set_axis("_Measure", Axis::Page); } assert_eq!( - m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), + wb.model + .get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")), Some(&CellValue::Number(1_000.0)) ); } #[test] fn two_views_have_independent_axis_assignments() { - let mut m = build_model(); - m.create_view("Pivot"); + let mut wb = build_workbook(); + wb.create_view("Pivot"); { - let v = m.views.get_mut("Pivot").unwrap(); + let v = wb.views.get_mut("Pivot").unwrap(); v.set_axis("Time", Axis::Row); v.set_axis("Channel", Axis::Column); v.set_axis("Region", Axis::Page); v.set_axis("Product", Axis::Page); v.set_axis("_Measure", Axis::Page); } - assert_eq!(m.views.get("Default").unwrap().axis_of("Region"), Axis::Row); - assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); assert_eq!( - m.views.get("Pivot").unwrap().axis_of("Channel"), + wb.views.get("Default").unwrap().axis_of("Region"), + Axis::Row + ); + assert_eq!(wb.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row); + assert_eq!( + wb.views.get("Pivot").unwrap().axis_of("Channel"), Axis::Column ); } #[test] fn page_selections_are_per_view() { - let mut m = build_model(); - m.create_view("West only"); - if let Some(v) = m.views.get_mut("West only") { + let mut wb = build_workbook(); + wb.create_view("West only"); + if let Some(v) = wb.views.get_mut("West only") { v.set_page_selection("Region", "West"); } assert_eq!( - m.views.get("Default").unwrap().page_selection("Region"), + wb.views.get("Default").unwrap().page_selection("Region"), None ); assert_eq!( - m.views.get("West only").unwrap().page_selection("Region"), + wb.views.get("West only").unwrap().page_selection("Region"), Some("West") ); } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 60bf42e..9d618d0 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -8,10 +8,10 @@ use std::io::{BufReader, BufWriter, Read, Write}; use std::path::Path; use crate::formula::parse_formula; -use crate::model::Model; use crate::model::category::Group; use crate::model::cell::{CellKey, CellValue}; use crate::view::{Axis, GridLayout}; +use crate::workbook::Workbook; #[derive(Parser)] #[grammar = "persistence/improv.pest"] @@ -112,8 +112,8 @@ fn is_gzip(path: &Path) -> bool { path.to_str().is_some_and(|s| s.ends_with(".gz")) } -pub fn save(model: &Model, path: &Path) -> Result<()> { - let text = format_md(model); +pub fn save(workbook: &Workbook, path: &Path) -> Result<()> { + let text = format_md(workbook); if is_gzip(path) { let file = std::fs::File::create(path) .with_context(|| format!("Cannot create {}", path.display()))?; @@ -126,7 +126,7 @@ pub fn save(model: &Model, path: &Path) -> Result<()> { Ok(()) } -pub fn load(path: &Path) -> Result { +pub fn load(path: &Path) -> Result { let file = std::fs::File::open(path).with_context(|| format!("Cannot open {}", path.display()))?; let text = if is_gzip(path) { @@ -139,7 +139,7 @@ pub fn load(path: &Path) -> Result { s }; if text.trim_start().starts_with('{') { - serde_json::from_str(&text).context("Failed to deserialize model") + serde_json::from_str(&text).context("Failed to deserialize workbook") } else { parse_md(&text) } @@ -152,21 +152,23 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf { p } -/// Serialize a model to the markdown `.improv` format. -pub fn format_md(model: &Model) -> String { +/// Serialize a workbook to the markdown `.improv` format. +pub fn format_md(workbook: &Workbook) -> String { // writeln! to a String is infallible; this macro avoids .unwrap() noise. macro_rules! w { ($dst:expr, $($arg:tt)*) => { { use std::fmt::Write; writeln!($dst, $($arg)*).ok(); } } } + let model = &workbook.model; + let mut out = String::new(); w!(out, "v2025-04-09"); w!(out, "# {}", model.name); - w!(out, "Initial View: {}", model.active_view); + w!(out, "Initial View: {}", workbook.active_view); // ── Views (first: typically small, orients the reader) ─────────── - for (_view_name, view) in &model.views { + for (_view_name, view) in &workbook.views { w!(out, "\n## View: {}", view.name); for (cat, axis) in &view.category_axes { let qcat = quote_name(cat); @@ -284,7 +286,7 @@ pub fn format_md(model: &Model) -> String { /// /// Sections may appear in any order; a two-pass approach registers categories /// before configuring views. -pub fn parse_md(text: &str) -> Result { +pub fn parse_md(text: &str) -> Result { use anyhow::bail; use pest::iterators::{Pair, Pairs}; @@ -494,14 +496,15 @@ pub fn parse_md(text: &str) -> Result { } } - // ── Pass 2: build the Model ───────────────────────────────────────────── + // ── Pass 2: build the Workbook ────────────────────────────────────────── let name = model_name.ok_or_else(|| anyhow::anyhow!("Missing model title (# Name)"))?; - let mut m = Model::new(&name); + let mut wb = Workbook::new(&name); for pc in &categories { - m.add_category(&pc.name)?; - let cat = m + wb.add_category(&pc.name)?; + let cat = wb + .model .category_mut(&pc.name) .ok_or_else(|| anyhow::anyhow!("Category '{}' not found after add", pc.name))?; for (item_name, group) in &pc.items { @@ -526,10 +529,10 @@ pub fn parse_md(text: &str) -> Result { } for pv in &views { - if !m.views.contains_key(&pv.name) { - m.create_view(&pv.name); + if !wb.views.contains_key(&pv.name) { + wb.create_view(&pv.name); } - let view = m + let view = wb .views .get_mut(&pv.name) .ok_or_else(|| anyhow::anyhow!("View '{}' not found after create", pv.name))?; @@ -551,19 +554,20 @@ pub fn parse_md(text: &str) -> Result { } if let Some(iv) = &initial_view - && m.views.contains_key(iv) + && wb.views.contains_key(iv) { - m.active_view = iv.clone(); + wb.active_view = iv.clone(); } for (raw, cat_name) in &formulas { - m.add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?); + wb.model + .add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?); } for (key, value) in data { - m.set_cell(key, value); + wb.model.set_cell(key, value); } - Ok(m) + Ok(wb) } fn coord_str(key: &CellKey) -> String { @@ -574,13 +578,18 @@ fn coord_str(key: &CellKey) -> String { .join(", ") } -pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { - let view = model +pub fn export_csv( + workbook: &Workbook, + view_name: &str, + path: &Path, +) -> Result<()> { + let view = workbook .views .get(view_name) .ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?; - let layout = GridLayout::new(model, view); + let layout = GridLayout::new(&workbook.model, view); + let model = &workbook.model; let mut out = String::new(); @@ -628,10 +637,10 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { mod tests { use super::{format_md, parse_md}; use crate::formula::parse_formula; - use crate::model::Model; use crate::model::category::Group; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; + use crate::workbook::Workbook; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( @@ -642,15 +651,15 @@ mod tests { ) } - fn two_cat_model() -> Model { - let mut m = Model::new("Budget"); + fn two_cat_model() -> Workbook { + let mut m = Workbook::new("Budget"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); for item in ["Food", "Gas"] { - m.category_mut("Type").unwrap().add_item(item); + m.model.category_mut("Type").unwrap().add_item(item); } for item in ["Jan", "Feb"] { - m.category_mut("Month").unwrap().add_item(item); + m.model.category_mut("Month").unwrap().add_item(item); } m } @@ -659,7 +668,7 @@ mod tests { #[test] fn format_md_contains_model_name() { - let m = Model::new("My Model"); + let m = Workbook::new("My Model"); assert!(format_md(&m).contains("# My Model")); } @@ -679,9 +688,10 @@ mod tests { #[test] fn format_md_item_with_group_uses_brackets() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); let text = format_md(&m); @@ -690,12 +700,14 @@ mod tests { #[test] fn format_md_group_hierarchy_uses_angle_prefix() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_group(Group::new("Q1").with_parent("2025")); let text = format_md(&m); @@ -705,11 +717,11 @@ mod tests { #[test] fn format_md_data_is_sorted_and_quoted() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Month", "Feb"), ("Type", "Food")]), CellValue::Number(200.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Month", "Jan"), ("Type", "Gas")]), CellValue::Text("N/A".into()), ); @@ -739,14 +751,14 @@ mod tests { #[test] fn format_md_page_axis_with_selection() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); + m.model.category_mut("Type").unwrap().add_item("Food"); + m.model.category_mut("Month").unwrap().add_item("Jan"); for r in ["East", "West"] { - m.category_mut("Region").unwrap().add_item(r); + m.model.category_mut("Region").unwrap().add_item(r); } m.active_view_mut().set_page_selection("Region", "West"); let text = format_md(&m); @@ -756,8 +768,9 @@ mod tests { #[test] fn format_md_formula_includes_category() { let mut m = two_cat_model(); - m.category_mut("Type").unwrap().add_item("Total"); - m.add_formula(parse_formula("Total = Food + Gas", "Type").unwrap()); + m.model.category_mut("Type").unwrap().add_item("Total"); + m.model + .add_formula(parse_formula("Total = Food + Gas", "Type").unwrap()); let text = format_md(&m); assert!(text.contains("- Total = Food + Gas [Type]"), "got:\n{text}"); } @@ -766,8 +779,8 @@ mod tests { #[test] fn parse_md_round_trips_model_name() { - let m = Model::new("My Budget"); - assert_eq!(parse_md(&format_md(&m)).unwrap().name, "My Budget"); + let m = Workbook::new("My Budget"); + assert_eq!(parse_md(&format_md(&m)).unwrap().model.name, "My Budget"); } #[test] @@ -775,12 +788,14 @@ mod tests { let m = two_cat_model(); let m2 = parse_md(&format_md(&m)).unwrap(); assert!( - m2.category("Type") + m2.model + .category("Type") .and_then(|c| c.items.get("Food")) .is_some() ); assert!( - m2.category("Month") + m2.model + .category("Month") .and_then(|c| c.items.get("Feb")) .is_some() ); @@ -788,14 +803,16 @@ mod tests { #[test] fn parse_md_round_trips_item_group() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); let m2 = parse_md(&format_md(&m)).unwrap(); assert_eq!( - m2.category("Month") + m2.model + .category("Month") .and_then(|c| c.items.get("Jan")) .and_then(|i| i.group.as_deref()), Some("Q1") @@ -804,16 +821,18 @@ mod tests { #[test] fn parse_md_round_trips_group_hierarchy() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Month").unwrap(); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_group(Group::new("Q1").with_parent("2025")); let m2 = parse_md(&format_md(&m)).unwrap(); - let groups = &m2.category("Month").unwrap().groups; + let groups = &m2.model.category("Month").unwrap().groups; let q1 = groups.iter().find(|g| g.name == "Q1").unwrap(); assert_eq!(q1.parent.as_deref(), Some("2025")); } @@ -821,21 +840,23 @@ mod tests { #[test] fn parse_md_round_trips_data_cells() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Month", "Jan"), ("Type", "Food")]), CellValue::Number(100.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Month", "Feb"), ("Type", "Gas")]), CellValue::Text("N/A".into()), ); let m2 = parse_md(&format_md(&m)).unwrap(); assert_eq!( - m2.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), + m2.model + .get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), Some(&CellValue::Number(100.0)) ); assert_eq!( - m2.get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])), + m2.model + .get_cell(&coord(&[("Month", "Feb"), ("Type", "Gas")])), Some(&CellValue::Text("N/A".into())) ); } @@ -851,14 +872,14 @@ mod tests { #[test] fn parse_md_round_trips_page_selection() { - let mut m = Model::new("T"); + let mut m = Workbook::new("T"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); + m.model.category_mut("Type").unwrap().add_item("Food"); + m.model.category_mut("Month").unwrap().add_item("Jan"); for r in ["East", "West"] { - m.category_mut("Region").unwrap().add_item(r); + m.model.category_mut("Region").unwrap().add_item(r); } m.active_view_mut().set_page_selection("Region", "West"); let m2 = parse_md(&format_md(&m)).unwrap(); @@ -871,10 +892,11 @@ mod tests { #[test] fn parse_md_round_trips_formula() { let mut m = two_cat_model(); - m.category_mut("Type").unwrap().add_item("Total"); - m.add_formula(parse_formula("Total = Food + Gas", "Type").unwrap()); + m.model.category_mut("Type").unwrap().add_item("Total"); + m.model + .add_formula(parse_formula("Total = Food + Gas", "Type").unwrap()); let m2 = parse_md(&format_md(&m)).unwrap(); - let f = &m2.formulas()[0]; + let f = &m2.model.formulas()[0]; assert_eq!(f.raw, "Total = Food + Gas"); assert_eq!(f.target_category, "Type"); } @@ -944,7 +966,8 @@ mod tests { Month: column\n"; let m = parse_md(text).unwrap(); assert_eq!( - m.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), + m.model + .get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])), Some(&CellValue::Number(42.0)) ); } @@ -956,8 +979,8 @@ mod tests { let json = serde_json::to_string_pretty(&m).unwrap(); assert!(json.trim_start().starts_with('{'), "sanity check"); // Deserialise via the JSON path - let m2: Model = serde_json::from_str(&json).unwrap(); - assert_eq!(m2.name, "Budget"); + let m2: Workbook = serde_json::from_str(&json).unwrap(); + assert_eq!(m2.model.name, "Budget"); } // ── save/load roundtrip via file ──────────────────────────────────── @@ -965,7 +988,7 @@ mod tests { #[test] fn save_and_load_roundtrip_plain() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(42.0), ); @@ -973,9 +996,11 @@ mod tests { let path = dir.path().join("test.improv"); super::save(&m, &path).unwrap(); let loaded = super::load(&path).unwrap(); - assert_eq!(loaded.name, "Budget"); + assert_eq!(loaded.model.name, "Budget"); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Number(42.0)) ); } @@ -983,7 +1008,7 @@ mod tests { #[test] fn save_and_load_roundtrip_gzip() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Gas"), ("Month", "Feb")]), CellValue::Number(99.0), ); @@ -991,9 +1016,11 @@ mod tests { let path = dir.path().join("test.improv.gz"); super::save(&m, &path).unwrap(); let loaded = super::load(&path).unwrap(); - assert_eq!(loaded.name, "Budget"); + assert_eq!(loaded.model.name, "Budget"); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), + loaded + .model + .get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), Some(&CellValue::Number(99.0)) ); } @@ -1068,8 +1095,8 @@ mod tests { Type=Food = 42 "#; let m = parse_md(text).unwrap(); - assert_eq!(m.name, "Test Model"); - assert!(m.category("Type").is_some()); + assert_eq!(m.model.name, "Test Model"); + assert!(m.model.category("Type").is_some()); } // ── parse_md: text values ─────────────────────────────────────────── @@ -1077,14 +1104,16 @@ Type=Food = 42 #[test] fn parse_md_round_trips_text_cell_values() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("pending".to_string()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("pending".to_string())) ); } @@ -1147,11 +1176,11 @@ Type=Food = 42 #[test] fn export_csv_produces_valid_output() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Gas"), ("Month", "Feb")]), CellValue::Number(50.0), ); @@ -1184,17 +1213,17 @@ Type=Food = 42 fn full_roundtrip_preserves_all_features() { let mut m = two_cat_model(); // Add data - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Gas"), ("Month", "Feb")]), CellValue::Text("pending".to_string()), ); // Add formula let f = parse_formula("Gas = Food * 2", "Type").unwrap(); - m.add_formula(f); + m.model.add_formula(f); // Configure view m.active_view_mut().set_axis("Month", Axis::Page); m.active_view_mut().set_page_selection("Month", "Jan"); @@ -1206,16 +1235,20 @@ Type=Food = 42 let loaded = parse_md(&text).unwrap(); // Verify everything roundtripped - assert_eq!(loaded.name, "Budget"); + assert_eq!(loaded.model.name, "Budget"); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Number(100.0)) ); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), + loaded + .model + .get_cell(&coord(&[("Type", "Gas"), ("Month", "Feb")])), Some(&CellValue::Text("pending".to_string())) ); - assert!(!loaded.formulas().is_empty()); + assert!(!loaded.model.formulas().is_empty()); let v = loaded.active_view(); assert_eq!(v.axis_of("Month"), Axis::Page); assert_eq!(v.page_selection("Month"), Some("Jan")); @@ -1229,14 +1262,16 @@ Type=Food = 42 #[test] fn text_value_with_embedded_comma() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("Smith, Jr.".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("Smith, Jr.".into())), "Comma inside quoted text was corrupted.\n{text}" ); @@ -1245,14 +1280,16 @@ Type=Food = 42 #[test] fn text_value_with_embedded_double_quote() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text(r#"He said "hello""#.into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text(r#"He said "hello""#.into())), "Embedded double quotes were corrupted.\n{text}" ); @@ -1261,14 +1298,16 @@ Type=Food = 42 #[test] fn text_value_with_equals_space_sequence() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("x = y".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("x = y".into())), "Text containing ' = ' was corrupted.\n{text}" ); @@ -1277,14 +1316,16 @@ Type=Food = 42 #[test] fn text_value_is_single_double_quote() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("\"".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("\"".into())), "Single double-quote text was corrupted.\n{text}" ); @@ -1293,14 +1334,16 @@ Type=Food = 42 #[test] fn text_value_is_empty_string() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("".into())), "Empty string was corrupted.\n{text}" ); @@ -1309,14 +1352,16 @@ Type=Food = 42 #[test] fn text_value_with_newline() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("line1\nline2".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("line1\nline2".into())), "Newline in text was corrupted.\n{text}" ); @@ -1325,14 +1370,16 @@ Type=Food = 42 #[test] fn text_value_looks_like_number() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("42".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("42".into())), "Numeric-looking text was converted to Number.\n{text}" ); @@ -1341,14 +1388,16 @@ Type=Food = 42 #[test] fn text_value_with_hash_prefix() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Text("#NotAHeader".into()), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "Food"), ("Month", "Jan")])), Some(&CellValue::Text("#NotAHeader".into())), "Hash-prefixed text was misinterpreted.\n{text}" ); @@ -1356,15 +1405,18 @@ Type=Food = 42 #[test] fn item_name_with_brackets_misinterpreted_as_group() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type").unwrap().add_item("Item [special]"); + m.model + .category_mut("Type") + .unwrap() + .add_item("Item [special]"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item("Jan"); + m.model.category_mut("Month").unwrap().add_item("Jan"); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let cat = loaded.category("Type").unwrap(); + let cat = loaded.model.category("Type").unwrap(); let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); assert!( item_names.contains(&"Item [special]"), @@ -1375,12 +1427,15 @@ Type=Food = 42 #[test] fn category_name_with_comma_space_in_data() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Income, Gross").unwrap(); - m.category_mut("Income, Gross").unwrap().add_item("A"); + m.model + .category_mut("Income, Gross") + .unwrap() + .add_item("A"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item("Jan"); - m.set_cell( + m.model.category_mut("Month").unwrap().add_item("Jan"); + m.model.set_cell( coord(&[("Income, Gross", "A"), ("Month", "Jan")]), CellValue::Number(100.0), ); @@ -1388,7 +1443,9 @@ Type=Food = 42 let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Income, Gross", "A"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Income, Gross", "A"), ("Month", "Jan")])), Some(&CellValue::Number(100.0)), "Category name with comma-space broke coord parsing.\n{text}" ); @@ -1396,12 +1453,12 @@ Type=Food = 42 #[test] fn item_name_with_equals_sign_in_data() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type").unwrap().add_item("A=B"); + m.model.category_mut("Type").unwrap().add_item("A=B"); m.add_category("Month").unwrap(); - m.category_mut("Month").unwrap().add_item("Jan"); - m.set_cell( + m.model.category_mut("Month").unwrap().add_item("Jan"); + m.model.set_cell( coord(&[("Type", "A=B"), ("Month", "Jan")]), CellValue::Number(50.0), ); @@ -1409,7 +1466,9 @@ Type=Food = 42 let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Type", "A=B"), ("Month", "Jan")])), + loaded + .model + .get_cell(&coord(&[("Type", "A=B"), ("Month", "Jan")])), Some(&CellValue::Number(50.0)), "Item name with '=' broke coord parsing.\n{text}" ); @@ -1417,9 +1476,9 @@ Type=Food = 42 #[test] fn view_name_with_parentheses() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type").unwrap().add_item("A"); + m.model.category_mut("Type").unwrap().add_item("A"); m.create_view("My View (v2)"); let text = format_md(&m); @@ -1433,13 +1492,13 @@ Type=Food = 42 #[test] fn multiple_tricky_text_cells() { - let mut m = Model::new("EdgeCases"); + let mut m = Workbook::new("EdgeCases"); m.add_category("Dim").unwrap(); for item in ["A", "B", "C", "D"] { - m.category_mut("Dim").unwrap().add_item(item); + m.model.category_mut("Dim").unwrap().add_item(item); } m.add_category("Msr").unwrap(); - m.category_mut("Msr").unwrap().add_item("Val"); + m.model.category_mut("Msr").unwrap().add_item("Val"); let cases: Vec<(&str, CellValue)> = vec![ ("A", CellValue::Text("hello, world".into())), @@ -1449,14 +1508,17 @@ Type=Food = 42 ]; for (item, value) in &cases { - m.set_cell(coord(&[("Dim", item), ("Msr", "Val")]), value.clone()); + m.model + .set_cell(coord(&[("Dim", item), ("Msr", "Val")]), value.clone()); } let text = format_md(&m); let loaded = parse_md(&text).unwrap(); for (item, expected) in &cases { - let got = loaded.get_cell(&coord(&[("Dim", item), ("Msr", "Val")])); + let got = loaded + .model + .get_cell(&coord(&[("Dim", item), ("Msr", "Val")])); assert_eq!( got, Some(expected), @@ -1471,8 +1533,8 @@ Type=Food = 42 #[cfg(test)] mod parser_prop_tests { use super::{format_md, parse_md}; - use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; + use crate::workbook::Workbook; use proptest::prelude::*; fn coord(pairs: &[(&str, &str)]) -> CellKey { @@ -1517,13 +1579,13 @@ mod parser_prop_tests { ] } - fn arbitrary_model() -> impl Strategy { + fn arbitrary_model() -> impl Strategy { let items1 = prop::collection::hash_set(safe_ident(), 1..=4); let items2 = prop::collection::hash_set(safe_ident(), 1..=4); let values = prop::collection::vec(cell_value(), 1..=8); (safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| { - let mut m = Model::new(&name); + let mut m = Workbook::new(&name); m.add_category("CatA").unwrap(); m.add_category("CatB").unwrap(); @@ -1531,16 +1593,17 @@ mod parser_prop_tests { let items2: Vec<_> = items2.into_iter().collect(); for item in &items1 { - m.category_mut("CatA").unwrap().add_item(item); + m.model.category_mut("CatA").unwrap().add_item(item); } for item in &items2 { - m.category_mut("CatB").unwrap().add_item(item); + m.model.category_mut("CatB").unwrap().add_item(item); } for (i, value) in values.into_iter().enumerate() { let a = &items1[i % items1.len()]; let b = &items2[i % items2.len()]; - m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value); + m.model + .set_cell(coord(&[("CatA", a), ("CatB", b)]), value); } m @@ -1552,10 +1615,10 @@ mod parser_prop_tests { #[test] fn roundtrip_preserves_model_name(name in safe_ident()) { - let m = Model::new(&name); + let m = Workbook::new(&name); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - prop_assert_eq!(loaded.name, name); + prop_assert_eq!(loaded.model.name, name); } #[test] @@ -1563,24 +1626,24 @@ mod parser_prop_tests { items1 in prop::collection::hash_set(safe_ident(), 1..=5), items2 in prop::collection::hash_set(safe_ident(), 1..=5), ) { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Alpha").unwrap(); m.add_category("Beta").unwrap(); for item in &items1 { - m.category_mut("Alpha").unwrap().add_item(item); + m.model.category_mut("Alpha").unwrap().add_item(item); } for item in &items2 { - m.category_mut("Beta").unwrap().add_item(item); + m.model.category_mut("Beta").unwrap().add_item(item); } let text = format_md(&m); let loaded = parse_md(&text).unwrap(); let loaded_alpha: std::collections::HashSet = loaded - .category("Alpha").unwrap() + .model.category("Alpha").unwrap() .items.values().map(|i| i.name.clone()).collect(); let loaded_beta: std::collections::HashSet = loaded - .category("Beta").unwrap() + .model.category("Beta").unwrap() .items.values().map(|i| i.name.clone()).collect(); prop_assert_eq!(loaded_alpha, items1); @@ -1600,19 +1663,19 @@ mod parser_prop_tests { /// Tricky text values survive round-trip as cell values. #[test] fn tricky_text_value_roundtrips(text_val in tricky_text()) { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Dim").unwrap(); - m.category_mut("Dim").unwrap().add_item("A"); + m.model.category_mut("Dim").unwrap().add_item("A"); m.add_category("Msr").unwrap(); - m.category_mut("Msr").unwrap().add_item("V"); - m.set_cell( + m.model.category_mut("Msr").unwrap().add_item("V"); + m.model.set_cell( coord(&[("Dim", "A"), ("Msr", "V")]), CellValue::Text(text_val.clone()), ); let formatted = format_md(&m); let loaded = parse_md(&formatted).unwrap(); - let got = loaded.get_cell(&coord(&[("Dim", "A"), ("Msr", "V")])); + let got = loaded.model.get_cell(&coord(&[("Dim", "A"), ("Msr", "V")])); prop_assert_eq!( got, Some(&CellValue::Text(text_val.clone())), @@ -1624,10 +1687,10 @@ mod parser_prop_tests { /// Cell count is preserved across round-trip. #[test] fn roundtrip_preserves_cell_count(model in arbitrary_model()) { - let original_count = model.data.iter_cells().count(); + let original_count = model.model.data.iter_cells().count(); let text = format_md(&model); let loaded = parse_md(&text).unwrap(); - let loaded_count = loaded.data.iter_cells().count(); + let loaded_count = loaded.model.data.iter_cells().count(); prop_assert_eq!(original_count, loaded_count, "Cell count changed after round-trip"); } @@ -1652,15 +1715,15 @@ mod parser_prop_tests { prop_assume!(!name.starts_with('>')); prop_assume!(!name.starts_with('-')); - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Cat").unwrap(); - m.category_mut("Cat").unwrap().add_item(&name); + m.model.category_mut("Cat").unwrap().add_item(&name); m.add_category("Dim").unwrap(); - m.category_mut("Dim").unwrap().add_item("X"); + m.model.category_mut("Dim").unwrap().add_item("X"); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let cat = loaded.category("Cat").unwrap(); + let cat = loaded.model.category("Cat").unwrap(); let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); prop_assert!(item_names.contains(&name.as_str()), "Item name {:?} did not round-trip.\nGot: {:?}\n{}", @@ -1681,19 +1744,19 @@ mod parser_prop_tests { prop_assume!(!cat_name.is_empty()); prop_assume!(!cat_name.starts_with('#')); - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category(&cat_name).unwrap(); - m.category_mut(&cat_name).unwrap().add_item("X"); + m.model.category_mut(&cat_name).unwrap().add_item("X"); m.add_category("Other").unwrap(); - m.category_mut("Other").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("Other").unwrap().add_item("Y"); + m.model.set_cell( coord(&[(&cat_name, "X"), ("Other", "Y")]), CellValue::Number(42.0), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[(&cat_name, "X"), ("Other", "Y")])); + let got = loaded.model.get_cell(&coord(&[(&cat_name, "X"), ("Other", "Y")])); prop_assert_eq!(got, Some(&CellValue::Number(42.0)), "Category name {:?} broke data round-trip.\n{}", cat_name, text); @@ -1714,19 +1777,19 @@ mod parser_prop_tests { prop_assume!(!item_name.starts_with('-')); prop_assume!(!item_name.starts_with('>')); - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Cat").unwrap(); - m.category_mut("Cat").unwrap().add_item(&item_name); + m.model.category_mut("Cat").unwrap().add_item(&item_name); m.add_category("Dim").unwrap(); - m.category_mut("Dim").unwrap().add_item("V"); - m.set_cell( + m.model.category_mut("Dim").unwrap().add_item("V"); + m.model.set_cell( coord(&[("Cat", &item_name), ("Dim", "V")]), CellValue::Number(99.0), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[("Cat", &item_name), ("Dim", "V")])); + let got = loaded.model.get_cell(&coord(&[("Cat", &item_name), ("Dim", "V")])); prop_assert_eq!(got, Some(&CellValue::Number(99.0)), "Item name {:?} broke data round-trip.\n{}", item_name, text); @@ -1739,9 +1802,9 @@ mod parser_prop_tests { #[cfg(test)] mod parser_edge_cases { use super::{format_md, parse_md}; - use crate::model::Model; use crate::model::category::Group; use crate::model::cell::{CellKey, CellValue}; + use crate::workbook::Workbook; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( @@ -1756,12 +1819,12 @@ mod parser_edge_cases { #[test] fn backtick_in_category_name() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Cat`s").unwrap(); - m.category_mut("Cat`s").unwrap().add_item("A"); + m.model.category_mut("Cat`s").unwrap().add_item("A"); m.add_category("Dim").unwrap(); - m.category_mut("Dim").unwrap().add_item("X"); - m.set_cell( + m.model.category_mut("Dim").unwrap().add_item("X"); + m.model.set_cell( coord(&[("Cat`s", "A"), ("Dim", "X")]), CellValue::Number(1.0), ); @@ -1769,7 +1832,9 @@ mod parser_edge_cases { let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Cat`s", "A"), ("Dim", "X")])), + loaded + .model + .get_cell(&coord(&[("Cat`s", "A"), ("Dim", "X")])), Some(&CellValue::Number(1.0)), "Backtick in category name broke round-trip.\n{text}" ); @@ -1777,12 +1842,12 @@ mod parser_edge_cases { #[test] fn item_name_with_both_equals_and_comma() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Cat").unwrap(); - m.category_mut("Cat").unwrap().add_item("a=1, b=2"); + m.model.category_mut("Cat").unwrap().add_item("a=1, b=2"); m.add_category("Dim").unwrap(); - m.category_mut("Dim").unwrap().add_item("X"); - m.set_cell( + m.model.category_mut("Dim").unwrap().add_item("X"); + m.model.set_cell( coord(&[("Cat", "a=1, b=2"), ("Dim", "X")]), CellValue::Number(7.0), ); @@ -1790,7 +1855,9 @@ mod parser_edge_cases { let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("Cat", "a=1, b=2"), ("Dim", "X")])), + loaded + .model + .get_cell(&coord(&[("Cat", "a=1, b=2"), ("Dim", "X")])), Some(&CellValue::Number(7.0)), "Item with '=' and ', ' broke round-trip.\n{text}" ); @@ -1801,9 +1868,9 @@ mod parser_edge_cases { #[test] fn hidden_item_with_slash_in_name() { // hidden: Cat/Item — but what if item name contains '/'? - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type").unwrap().add_item("A/B"); + m.model.category_mut("Type").unwrap().add_item("A/B"); m.active_view_mut().hide_item("Type", "A/B"); let text = format_md(&m); @@ -1817,7 +1884,7 @@ mod parser_edge_cases { #[test] fn collapsed_group_with_slash_in_name() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2"); @@ -1832,9 +1899,9 @@ mod parser_edge_cases { #[test] fn view_name_ending_with_active_string() { // View name "Not (active)" could be confused with the active marker - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type").unwrap().add_item("A"); + m.model.category_mut("Type").unwrap().add_item("A"); m.create_view("Not (active)"); let text = format_md(&m); @@ -1850,18 +1917,20 @@ mod parser_edge_cases { #[test] fn group_name_with_brackets() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Month").unwrap(); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1 [2025]"); - m.category_mut("Month") + m.model + .category_mut("Month") .unwrap() .add_group(Group::new("Q1 [2025]").with_parent("Year")); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let cat = loaded.category("Month").unwrap(); + let cat = loaded.model.category("Month").unwrap(); let jan = cat.items.values().find(|i| i.name == "Jan"); assert!(jan.is_some(), "Jan item missing after round-trip.\n{text}"); assert_eq!( @@ -1875,15 +1944,16 @@ mod parser_edge_cases { fn item_in_group_where_item_has_brackets() { // Item "Data [raw]" in group "Input" — the item name has brackets // AND the item has a group. - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.category_mut("Type") + m.model + .category_mut("Type") .unwrap() .add_item_in_group("Data [raw]", "Input"); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let cat = loaded.category("Type").unwrap(); + let cat = loaded.model.category("Type").unwrap(); let item = cat.items.values().find(|i| i.name == "Data [raw]"); assert!( item.is_some(), @@ -1902,7 +1972,7 @@ mod parser_edge_cases { fn parse_empty_string() { let result = parse_md(""); assert!( - result.is_err() || result.unwrap().name.is_empty(), + result.is_err() || result.unwrap().model.name.is_empty(), "Empty input should either error or produce empty model" ); } @@ -1910,7 +1980,7 @@ mod parser_edge_cases { #[test] fn parse_just_model_name() { let m = parse_md("v2025-04-09\n# MyModel\n").unwrap(); - assert_eq!(m.name, "MyModel"); + assert_eq!(m.model.name, "MyModel"); } #[test] @@ -1931,7 +2001,7 @@ mod parser_edge_cases { fn parse_duplicate_categories() { let text = "v2025-04-09\n# Test\n## Category: Type\n- A\n## Category: Type\n- B\n"; let m = parse_md(text).unwrap(); - let cat = m.category("Type").unwrap(); + let cat = m.model.category("Type").unwrap(); let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect(); assert!(!item_names.is_empty()); } @@ -1940,25 +2010,26 @@ mod parser_edge_cases { fn parse_category_with_no_items() { let text = "v2025-04-09\n# Test\n## Category: Empty\n## Category: Full\n- A\n"; let m = parse_md(text).unwrap(); - assert!(m.category("Empty").is_some()); - assert_eq!(m.category("Empty").unwrap().items.len(), 0); - assert_eq!(m.category("Full").unwrap().items.len(), 1); + assert!(m.model.category("Empty").is_some()); + assert_eq!(m.model.category("Empty").unwrap().items.len(), 0); + assert_eq!(m.model.category("Full").unwrap().items.len(), 1); } // ── Number formatting edge cases ──────────────────────────────────── #[test] fn number_negative_zero_roundtrips() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell(coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(-0.0)); + m.model.category_mut("B").unwrap().add_item("Y"); + m.model + .set_cell(coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(-0.0)); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])); match got { Some(CellValue::Number(n)) => assert!(n.abs() == 0.0), other => panic!("Expected Number(0.0), got {other:?}"), @@ -1967,19 +2038,19 @@ mod parser_edge_cases { #[test] fn number_very_large_roundtrips() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("B").unwrap().add_item("Y"); + m.model.set_cell( coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(1.7976931348623157e308), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])); assert_eq!( got, Some(&CellValue::Number(1.7976931348623157e308)), @@ -1989,19 +2060,19 @@ mod parser_edge_cases { #[test] fn number_very_small_roundtrips() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("B").unwrap().add_item("Y"); + m.model.set_cell( coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(5e-324), // f64::MIN_POSITIVE subnormal ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])); assert_eq!( got, Some(&CellValue::Number(5e-324)), @@ -2011,19 +2082,19 @@ mod parser_edge_cases { #[test] fn number_pi_roundtrips() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("B").unwrap().add_item("Y"); + m.model.set_cell( coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(std::f64::consts::PI), ); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - let got = loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])); + let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])); assert_eq!( got, Some(&CellValue::Number(std::f64::consts::PI)), @@ -2038,7 +2109,7 @@ mod parser_edge_cases { let text = "v2025-04-09\n# Spaced Model \n"; let m = parse_md(text).unwrap(); // rest_of_line captures everything after "# "; we trim in the builder - assert_eq!(m.name, "Spaced Model"); + assert_eq!(m.model.name, "Spaced Model"); } #[test] @@ -2046,7 +2117,7 @@ mod parser_edge_cases { let text = "v2025-04-09\n# Test\n## Category: Trailing \n- Item\n"; let m = parse_md(text).unwrap(); // rest_of_line includes trailing spaces; we trim in the builder - assert!(m.category("Trailing").is_some()); + assert!(m.model.category("Trailing").is_some()); } #[test] @@ -2061,15 +2132,15 @@ mod parser_edge_cases { #[test] fn three_categories_round_trip() { - let mut m = Model::new("3D"); + let mut m = Workbook::new("3D"); for cat in ["Region", "Product", "Year"] { m.add_category(cat).unwrap(); } - m.category_mut("Region").unwrap().add_item("East"); - m.category_mut("Region").unwrap().add_item("West"); - m.category_mut("Product").unwrap().add_item("Widget"); - m.category_mut("Year").unwrap().add_item("2025"); - m.set_cell( + m.model.category_mut("Region").unwrap().add_item("East"); + m.model.category_mut("Region").unwrap().add_item("West"); + m.model.category_mut("Product").unwrap().add_item("Widget"); + m.model.category_mut("Year").unwrap().add_item("2025"); + m.model.set_cell( coord(&[("Region", "East"), ("Product", "Widget"), ("Year", "2025")]), CellValue::Number(1000.0), ); @@ -2077,7 +2148,7 @@ mod parser_edge_cases { let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[ + loaded.model.get_cell(&coord(&[ ("Region", "East"), ("Product", "Widget"), ("Year", "2025") @@ -2091,12 +2162,12 @@ mod parser_edge_cases { #[test] fn text_value_with_backslash() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("B").unwrap().add_item("Y"); + m.model.set_cell( coord(&[("A", "X"), ("B", "Y")]), CellValue::Text("C:\\Users\\file.txt".into()), ); @@ -2104,7 +2175,7 @@ mod parser_edge_cases { let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])), + loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])), Some(&CellValue::Text("C:\\Users\\file.txt".into())), "Backslash in text was corrupted.\n{text}" ); @@ -2113,12 +2184,12 @@ mod parser_edge_cases { #[test] fn text_value_with_backslash_n_literal() { // The literal string "\n" (two chars) should not become a newline - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("A").unwrap(); - m.category_mut("A").unwrap().add_item("X"); + m.model.category_mut("A").unwrap().add_item("X"); m.add_category("B").unwrap(); - m.category_mut("B").unwrap().add_item("Y"); - m.set_cell( + m.model.category_mut("B").unwrap().add_item("Y"); + m.model.set_cell( coord(&[("A", "X"), ("B", "Y")]), CellValue::Text("literal \\n not newline".into()), ); @@ -2126,7 +2197,7 @@ mod parser_edge_cases { let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert_eq!( - loaded.get_cell(&coord(&[("A", "X"), ("B", "Y")])), + loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])), Some(&CellValue::Text("literal \\n not newline".into())), "Literal backslash-n was corrupted.\n{text}" ); @@ -2314,18 +2385,18 @@ mod grammar_prop_tests { let model2 = model2_result.unwrap(); // Model name preserved - prop_assert_eq!(&model1.name, &model2.name); + prop_assert_eq!(&model1.model.name, &model2.model.name); // Category count preserved prop_assert_eq!( - model1.categories.len(), - model2.categories.len(), + model1.model.categories.len(), + model2.model.categories.len(), "Category count changed" ); // Cell count preserved - let count1 = model1.data.iter_cells().count(); - let count2 = model2.data.iter_cells().count(); + let count1 = model1.model.data.iter_cells().count(); + let count2 = model2.model.data.iter_cells().count(); prop_assert_eq!(count1, count2, "Cell count changed: {} → {}\nOriginal:\n{}\nRe-formatted:\n{}", count1, count2, file, printed); diff --git a/src/ui/app.rs b/src/ui/app.rs index 137c800..d9d62fd 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -12,13 +12,13 @@ use ratatui::style::Color; use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; -use crate::model::Model; use crate::model::cell::CellValue; use crate::persistence; use crate::ui::grid::{ compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format, }; use crate::view::GridLayout; +use crate::workbook::Workbook; /// Drill-down state: frozen record snapshot + pending edits that have not /// yet been applied to the model. @@ -152,7 +152,7 @@ impl AppMode { } pub struct App { - pub model: Model, + pub workbook: Workbook, pub file_path: Option, pub mode: AppMode, pub status_msg: String, @@ -199,22 +199,19 @@ pub struct App { } impl App { - pub fn new(mut model: Model, file_path: Option) -> Self { + pub fn new(mut workbook: Workbook, file_path: Option) -> Self { // Recompute formula cache before building the initial layout so - // formula-derived values are available on the first frame. - let none_cats: Vec = model - .active_view() - .categories_on(crate::view::Axis::None) - .into_iter() - .map(String::from) - .collect(); - model.recompute_formulas(&none_cats); + // formula-derived values are available on the first frame. The + // cache is keyed by the active view's None-axis categories, so + // the caller must gather them explicitly. + let none_cats = workbook.active_view().none_cats(); + workbook.model.recompute_formulas(&none_cats); let layout = { - let view = model.active_view(); - GridLayout::with_frozen_records(&model, view, None) + let view = workbook.active_view(); + GridLayout::with_frozen_records(&workbook.model, view, None) }; Self { - model, + workbook, file_path, mode: AppMode::Normal, status_msg: String::new(), @@ -245,29 +242,24 @@ impl App { } } - /// Rebuild the grid layout from current model, view, and drill state. - /// Note: `with_frozen_records` already handles pruning internally. + /// 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) { - // Gather none_cats before mutable borrow for formula recomputation - let none_cats: Vec = self - .model - .active_view() - .categories_on(crate::view::Axis::None) - .into_iter() - .map(String::from) - .collect(); - self.model.recompute_formulas(&none_cats); - let view = self.model.active_view(); + let none_cats = self.workbook.active_view().none_cats(); + self.workbook.model.recompute_formulas(&none_cats); + let view = self.workbook.active_view(); let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records)); - self.layout = GridLayout::with_frozen_records(&self.model, view, frozen); + self.layout = GridLayout::with_frozen_records(&self.workbook.model, view, frozen); } pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { - let view = self.model.active_view(); + let view = self.workbook.active_view(); let layout = &self.layout; let (sel_row, sel_col) = view.selected; CmdContext { - model: &self.model, + model: &self.workbook.model, + workbook: &self.workbook, + view, layout, registry: self.keymap_set.registry(), mode: &self.mode, @@ -298,7 +290,8 @@ impl App { .or_else(|| layout.resolve_display(k)) .unwrap_or_default() } else { - self.model + self.workbook + .model .get_cell(k) .map(|v| v.to_string()) .unwrap_or_default() @@ -310,7 +303,7 @@ impl App { visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); - let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals); + let col_widths = compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals); let row_header_width = compute_row_header_width(layout); compute_visible_cols( &col_widths, @@ -335,7 +328,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.model.categories.values().all(|c| { + self.workbook.model.categories.values().all(|c| { matches!( c.kind, CategoryKind::VirtualIndex @@ -379,7 +372,7 @@ impl App { && let Some(path) = &self.file_path.clone() { let ap = persistence::autosave_path(path); - let _ = persistence::save(&self.model, &ap); + let _ = persistence::save(&self.workbook, &ap); self.last_autosave = Instant::now(); } } @@ -422,18 +415,17 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::model::Model; fn two_col_model() -> App { - let mut m = Model::new("T"); - m.add_category("Row").unwrap(); // → Row axis - m.add_category("Col").unwrap(); // → Column axis - m.category_mut("Row").unwrap().add_item("A"); - m.category_mut("Row").unwrap().add_item("B"); - m.category_mut("Row").unwrap().add_item("C"); - m.category_mut("Col").unwrap().add_item("X"); - m.category_mut("Col").unwrap().add_item("Y"); - App::new(m, None) + let mut wb = Workbook::new("T"); + wb.add_category("Row").unwrap(); // → Row axis + wb.add_category("Col").unwrap(); // → Column axis + wb.model.category_mut("Row").unwrap().add_item("A"); + wb.model.category_mut("Row").unwrap().add_item("B"); + wb.model.category_mut("Row").unwrap().add_item("C"); + wb.model.category_mut("Col").unwrap().add_item("X"); + wb.model.category_mut("Col").unwrap().add_item("Y"); + App::new(wb, None) } fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) { @@ -445,7 +437,7 @@ mod tests { fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance { use crate::command::cmd::navigation::CursorState; - let view = app.model.active_view(); + let view = app.workbook.active_view(); let cursor = CursorState { row: view.selected.0, col: view.selected.1, @@ -462,29 +454,29 @@ mod tests { #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); - app.model.active_view_mut().selected = (0, 0); + app.workbook.active_view_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.model.active_view().selected, (1, 0)); + assert_eq!(app.workbook.active_view().selected, (1, 0)); } #[test] fn enter_advance_wraps_to_top_of_next_column() { let mut app = two_col_model(); // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1) - app.model.active_view_mut().selected = (2, 0); + app.workbook.active_view_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.model.active_view().selected, (0, 1)); + assert_eq!(app.workbook.active_view().selected, (0, 1)); } #[test] fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); - app.model.active_view_mut().selected = (2, 1); + app.workbook.active_view_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); - assert_eq!(app.model.active_view().selected, (2, 1)); + assert_eq!(app.workbook.active_view().selected, (2, 1)); } #[test] @@ -535,22 +527,22 @@ mod tests { // each → column widths ~31 chars. With term_width=80, row header ~4, // data area ~76 → only ~2 columns actually fit. But the rough estimate // (80−30)/12 = 4 over-counts, so viewport_effects never scrolls. - let mut m = Model::new("T"); - m.add_category("Row").unwrap(); - m.add_category("Col").unwrap(); - m.category_mut("Row").unwrap().add_item("R1"); + let mut wb = Workbook::new("T"); + wb.add_category("Row").unwrap(); + wb.add_category("Col").unwrap(); + wb.model.category_mut("Row").unwrap().add_item("R1"); for i in 0..8 { let name = format!("VeryLongColumnItemName_{i:03}"); - m.category_mut("Col").unwrap().add_item(&name); + wb.model.category_mut("Col").unwrap().add_item(&name); } - // Populate a value so the model isn't empty + // Populate a value so the workbook isn't empty let key = CellKey::new(vec![ ("Row".to_string(), "R1".to_string()), ("Col".to_string(), "VeryLongColumnItemName_000".to_string()), ]); - m.set_cell(key, CellValue::Number(1.0)); + wb.model.set_cell(key, CellValue::Number(1.0)); - let mut app = App::new(m, None); + let mut app = App::new(wb, None); app.term_width = 80; // Press 'l' (right) 3 times to move cursor to column 3. @@ -563,34 +555,34 @@ mod tests { } assert_eq!( - app.model.active_view().selected.1, + app.workbook.active_view().selected.1, 3, "cursor should be at column 3" ); assert!( - app.model.active_view().col_offset > 0, + app.workbook.active_view().col_offset > 0, "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \ in 80-char terminal with 26-char-wide columns), but col_offset is {}", - app.model.active_view().col_offset + app.workbook.active_view().col_offset ); } #[test] fn home_jumps_to_first_col() { let mut app = two_col_model(); - app.model.active_view_mut().selected = (1, 1); + app.workbook.active_view_mut().selected = (1, 1); app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.model.active_view().selected, (1, 0)); + assert_eq!(app.workbook.active_view().selected, (1, 0)); } #[test] fn end_jumps_to_last_col() { let mut app = two_col_model(); - app.model.active_view_mut().selected = (1, 0); + app.workbook.active_view_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.model.active_view().selected, (1, 1)); + assert_eq!(app.workbook.active_view().selected, (1, 1)); } #[test] @@ -598,38 +590,40 @@ mod tests { let mut app = two_col_model(); // Add enough rows for i in 0..30 { - app.model + app.workbook + .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; // ~20 visible rows → delta = 15 - app.model.active_view_mut().selected = (0, 0); + app.workbook.active_view_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.model.active_view().selected.1, 0, "column preserved"); + assert_eq!(app.workbook.active_view().selected.1, 0, "column preserved"); assert!( - app.model.active_view().selected.0 > 0, + app.workbook.active_view().selected.0 > 0, "row should advance on PageDown" ); // 3/4 of ~20 = 15 - assert_eq!(app.model.active_view().selected.0, 15); + assert_eq!(app.workbook.active_view().selected.0, 15); } #[test] fn page_up_scrolls_backward() { let mut app = two_col_model(); for i in 0..30 { - app.model + app.workbook + .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 28; - app.model.active_view_mut().selected = (20, 0); + app.workbook.active_view_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.model.active_view().selected.0, 5); + assert_eq!(app.workbook.active_view().selected.0, 5); } #[test] @@ -637,21 +631,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.model + app.workbook + .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows - app.model.active_view_mut().selected = (0, 0); + app.workbook.active_view_mut().selected = (0, 0); // G jumps to last row (row 12) app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)) .unwrap(); - let last = app.model.active_view().selected.0; + let last = app.workbook.active_view().selected.0; assert_eq!(last, 12, "should be at last row"); // With only ~5 visible rows and 13 rows, offset should scroll. // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll. - let offset = app.model.active_view().row_offset; + let offset = app.workbook.active_view().row_offset; assert!( offset > 0, "row_offset should scroll when last row is beyond visible area, but is {offset}" @@ -662,33 +657,34 @@ mod tests { fn ctrl_d_scrolls_viewport_with_small_terminal() { let mut app = two_col_model(); for i in 0..30 { - app.model + app.workbook + .model .category_mut("Row") .unwrap() .add_item(format!("R{i}")); } app.term_height = 13; // ~5 visible rows - app.model.active_view_mut().selected = (0, 0); + app.workbook.active_view_mut().selected = (0, 0); // Ctrl+d scrolls by 5 rows app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); - assert_eq!(app.model.active_view().selected.0, 5); + assert_eq!(app.workbook.active_view().selected.0, 5); // Press Ctrl+d again — now at row 10 with only 5 visible rows, // row_offset should have scrolled (not stay at 0 due to hardcoded 20) app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); - assert_eq!(app.model.active_view().selected.0, 10); + assert_eq!(app.workbook.active_view().selected.0, 10); assert!( - app.model.active_view().row_offset > 0, + app.workbook.active_view().row_offset > 0, "row_offset should scroll with small terminal, but is {}", - app.model.active_view().row_offset + app.workbook.active_view().row_offset ); } #[test] fn tab_in_edit_mode_commits_and_moves_right() { let mut app = two_col_model(); - app.model.active_view_mut().selected = (0, 0); + app.workbook.active_view_mut().selected = (0, 0); // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); @@ -706,7 +702,7 @@ mod tests { app.mode ); assert_eq!( - app.model.active_view().selected.1, + app.workbook.active_view().selected.1, 1, "should have moved to column 1" ); @@ -735,7 +731,7 @@ mod tests { #[test] fn fresh_model_is_empty() { - let app = App::new(Model::new("T"), None); + let app = App::new(Workbook::new("T"), None); assert!( app.is_empty_model(), "a brand-new model with only virtual categories should be empty" @@ -744,9 +740,9 @@ mod tests { #[test] fn model_with_user_category_is_not_empty() { - let mut m = Model::new("T"); - m.add_category("Sales").unwrap(); - let app = App::new(m, None); + let mut wb = Workbook::new("T"); + wb.add_category("Sales").unwrap(); + let app = App::new(wb, None); assert!( !app.is_empty_model(), "a model with a user-defined category should not be empty" @@ -757,7 +753,7 @@ mod tests { #[test] fn help_page_next_advances_page() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.help_page = 0; @@ -768,7 +764,7 @@ mod tests { #[test] fn help_page_prev_goes_back() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.help_page = 2; @@ -779,7 +775,7 @@ mod tests { #[test] fn help_page_clamps_at_zero() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.help_page = 0; @@ -792,7 +788,7 @@ mod tests { fn help_page_clamps_at_max() { use crate::ui::help::HELP_PAGE_COUNT; - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.help_page = HELP_PAGE_COUNT - 1; @@ -809,7 +805,7 @@ mod tests { #[test] fn help_q_returns_to_normal() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) @@ -822,7 +818,7 @@ mod tests { #[test] fn help_esc_returns_to_normal() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) @@ -835,7 +831,7 @@ mod tests { #[test] fn help_colon_enters_command_mode() { - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) @@ -852,7 +848,7 @@ mod tests { #[test] fn add_item_to_nonexistent_category_sets_status() { use crate::ui::effect::Effect; - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddItem { category: "Nonexistent".to_string(), item: "x".to_string(), @@ -868,7 +864,7 @@ mod tests { #[test] fn add_formula_with_bad_syntax_sets_status() { use crate::ui::effect::Effect; - let mut app = App::new(Model::new("T"), None); + let mut app = App::new(Workbook::new("T"), None); let effect = crate::ui::effect::AddFormula { raw: "!!!invalid".to_string(), target_category: "X".to_string(), diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index e835727..ed47311 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -8,7 +8,7 @@ use crate::model::Model; use crate::ui::app::AppMode; use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree}; use crate::ui::panel::PanelContent; -use crate::view::Axis; +use crate::view::{Axis, View}; fn axis_display(axis: Axis) -> (&'static str, Color) { match axis { @@ -20,14 +20,18 @@ fn axis_display(axis: Axis) -> (&'static str, Color) { } pub struct CategoryContent<'a> { - model: &'a Model, + view: &'a View, tree: Vec, } impl<'a> CategoryContent<'a> { - pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet) -> Self { + pub fn new( + model: &'a Model, + view: &'a View, + expanded: &'a std::collections::HashSet, + ) -> Self { let tree = build_cat_tree(model, expanded); - Self { model, tree } + Self { view, tree } } } @@ -57,7 +61,7 @@ impl PanelContent for CategoryContent<'_> { fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) { let y = inner.y + index as u16; - let view = self.model.active_view(); + let view = self.view; let base_style = if is_selected { Style::default() diff --git a/src/ui/effect.rs b/src/ui/effect.rs index b00d853..d9a7489 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -22,7 +22,7 @@ pub trait Effect: Debug { pub struct AddCategory(pub String); impl Effect for AddCategory { fn apply(&self, app: &mut App) { - let _ = app.model.add_category(&self.0); + let _ = app.workbook.add_category(&self.0); } } @@ -33,7 +33,7 @@ pub struct AddItem { } impl Effect for AddItem { fn apply(&self, app: &mut App) { - if let Some(cat) = app.model.category_mut(&self.category) { + if let Some(cat) = app.workbook.model.category_mut(&self.category) { cat.add_item(&self.item); } else { app.status_msg = format!("Unknown category '{}'", self.category); @@ -49,7 +49,7 @@ pub struct AddItemInGroup { } impl Effect for AddItemInGroup { fn apply(&self, app: &mut App) { - if let Some(cat) = app.model.category_mut(&self.category) { + if let Some(cat) = app.workbook.model.category_mut(&self.category) { cat.add_item_in_group(&self.item, &self.group); } else { app.status_msg = format!("Unknown category '{}'", self.category); @@ -61,7 +61,7 @@ impl Effect for AddItemInGroup { pub struct SetCell(pub CellKey, pub CellValue); impl Effect for SetCell { fn apply(&self, app: &mut App) { - app.model.set_cell(self.0.clone(), self.1.clone()); + app.workbook.model.set_cell(self.0.clone(), self.1.clone()); } } @@ -69,7 +69,7 @@ impl Effect for SetCell { pub struct ClearCell(pub CellKey); impl Effect for ClearCell { fn apply(&self, app: &mut App) { - app.model.clear_cell(&self.0); + app.workbook.model.clear_cell(&self.0); } } @@ -86,11 +86,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.model.category_mut(&formula.target_category) + && let Some(cat) = app.workbook.model.category_mut(&formula.target_category) { cat.add_item(&formula.target); } - app.model.add_formula(formula); + app.workbook.model.add_formula(formula); } Err(e) => { app.status_msg = format!("Formula error: {e}"); @@ -106,7 +106,8 @@ pub struct RemoveFormula { } impl Effect for RemoveFormula { fn apply(&self, app: &mut App) { - app.model + app.workbook + .model .remove_formula(&self.target, &self.target_category); } } @@ -133,7 +134,7 @@ impl Effect for EnterEditAtCursor { pub struct TogglePruneEmpty; impl Effect for TogglePruneEmpty { fn apply(&self, app: &mut App) { - let v = app.model.active_view_mut(); + let v = app.workbook.active_view_mut(); v.prune_empty = !v.prune_empty; } } @@ -155,7 +156,7 @@ pub struct RemoveItem { } impl Effect for RemoveItem { fn apply(&self, app: &mut App) { - app.model.remove_item(&self.category, &self.item); + app.workbook.model.remove_item(&self.category, &self.item); } } @@ -163,7 +164,7 @@ impl Effect for RemoveItem { pub struct RemoveCategory(pub String); impl Effect for RemoveCategory { fn apply(&self, app: &mut App) { - app.model.remove_category(&self.0); + app.workbook.remove_category(&self.0); } } @@ -173,7 +174,7 @@ impl Effect for RemoveCategory { pub struct CreateView(pub String); impl Effect for CreateView { fn apply(&self, app: &mut App) { - app.model.create_view(&self.0); + app.workbook.create_view(&self.0); } } @@ -181,7 +182,7 @@ impl Effect for CreateView { pub struct DeleteView(pub String); impl Effect for DeleteView { fn apply(&self, app: &mut App) { - let _ = app.model.delete_view(&self.0); + let _ = app.workbook.delete_view(&self.0); } } @@ -189,12 +190,12 @@ impl Effect for DeleteView { pub struct SwitchView(pub String); impl Effect for SwitchView { fn apply(&self, app: &mut App) { - let current = app.model.active_view.clone(); + let current = app.workbook.active_view.clone(); if current != self.0 { app.view_back_stack.push(current); app.view_forward_stack.clear(); } - let _ = app.model.switch_view(&self.0); + let _ = app.workbook.switch_view(&self.0); } } @@ -204,9 +205,9 @@ pub struct ViewBack; impl Effect for ViewBack { fn apply(&self, app: &mut App) { if let Some(prev) = app.view_back_stack.pop() { - let current = app.model.active_view.clone(); + let current = app.workbook.active_view.clone(); app.view_forward_stack.push(current); - let _ = app.model.switch_view(&prev); + let _ = app.workbook.switch_view(&prev); } } } @@ -217,9 +218,9 @@ pub struct ViewForward; impl Effect for ViewForward { fn apply(&self, app: &mut App) { if let Some(next) = app.view_forward_stack.pop() { - let current = app.model.active_view.clone(); + let current = app.workbook.active_view.clone(); app.view_back_stack.push(current); - let _ = app.model.switch_view(&next); + let _ = app.workbook.switch_view(&next); } } } @@ -231,7 +232,7 @@ pub struct SetAxis { } impl Effect for SetAxis { fn apply(&self, app: &mut App) { - app.model + app.workbook .active_view_mut() .set_axis(&self.category, self.axis); } @@ -244,7 +245,7 @@ pub struct SetPageSelection { } impl Effect for SetPageSelection { fn apply(&self, app: &mut App) { - app.model + app.workbook .active_view_mut() .set_page_selection(&self.category, &self.item); } @@ -257,7 +258,7 @@ pub struct ToggleGroup { } impl Effect for ToggleGroup { fn apply(&self, app: &mut App) { - app.model + app.workbook .active_view_mut() .toggle_group_collapse(&self.category, &self.group); } @@ -270,7 +271,7 @@ pub struct HideItem { } impl Effect for HideItem { fn apply(&self, app: &mut App) { - app.model + app.workbook .active_view_mut() .hide_item(&self.category, &self.item); } @@ -283,7 +284,7 @@ pub struct ShowItem { } impl Effect for ShowItem { fn apply(&self, app: &mut App) { - app.model + app.workbook .active_view_mut() .show_item(&self.category, &self.item); } @@ -293,7 +294,7 @@ impl Effect for ShowItem { pub struct TransposeAxes; impl Effect for TransposeAxes { fn apply(&self, app: &mut App) { - app.model.active_view_mut().transpose_axes(); + app.workbook.active_view_mut().transpose_axes(); } } @@ -301,7 +302,7 @@ impl Effect for TransposeAxes { pub struct CycleAxis(pub String); impl Effect for CycleAxis { fn apply(&self, app: &mut App) { - app.model.active_view_mut().cycle_axis(&self.0); + app.workbook.active_view_mut().cycle_axis(&self.0); } } @@ -309,7 +310,7 @@ impl Effect for CycleAxis { pub struct SetNumberFormat(pub String); impl Effect for SetNumberFormat { fn apply(&self, app: &mut App) { - app.model.active_view_mut().number_format = self.0.clone(); + app.workbook.active_view_mut().number_format = self.0.clone(); } } @@ -319,7 +320,7 @@ impl Effect for SetNumberFormat { pub struct SetSelected(pub usize, pub usize); impl Effect for SetSelected { fn apply(&self, app: &mut App) { - app.model.active_view_mut().selected = (self.0, self.1); + app.workbook.active_view_mut().selected = (self.0, self.1); } } @@ -327,7 +328,7 @@ impl Effect for SetSelected { pub struct SetRowOffset(pub usize); impl Effect for SetRowOffset { fn apply(&self, app: &mut App) { - app.model.active_view_mut().row_offset = self.0; + app.workbook.active_view_mut().row_offset = self.0; } } @@ -335,7 +336,7 @@ impl Effect for SetRowOffset { pub struct SetColOffset(pub usize); impl Effect for SetColOffset { fn apply(&self, app: &mut App) { - app.model.active_view_mut().col_offset = self.0; + app.workbook.active_view_mut().col_offset = self.0; } } @@ -449,21 +450,21 @@ impl Effect for ApplyAndClearDrill { if col_name == "Value" { // Update the cell's value let value = if new_value.is_empty() { - app.model.clear_cell(orig_key); + app.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.model.set_cell(orig_key.clone(), value); + app.workbook.model.set_cell(orig_key.clone(), value); } else { // Rename a coordinate: remove old cell, insert new with updated coord - let value = match app.model.get_cell(orig_key) { + let value = match app.workbook.model.get_cell(orig_key) { Some(v) => v.clone(), None => continue, }; - app.model.clear_cell(orig_key); + app.workbook.model.clear_cell(orig_key); // Build new key by replacing the coord let new_coords: Vec<(String, String)> = orig_key .0 @@ -478,10 +479,10 @@ 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.model.category_mut(col_name) { + if let Some(cat) = app.workbook.model.category_mut(col_name) { cat.add_item(new_value.clone()); } - app.model.set_cell(new_key, value); + app.workbook.model.set_cell(new_key, value); } } app.dirty = true; @@ -513,7 +514,7 @@ 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.model, path) { + match crate::persistence::save(&app.workbook,path) { Ok(()) => { app.dirty = false; app.status_msg = format!("Saved to {}", path.display()); @@ -532,7 +533,7 @@ impl Effect for Save { pub struct SaveAs(pub PathBuf); impl Effect for SaveAs { fn apply(&self, app: &mut App) { - match crate::persistence::save(&app.model, &self.0) { + match crate::persistence::save(&app.workbook,&self.0) { Ok(()) => { app.file_path = Some(self.0.clone()); app.dirty = false; @@ -648,9 +649,9 @@ impl Effect for WizardKey { crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c), crossterm::event::KeyCode::Backspace => wizard.pop_name_char(), crossterm::event::KeyCode::Enter => match wizard.build_model() { - Ok(mut model) => { - model.normalize_view_state(); - app.model = model; + Ok(mut workbook) => { + workbook.normalize_view_state(); + app.workbook = workbook; app.formula_cursor = 0; app.dirty = true; app.status_msg = "Import successful! Press :w to save.".to_string(); @@ -703,8 +704,8 @@ impl Effect for StartImportWizard { pub struct ExportCsv(pub PathBuf); impl Effect for ExportCsv { fn apply(&self, app: &mut App) { - let view_name = app.model.active_view.clone(); - match crate::persistence::export_csv(&app.model, &view_name, &self.0) { + let view_name = app.workbook.active_view.clone(); + match crate::persistence::export_csv(&app.workbook, &view_name, &self.0) { Ok(()) => { app.status_msg = format!("Exported to {}", self.0.display()); } @@ -723,7 +724,7 @@ impl Effect for LoadModel { match crate::persistence::load(&self.0) { Ok(mut loaded) => { loaded.normalize_view_state(); - app.model = loaded; + app.workbook = loaded; app.status_msg = format!("Loaded from {}", self.0.display()); } Err(e) => { @@ -833,8 +834,8 @@ impl Effect for ImportJsonHeadless { }; match pipeline.build_model() { - Ok(new_model) => { - app.model = new_model; + Ok(new_workbook) => { + app.workbook = new_workbook; app.status_msg = "Imported successfully".to_string(); } Err(e) => { @@ -952,18 +953,18 @@ pub fn help_page_set(page: usize) -> Box { #[cfg(test)] mod tests { use super::*; - use crate::model::Model; + use crate::workbook::Workbook; use crate::model::cell::{CellKey, CellValue}; fn test_app() -> App { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - App::new(m, None) + let mut wb = Workbook::new("Test"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Type").unwrap().add_item("Clothing"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + wb.model.category_mut("Month").unwrap().add_item("Feb"); + App::new(wb, None) } // ── Model mutation effects ────────────────────────────────────────── @@ -972,7 +973,7 @@ mod tests { fn add_category_effect() { let mut app = test_app(); AddCategory("Region".to_string()).apply(&mut app); - assert!(app.model.category("Region").is_some()); + assert!(app.workbook.model.category("Region").is_some()); } #[test] @@ -984,6 +985,7 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .workbook .model .category("Type") .unwrap() @@ -1012,10 +1014,10 @@ mod tests { ("Month".into(), "Jan".into()), ]); SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app); - assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(42.0))); + assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(42.0))); ClearCell(key.clone()).apply(&mut app); - assert_eq!(app.model.get_cell(&key), None); + assert_eq!(app.workbook.model.get_cell(&key), None); } #[test] @@ -1026,7 +1028,7 @@ mod tests { target_category: "Type".to_string(), } .apply(&mut app); - assert!(!app.model.formulas().is_empty()); + assert!(!app.workbook.model.formulas().is_empty()); } /// Regression: AddFormula must add the target item to the target category @@ -1037,7 +1039,7 @@ mod tests { let mut app = test_app(); // "Margin" does not exist as an item in "Type" before adding the formula assert!( - !app.model + !app.workbook.model .category("Type") .unwrap() .ordered_item_names() @@ -1049,6 +1051,7 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .workbook .model .category("Type") .unwrap() @@ -1073,7 +1076,7 @@ mod tests { } .apply(&mut app); // Should appear in effective_item_names (used by layout) - let effective = app.model.effective_item_names("_Measure"); + let effective = app.workbook.model.effective_item_names("_Measure"); assert!( effective.contains(&"Margin".to_string()), "formula target 'Margin' should appear in effective _Measure items, got: {:?}", @@ -1081,7 +1084,7 @@ mod tests { ); // Should NOT be in the category's own items assert!( - !app.model + !app.workbook.model .category("_Measure") .unwrap() .ordered_item_names() @@ -1109,13 +1112,13 @@ mod tests { target_category: "Type".to_string(), } .apply(&mut app); - assert!(!app.model.formulas().is_empty()); + assert!(!app.workbook.model.formulas().is_empty()); RemoveFormula { target: "Clothing".to_string(), target_category: "Type".to_string(), } .apply(&mut app); - assert!(app.model.formulas().is_empty()); + assert!(app.workbook.model.formulas().is_empty()); } // ── View effects ──────────────────────────────────────────────────── @@ -1123,11 +1126,11 @@ mod tests { #[test] fn switch_view_pushes_to_back_stack() { let mut app = test_app(); - app.model.create_view("View 2"); + app.workbook.create_view("View 2"); assert!(app.view_back_stack.is_empty()); SwitchView("View 2".to_string()).apply(&mut app); - assert_eq!(app.model.active_view.as_str(), "View 2"); + assert_eq!(app.workbook.active_view.as_str(), "View 2"); assert_eq!(app.view_back_stack, vec!["Default".to_string()]); // Forward stack should be cleared assert!(app.view_forward_stack.is_empty()); @@ -1143,19 +1146,19 @@ mod tests { #[test] fn view_back_and_forward() { let mut app = test_app(); - app.model.create_view("View 2"); + app.workbook.create_view("View 2"); SwitchView("View 2".to_string()).apply(&mut app); - assert_eq!(app.model.active_view.as_str(), "View 2"); + assert_eq!(app.workbook.active_view.as_str(), "View 2"); // Go back ViewBack.apply(&mut app); - assert_eq!(app.model.active_view.as_str(), "Default"); + assert_eq!(app.workbook.active_view.as_str(), "Default"); assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]); assert!(app.view_back_stack.is_empty()); // Go forward ViewForward.apply(&mut app); - assert_eq!(app.model.active_view.as_str(), "View 2"); + assert_eq!(app.workbook.active_view.as_str(), "View 2"); assert_eq!(app.view_back_stack, vec!["Default".to_string()]); assert!(app.view_forward_stack.is_empty()); } @@ -1163,19 +1166,19 @@ mod tests { #[test] fn view_back_with_empty_stack_is_noop() { let mut app = test_app(); - let before = app.model.active_view.clone(); + let before = app.workbook.active_view.clone(); ViewBack.apply(&mut app); - assert_eq!(app.model.active_view, before); + assert_eq!(app.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.model.views.contains_key("View 2")); + assert!(app.workbook.views.contains_key("View 2")); DeleteView("View 2".to_string()).apply(&mut app); - assert!(!app.model.views.contains_key("View 2")); + assert!(!app.workbook.views.contains_key("View 2")); } #[test] @@ -1186,21 +1189,21 @@ mod tests { axis: Axis::Page, } .apply(&mut app); - assert_eq!(app.model.active_view().axis_of("Type"), Axis::Page); + assert_eq!(app.workbook.active_view().axis_of("Type"), Axis::Page); } #[test] fn transpose_axes_effect() { let mut app = test_app(); let row_before: Vec = app - .model + .workbook .active_view() .categories_on(Axis::Row) .into_iter() .map(String::from) .collect(); let col_before: Vec = app - .model + .workbook .active_view() .categories_on(Axis::Column) .into_iter() @@ -1208,14 +1211,14 @@ mod tests { .collect(); TransposeAxes.apply(&mut app); let row_after: Vec = app - .model + .workbook .active_view() .categories_on(Axis::Row) .into_iter() .map(String::from) .collect(); let col_after: Vec = app - .model + .workbook .active_view() .categories_on(Axis::Column) .into_iter() @@ -1231,7 +1234,7 @@ mod tests { fn set_selected_effect() { let mut app = test_app(); SetSelected(3, 5).apply(&mut app); - assert_eq!(app.model.active_view().selected, (3, 5)); + assert_eq!(app.workbook.active_view().selected, (3, 5)); } #[test] @@ -1239,8 +1242,8 @@ mod tests { let mut app = test_app(); SetRowOffset(10).apply(&mut app); SetColOffset(5).apply(&mut app); - assert_eq!(app.model.active_view().row_offset, 10); - assert_eq!(app.model.active_view().col_offset, 5); + assert_eq!(app.workbook.active_view().row_offset, 10); + assert_eq!(app.workbook.active_view().col_offset, 5); } // ── App state effects ─────────────────────────────────────────────── @@ -1417,7 +1420,7 @@ mod tests { ("Month".into(), "Jan".into()), ]); // Set original cell - app.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1433,7 +1436,7 @@ mod tests { ApplyAndClearDrill.apply(&mut app); assert!(app.drill_state.is_none()); assert!(app.dirty); - assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(99.0))); + assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(99.0))); } #[test] @@ -1443,7 +1446,7 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1459,15 +1462,16 @@ mod tests { ApplyAndClearDrill.apply(&mut app); assert!(app.dirty); // Old cell should be gone - assert_eq!(app.model.get_cell(&key), None); + assert_eq!(app.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.model.get_cell(&new_key), Some(&CellValue::Number(42.0))); + assert_eq!(app.workbook.model.get_cell(&new_key), Some(&CellValue::Number(42.0))); // "Drink" should have been added as an item let items: Vec<&str> = app + .workbook .model .category("Type") .unwrap() @@ -1484,7 +1488,7 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1498,7 +1502,7 @@ mod tests { .apply(&mut app); ApplyAndClearDrill.apply(&mut app); - assert_eq!(app.model.get_cell(&key), None); + assert_eq!(app.workbook.model.get_cell(&key), None); } // ── Toggle effects ────────────────────────────────────────────────── @@ -1506,11 +1510,11 @@ mod tests { #[test] fn toggle_prune_empty_effect() { let mut app = test_app(); - let before = app.model.active_view().prune_empty; + let before = app.workbook.active_view().prune_empty; TogglePruneEmpty.apply(&mut app); - assert_ne!(app.model.active_view().prune_empty, before); + assert_ne!(app.workbook.active_view().prune_empty, before); TogglePruneEmpty.apply(&mut app); - assert_eq!(app.model.active_view().prune_empty, before); + assert_eq!(app.workbook.active_view().prune_empty, before); } #[test] @@ -1532,6 +1536,7 @@ mod tests { } .apply(&mut app); let items: Vec<&str> = app + .workbook .model .category("Type") .unwrap() @@ -1541,7 +1546,7 @@ mod tests { assert!(!items.contains(&"Food")); RemoveCategory("Month".to_string()).apply(&mut app); - assert!(app.model.category("Month").is_none()); + assert!(app.workbook.model.category("Month").is_none()); } // ── Number format ─────────────────────────────────────────────────── @@ -1550,7 +1555,7 @@ mod tests { fn set_number_format_effect() { let mut app = test_app(); SetNumberFormat(",.2f".to_string()).apply(&mut app); - assert_eq!(app.model.active_view().number_format, ",.2f"); + assert_eq!(app.workbook.active_view().number_format, ",.2f"); } // ── Page selection ────────────────────────────────────────────────── @@ -1563,7 +1568,7 @@ mod tests { item: "Food".to_string(), } .apply(&mut app); - assert_eq!(app.model.active_view().page_selection("Type"), Some("Food")); + assert_eq!(app.workbook.active_view().page_selection("Type"), Some("Food")); } // ── Hide/show items ───────────────────────────────────────────────── @@ -1576,14 +1581,14 @@ mod tests { item: "Food".to_string(), } .apply(&mut app); - assert!(app.model.active_view().is_hidden("Type", "Food")); + assert!(app.workbook.active_view().is_hidden("Type", "Food")); ShowItem { category: "Type".to_string(), item: "Food".to_string(), } .apply(&mut app); - assert!(!app.model.active_view().is_hidden("Type", "Food")); + assert!(!app.workbook.active_view().is_hidden("Type", "Food")); } // ── Toggle group ──────────────────────────────────────────────────── @@ -1597,7 +1602,7 @@ mod tests { } .apply(&mut app); assert!( - app.model + app.workbook .active_view() .is_group_collapsed("Type", "MyGroup") ); @@ -1607,7 +1612,7 @@ mod tests { } .apply(&mut app); assert!( - !app.model + !app.workbook .active_view() .is_group_collapsed("Type", "MyGroup") ); @@ -1618,9 +1623,9 @@ mod tests { #[test] fn cycle_axis_effect() { let mut app = test_app(); - let before = app.model.active_view().axis_of("Type"); + let before = app.workbook.active_view().axis_of("Type"); CycleAxis("Type".to_string()).apply(&mut app); - let after = app.model.active_view().axis_of("Type"); + let after = app.workbook.active_view().axis_of("Type"); assert_ne!(before, after); } diff --git a/src/ui/grid.rs b/src/ui/grid.rs index cdfe70a..04a7485 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -8,7 +8,7 @@ use unicode_width::UnicodeWidthStr; use crate::model::Model; use crate::ui::app::AppMode; -use crate::view::{AxisEntry, GridLayout}; +use crate::view::{AxisEntry, GridLayout, View}; /// Minimum column width — enough for short numbers/labels + 1 char gap. const MIN_COL_WIDTH: u16 = 5; @@ -22,6 +22,8 @@ const GROUP_COLLAPSED: &str = "▶"; pub struct GridWidget<'a> { pub model: &'a Model, + pub view: &'a View, + pub view_name: &'a str, pub layout: &'a GridLayout, pub mode: &'a AppMode, pub search_query: &'a str, @@ -30,8 +32,11 @@ pub struct GridWidget<'a> { } impl<'a> GridWidget<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( model: &'a Model, + view: &'a View, + view_name: &'a str, layout: &'a GridLayout, mode: &'a AppMode, search_query: &'a str, @@ -40,6 +45,8 @@ impl<'a> GridWidget<'a> { ) -> Self { Self { model, + view, + view_name, layout, mode, search_query, @@ -49,7 +56,7 @@ impl<'a> GridWidget<'a> { } fn render_grid(&self, area: Rect, buf: &mut Buffer) { - let view = self.model.active_view(); + let view = self.view; let layout = self.layout; let (sel_row, sel_col) = view.selected; let row_offset = view.row_offset; @@ -494,10 +501,9 @@ impl<'a> GridWidget<'a> { impl<'a> Widget for GridWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - let view_name = self.model.active_view.clone(); let block = Block::default() .borders(Borders::ALL) - .title(format!(" View: {} ", view_name)); + .title(format!(" View: {} ", self.view_name)); let inner = block.inner(area); block.render(area, buf); @@ -674,27 +680,33 @@ mod tests { use super::GridWidget; use crate::formula::parse_formula; - use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::AppMode; use crate::view::GridLayout; + use crate::workbook::Workbook; // ── Helpers ─────────────────────────────────────────────────────────────── /// Render a GridWidget into a fresh buffer of the given size. - fn render(model: &mut Model, width: u16, height: u16) -> Buffer { - let none_cats: Vec = model - .active_view() - .categories_on(crate::view::Axis::None) - .into_iter() - .map(String::from) - .collect(); - model.recompute_formulas(&none_cats); + fn render(wb: &mut Workbook, width: u16, height: u16) -> Buffer { + let none_cats = wb.active_view().none_cats(); + wb.model.recompute_formulas(&none_cats); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); let bufs = std::collections::HashMap::new(); - let layout = GridLayout::new(model, model.active_view()); - GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf); + let layout = GridLayout::new(&wb.model, wb.active_view()); + let view_name = wb.active_view.clone(); + GridWidget::new( + &wb.model, + wb.active_view(), + &view_name, + &layout, + &AppMode::Normal, + "", + &bufs, + None, + ) + .render(area, &mut buf); buf } @@ -723,24 +735,25 @@ mod tests { ) } - /// Minimal model: Type on Row, Month on Column. + /// Minimal workbook: Type on Row, Month on Column. /// Every cell has a value so rows/cols survive pruning. - fn two_cat_model() -> Model { - let mut m = Model::new("Test"); + fn two_cat_model() -> Workbook { + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); // → Row m.add_category("Month").unwrap(); // → Column - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); c.add_item("Feb"); } // Fill every cell so nothing is pruned as empty. for t in ["Food", "Clothing"] { for mo in ["Jan", "Feb"] { - m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0)); + m.model + .set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0)); } } m @@ -771,7 +784,7 @@ mod tests { #[test] fn cell_value_appears_in_correct_position() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(123.0), ); @@ -782,15 +795,15 @@ mod tests { #[test] fn multiple_cell_values_all_appear() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Feb")]), CellValue::Number(200.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0), ); @@ -803,13 +816,13 @@ mod tests { #[test] fn unset_cells_show_no_value() { // Build a model without the two_cat_model helper (which fills every cell). - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); + m.model.category_mut("Type").unwrap().add_item("Food"); + m.model.category_mut("Month").unwrap().add_item("Jan"); // Set one cell so the row/col isn't pruned - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(1.0), ); @@ -830,11 +843,11 @@ mod tests { #[test] fn total_row_sums_column_correctly() { let mut m = two_cat_model(); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0), ); - m.set_cell( + m.model.set_cell( coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0), ); @@ -850,17 +863,17 @@ mod tests { #[test] fn page_filter_bar_shows_category_and_selection() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); // → Row m.add_category("Month").unwrap(); // → Column m.add_category("Payer").unwrap(); // → Page - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Payer") { + if let Some(c) = m.model.category_mut("Payer") { c.add_item("Alice"); c.add_item("Bob"); } @@ -874,17 +887,17 @@ mod tests { #[test] fn page_filter_defaults_to_first_item() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Payer").unwrap(); - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Payer") { + if let Some(c) = m.model.category_mut("Payer") { c.add_item("Alice"); c.add_item("Bob"); } @@ -900,21 +913,21 @@ mod tests { #[test] fn formula_cell_renders_computed_value() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Region").unwrap(); // → Column - m.category_mut("_Measure").unwrap().add_item("Revenue"); - m.category_mut("_Measure").unwrap().add_item("Cost"); + m.model.category_mut("_Measure").unwrap().add_item("Revenue"); + m.model.category_mut("_Measure").unwrap().add_item("Cost"); // Profit is a formula target — dynamically included in _Measure - m.category_mut("Region").unwrap().add_item("East"); - m.set_cell( + m.model.category_mut("Region").unwrap().add_item("East"); + m.model.set_cell( coord(&[("_Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0), ); - m.set_cell( + m.model.set_cell( coord(&[("_Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0), ); - m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); + m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); m.active_view_mut() .set_axis("_Index", crate::view::Axis::None); m.active_view_mut() @@ -932,18 +945,18 @@ mod tests { #[test] fn two_row_categories_produce_cross_product_labels() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); // → Row m.add_category("Month").unwrap(); // → Column m.add_category("Recipient").unwrap(); // → Page by default; move to Row - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Recipient") { + if let Some(c) = m.model.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } @@ -952,7 +965,7 @@ mod tests { // Populate cells so rows/cols survive pruning for t in ["Food", "Clothing"] { for r in ["Alice", "Bob"] { - m.set_cell( + m.model.set_cell( coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]), CellValue::Number(1.0), ); @@ -978,24 +991,24 @@ mod tests { #[test] fn two_row_categories_include_all_coords_in_cell_lookup() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Recipient").unwrap(); - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Recipient") { + if let Some(c) = m.model.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } m.active_view_mut() .set_axis("Recipient", crate::view::Axis::Row); // Set data at the full 3-coordinate key - m.set_cell( + m.model.set_cell( coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]), CellValue::Number(77.0), ); @@ -1005,17 +1018,17 @@ mod tests { #[test] fn two_column_categories_produce_cross_product_headers() { - let mut m = Model::new("Test"); + let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); // → Row m.add_category("Month").unwrap(); // → Column m.add_category("Year").unwrap(); // → Page by default; move to Column - if let Some(c) = m.category_mut("Type") { + if let Some(c) = m.model.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { + if let Some(c) = m.model.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Year") { + if let Some(c) = m.model.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); } @@ -1023,7 +1036,7 @@ mod tests { .set_axis("Year", crate::view::Axis::Column); // Populate cells so cols survive pruning for y in ["2024", "2025"] { - m.set_cell( + m.model.set_cell( coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]), CellValue::Number(1.0), ); diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 702680f..34c7695 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -8,18 +8,25 @@ use unicode_width::UnicodeWidthStr; use crate::model::Model; use crate::ui::app::AppMode; -use crate::view::Axis; +use crate::view::{Axis, View}; pub struct TileBar<'a> { pub model: &'a Model, + pub view: &'a View, pub mode: &'a AppMode, pub tile_cat_idx: usize, } impl<'a> TileBar<'a> { - pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self { + pub fn new( + model: &'a Model, + view: &'a View, + mode: &'a AppMode, + tile_cat_idx: usize, + ) -> Self { Self { model, + view, mode, tile_cat_idx, } @@ -44,7 +51,7 @@ impl<'a> Widget for TileBar<'a> { Style::default(), ); - let view = self.model.active_view(); + let view = self.view; let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) { Some(self.tile_cat_idx) diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index bed47ea..24ebd05 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -4,31 +4,31 @@ use ratatui::{ style::{Color, Modifier, Style}, }; -use crate::model::Model; use crate::ui::app::AppMode; use crate::ui::panel::PanelContent; use crate::view::Axis; +use crate::workbook::Workbook; pub struct ViewContent<'a> { view_names: Vec, active_view: String, - model: &'a Model, + workbook: &'a Workbook, } impl<'a> ViewContent<'a> { - pub fn new(model: &'a Model) -> Self { - let view_names: Vec = model.views.keys().cloned().collect(); - let active_view = model.active_view.clone(); + pub fn new(workbook: &'a Workbook) -> Self { + let view_names: Vec = workbook.views.keys().cloned().collect(); + let active_view = workbook.active_view.clone(); Self { view_names, active_view, - model, + workbook, } } /// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time" fn axis_summary(&self, view_name: &str) -> String { - let Some(view) = self.model.views.get(view_name) else { + let Some(view) = self.workbook.views.get(view_name) else { return String::new(); }; let mut parts = Vec::new(); diff --git a/src/view/layout.rs b/src/view/layout.rs index f502843..441d97a 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -568,73 +568,76 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec #[cfg(test)] mod tests { use super::{AxisEntry, GridLayout, synthetic_record_info}; - use crate::model::Model; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; + use crate::workbook::Workbook; - fn records_model() -> Model { - let mut m = Model::new("T"); - m.add_category("Region").unwrap(); - m.add_category("_Measure").unwrap(); - m.category_mut("Region").unwrap().add_item("North"); - m.category_mut("_Measure").unwrap().add_item("Revenue"); - m.category_mut("_Measure").unwrap().add_item("Cost"); - m.set_cell( + fn records_workbook() -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("_Measure").unwrap(); + wb.model.category_mut("Region").unwrap().add_item("North"); + wb.model + .category_mut("_Measure") + .unwrap() + .add_item("Revenue"); + wb.model.category_mut("_Measure").unwrap().add_item("Cost"); + wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(100.0), ); - m.set_cell( + wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "Cost".into()), ]), CellValue::Number(50.0), ); - m + wb } #[test] fn prune_empty_removes_all_empty_columns_in_pivot_mode() { - let mut m = Model::new("T"); - m.add_category("Row").unwrap(); - m.add_category("Col").unwrap(); - m.category_mut("Row").unwrap().add_item("A"); - m.category_mut("Col").unwrap().add_item("X"); - m.category_mut("Col").unwrap().add_item("Y"); + let mut wb = Workbook::new("T"); + wb.add_category("Row").unwrap(); + wb.add_category("Col").unwrap(); + wb.model.category_mut("Row").unwrap().add_item("A"); + wb.model.category_mut("Col").unwrap().add_item("X"); + wb.model.category_mut("Col").unwrap().add_item("Y"); // Only X has data; Y is entirely empty - m.set_cell( + wb.model.set_cell( CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]), CellValue::Number(1.0), ); - let mut layout = GridLayout::new(&m, m.active_view()); + let mut layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_count(), 2); // X and Y before pruning - layout.prune_empty(&m); + layout.prune_empty(&wb.model); assert_eq!(layout.col_count(), 1); // only X after pruning assert_eq!(layout.col_label(0), "X"); } #[test] fn records_mode_activated_when_index_and_dim_on_axes() { - let mut m = records_model(); - let v = m.active_view_mut(); + let mut wb = records_workbook(); + let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); assert_eq!(layout.row_count(), 2); // 2 cells } #[test] fn records_mode_cell_key_returns_synthetic_for_all_columns() { - let mut m = records_model(); - let v = m.active_view_mut(); + let mut wb = records_workbook(); + let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); let cols: Vec = (0..layout.col_count()) .map(|i| layout.col_label(i)) @@ -653,11 +656,11 @@ mod tests { #[test] fn records_mode_resolve_display_returns_values() { - let mut m = records_model(); - let v = m.active_view_mut(); + let mut wb = records_workbook(); + let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); let cols: Vec = (0..layout.col_count()) .map(|i| layout.col_label(i)) .collect(); @@ -707,31 +710,31 @@ mod tests { ) } - fn two_cat_model() -> Model { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); + fn two_cat_workbook() -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); for item in ["Food", "Clothing"] { - m.category_mut("Type").unwrap().add_item(item); + wb.model.category_mut("Type").unwrap().add_item(item); } for item in ["Jan", "Feb"] { - m.category_mut("Month").unwrap().add_item(item); + wb.model.category_mut("Month").unwrap().add_item(item); } - m + wb } #[test] fn row_and_col_counts_match_item_counts() { - let m = two_cat_model(); - let layout = GridLayout::new(&m, m.active_view()); + let wb = two_cat_workbook(); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 2); // Food, Clothing assert_eq!(layout.col_count(), 2); // Jan, Feb } #[test] fn cell_key_encodes_correct_coordinates() { - let m = two_cat_model(); - let layout = GridLayout::new(&m, m.active_view()); + let wb = two_cat_workbook(); + let layout = GridLayout::new(&wb.model, wb.active_view()); // row 0 = Food, col 1 = Feb let key = layout.cell_key(0, 1).unwrap(); assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")])); @@ -739,88 +742,93 @@ mod tests { #[test] fn cell_key_out_of_bounds_returns_none() { - let m = two_cat_model(); - let layout = GridLayout::new(&m, m.active_view()); + let wb = two_cat_workbook(); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.cell_key(99, 0).is_none()); assert!(layout.cell_key(0, 99).is_none()); } #[test] fn cell_key_includes_page_coords() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Region").unwrap().add_item("East"); - m.category_mut("Region").unwrap().add_item("West"); - m.active_view_mut().set_page_selection("Region", "West"); - let layout = GridLayout::new(&m, m.active_view()); + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_category("Region").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + wb.model.category_mut("Region").unwrap().add_item("East"); + wb.model.category_mut("Region").unwrap().add_item("West"); + wb.active_view_mut().set_page_selection("Region", "West"); + let layout = GridLayout::new(&wb.model, wb.active_view()); let key = layout.cell_key(0, 0).unwrap(); assert_eq!(key.get("Region"), Some("West")); } #[test] fn cell_key_round_trips_through_model_evaluate() { - let mut m = two_cat_model(); - m.set_cell( + let mut wb = two_cat_workbook(); + wb.model.set_cell( coord(&[("Month", "Feb"), ("Type", "Clothing")]), CellValue::Number(42.0), ); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); // Clothing = row 1, Feb = col 1 let key = layout.cell_key(1, 1).unwrap(); - assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0))); + assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(42.0))); } #[test] fn labels_join_with_slash_for_multi_cat_axis() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.add_category("Year").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Year").unwrap().add_item("2025"); - m.active_view_mut() + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_category("Year").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + wb.model.category_mut("Year").unwrap().add_item("2025"); + wb.active_view_mut() .set_axis("Year", crate::view::Axis::Column); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_label(0), "Jan/2025"); } #[test] fn row_count_excludes_group_headers() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Feb", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - let layout = GridLayout::new(&m, m.active_view()); + wb.model.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count } #[test] fn group_header_emitted_at_group_boundary() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - let layout = GridLayout::new(&m, m.active_view()); + wb.model.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&wb.model, wb.active_view()); let headers: Vec<_> = layout .row_items .iter() @@ -837,21 +845,24 @@ mod tests { #[test] fn collapsed_group_has_header_but_no_data_items() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Feb", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - m.active_view_mut().toggle_group_collapse("Month", "Q1"); - let layout = GridLayout::new(&m, m.active_view()); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + let layout = GridLayout::new(&wb.model, wb.active_view()); // Q1 collapsed: header present, Jan and Feb absent; Q2 intact assert_eq!(layout.row_count(), 1); // only Apr let q1_header = layout @@ -868,8 +879,8 @@ mod tests { #[test] fn ungrouped_items_produce_no_headers() { - let m = two_cat_model(); - let layout = GridLayout::new(&m, m.active_view()); + let wb = two_cat_workbook(); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert!( !layout .row_items @@ -886,39 +897,43 @@ mod tests { #[test] fn cell_key_correct_with_grouped_items() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - m.set_cell( + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.set_cell( coord(&[("Month", "Apr"), ("Type", "Food")]), CellValue::Number(99.0), ); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); // data row 0 = Jan, data row 1 = Apr let key = layout.cell_key(1, 0).unwrap(); - assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0))); + assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(99.0))); } #[test] fn data_row_to_visual_skips_headers() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - let layout = GridLayout::new(&m, m.active_view()); + wb.model.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&wb.model, wb.active_view()); // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1 assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3 @@ -927,17 +942,19 @@ mod tests { #[test] fn data_col_to_visual_skips_headers() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); // Row - m.add_category("Month").unwrap(); // Column - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); // Row + wb.add_category("Month").unwrap(); // Column + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_col_to_visual(0), Some(1)); assert_eq!(layout.data_col_to_visual(1), Some(3)); @@ -946,17 +963,19 @@ mod tests { #[test] fn row_group_for_finds_enclosing_group() { - let mut m = Model::new("T"); - m.add_category("Month").unwrap(); - m.add_category("Type").unwrap(); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - m.category_mut("Type").unwrap().add_item("Food"); - let layout = GridLayout::new(&m, m.active_view()); + wb.model.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_group_for(0), Some(("Month".to_string(), "Q1".to_string())) @@ -969,28 +988,30 @@ mod tests { #[test] fn row_group_for_returns_none_for_ungrouped() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); - let layout = GridLayout::new(&m, m.active_view()); + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_group_for(0), None); } #[test] fn col_group_for_finds_enclosing_group() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); // Row - m.add_category("Month").unwrap(); // Column - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month") + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); // Row + wb.add_category("Month").unwrap(); // Column + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Jan", "Q1"); - m.category_mut("Month") + wb.model + .category_mut("Month") .unwrap() .add_item_in_group("Apr", "Q2"); - let layout = GridLayout::new(&m, m.active_view()); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.col_group_for(0), Some(("Month".to_string(), "Q1".to_string())) @@ -1003,12 +1024,12 @@ mod tests { #[test] fn col_group_for_returns_none_for_ungrouped() { - let mut m = Model::new("T"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Month").unwrap().add_item("Jan"); - let layout = GridLayout::new(&m, m.active_view()); + let mut wb = Workbook::new("T"); + wb.add_category("Type").unwrap(); + wb.add_category("Month").unwrap(); + wb.model.category_mut("Type").unwrap().add_item("Food"); + wb.model.category_mut("Month").unwrap().add_item("Jan"); + let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_group_for(0), None); } } diff --git a/src/view/types.rs b/src/view/types.rs index 4021ffe..1e6f1f1 100644 --- a/src/view/types.rs +++ b/src/view/types.rs @@ -126,6 +126,16 @@ impl View { .collect() } + /// Owned-string variant of `categories_on(Axis::None)`. Used by callers + /// that need to pass the None-axis set to formula recomputation, which + /// takes `&[String]` so it can be stored without tying lifetimes to `View`. + pub fn none_cats(&self) -> Vec { + self.categories_on(Axis::None) + .into_iter() + .map(String::from) + .collect() + } + pub fn set_page_selection(&mut self, cat_name: &str, item: &str) { self.page_selections .insert(cat_name.to_string(), item.to_string()); diff --git a/src/workbook.rs b/src/workbook.rs new file mode 100644 index 0000000..5bb97a8 --- /dev/null +++ b/src/workbook.rs @@ -0,0 +1,259 @@ +//! A [`Workbook`] wraps a pure-data [`Model`] with the set of named [`View`]s +//! that are rendered over it. Splitting the two breaks the former +//! `Model ↔ View` cycle: `Model` knows nothing about views, while `View` +//! depends on `Model` (one direction). +//! +//! Cross-slice operations — adding or removing a category, for example, must +//! update both the model's categories and every view's axis assignments +//! — live here rather than on `Model`, so `Model` stays pure data and +//! `improvise-core` can be extracted without pulling view code along. + +use anyhow::{Result, anyhow}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::model::Model; +use crate::model::category::CategoryId; +use crate::view::{Axis, View}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workbook { + pub model: Model, + pub views: IndexMap, + pub active_view: String, +} + +impl Workbook { + /// Create a new workbook with a fresh `Model` and a single `Default` view. + /// Virtual categories (`_Index`, `_Dim`, `_Measure`) are registered on the + /// default view with their conventional axes (`_Index`=Row, `_Dim`=Column). + pub fn new(name: impl Into) -> Self { + let model = Model::new(name); + let mut views = IndexMap::new(); + views.insert("Default".to_string(), View::new("Default")); + let mut wb = Self { + model, + views, + active_view: "Default".to_string(), + }; + for view in wb.views.values_mut() { + for cat_name in wb.model.categories.keys() { + view.on_category_added(cat_name); + } + view.set_axis("_Index", Axis::Row); + view.set_axis("_Dim", Axis::Column); + } + wb + } + + // ── Cross-slice category management ───────────────────────────────────── + + /// Add a regular pivot category and register it with every view. + pub fn add_category(&mut self, name: impl Into) -> Result { + let name = name.into(); + let id = self.model.add_category(&name)?; + for view in self.views.values_mut() { + view.on_category_added(&name); + } + Ok(id) + } + + /// Add a label category (excluded from pivot-count limit) and register it + /// with every view on `Axis::None`. + pub fn add_label_category(&mut self, name: impl Into) -> Result { + let name = name.into(); + let id = self.model.add_label_category(&name)?; + for view in self.views.values_mut() { + view.on_category_added(&name); + view.set_axis(&name, Axis::None); + } + Ok(id) + } + + /// Remove a category from the model and from every view. + pub fn remove_category(&mut self, name: &str) { + self.model.remove_category(name); + for view in self.views.values_mut() { + view.on_category_removed(name); + } + } + + // ── Active view access ────────────────────────────────────────────────── + + pub fn active_view(&self) -> &View { + self.views + .get(&self.active_view) + .expect("active_view always names an existing view") + } + + pub fn active_view_mut(&mut self) -> &mut View { + self.views + .get_mut(&self.active_view) + .expect("active_view always names an existing view") + } + + // ── View management ───────────────────────────────────────────────────── + + /// Create a new view pre-populated with every existing category, and + /// return a mutable reference to it. Does not change the active view. + pub fn create_view(&mut self, name: impl Into) -> &mut View { + let name = name.into(); + let mut view = View::new(name.clone()); + for cat_name in self.model.categories.keys() { + view.on_category_added(cat_name); + } + self.views.insert(name.clone(), view); + self.views.get_mut(&name).unwrap() + } + + pub fn switch_view(&mut self, name: &str) -> Result<()> { + if self.views.contains_key(name) { + self.active_view = name.to_string(); + Ok(()) + } else { + Err(anyhow!("View '{name}' not found")) + } + } + + pub fn delete_view(&mut self, name: &str) -> Result<()> { + if self.views.len() <= 1 { + return Err(anyhow!("Cannot delete the last view")); + } + self.views.shift_remove(name); + if self.active_view == name { + self.active_view = self.views.keys().next().unwrap().clone(); + } + Ok(()) + } + + /// Reset all view scroll offsets to zero. Call after loading or replacing + /// a workbook so stale offsets don't render an empty grid. + pub fn normalize_view_state(&mut self) { + for view in self.views.values_mut() { + view.row_offset = 0; + view.col_offset = 0; + } + } + +} + +#[cfg(test)] +mod tests { + use super::Workbook; + use crate::view::Axis; + + #[test] + fn new_workbook_has_default_view_with_virtuals_seeded() { + let wb = Workbook::new("Test"); + assert_eq!(wb.active_view, "Default"); + let v = wb.active_view(); + assert_eq!(v.axis_of("_Index"), Axis::Row); + assert_eq!(v.axis_of("_Dim"), Axis::Column); + } + + #[test] + fn add_category_notifies_all_views() { + let mut wb = Workbook::new("Test"); + wb.create_view("Secondary"); + wb.add_category("Region").unwrap(); + // Both views should know about Region (axis_of panics on unknown). + let _ = wb.views.get("Default").unwrap().axis_of("Region"); + let _ = wb.views.get("Secondary").unwrap().axis_of("Region"); + } + + #[test] + fn add_label_category_sets_none_axis_on_all_views() { + let mut wb = Workbook::new("Test"); + wb.create_view("Other"); + wb.add_label_category("Note").unwrap(); + assert_eq!(wb.views.get("Default").unwrap().axis_of("Note"), Axis::None); + assert_eq!(wb.views.get("Other").unwrap().axis_of("Note"), Axis::None); + } + + #[test] + fn remove_category_removes_from_all_views() { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.create_view("Second"); + wb.remove_category("Region"); + // Region should no longer appear in either view's Row axis. + assert!( + wb.views + .get("Default") + .unwrap() + .categories_on(Axis::Row) + .iter() + .all(|c| *c != "Region") + ); + assert!( + wb.views + .get("Second") + .unwrap() + .categories_on(Axis::Row) + .iter() + .all(|c| *c != "Region") + ); + } + + #[test] + fn switch_view_changes_active_view() { + let mut wb = Workbook::new("Test"); + wb.create_view("Other"); + wb.switch_view("Other").unwrap(); + assert_eq!(wb.active_view, "Other"); + } + + #[test] + fn switch_view_unknown_returns_error() { + let mut wb = Workbook::new("Test"); + assert!(wb.switch_view("NoSuchView").is_err()); + } + + #[test] + fn delete_view_removes_it() { + let mut wb = Workbook::new("Test"); + wb.create_view("Extra"); + wb.delete_view("Extra").unwrap(); + assert!(!wb.views.contains_key("Extra")); + } + + #[test] + fn delete_last_view_returns_error() { + let wb = Workbook::new("Test"); + // Use wb without binding mut — delete_view would need &mut, so: + let mut wb = wb; + assert!(wb.delete_view("Default").is_err()); + } + + #[test] + fn delete_active_view_switches_to_another() { + let mut wb = Workbook::new("Test"); + wb.create_view("Other"); + wb.switch_view("Other").unwrap(); + wb.delete_view("Other").unwrap(); + assert_ne!(wb.active_view, "Other"); + } + + #[test] + fn first_category_goes_to_row_second_to_column_rest_to_page() { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + wb.add_category("Time").unwrap(); + let v = wb.active_view(); + assert_eq!(v.axis_of("Region"), Axis::Row); + assert_eq!(v.axis_of("Product"), Axis::Column); + assert_eq!(v.axis_of("Time"), Axis::Page); + } + + #[test] + fn create_view_copies_category_structure() { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + wb.create_view("Secondary"); + let v = wb.views.get("Secondary").unwrap(); + let _ = v.axis_of("Region"); + let _ = v.axis_of("Product"); + } +}