//! 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. All virtuals default to `Axis::None` via /// `on_category_added` (see improvise-709f2df), then `_Measure` is bumped /// to `Axis::Page` so aggregated pivot views show a single measure at a /// time (see improvise-kos). Leaving `_Index`/`_Dim` on None keeps pivot /// mode the default — records mode activates only when the user moves /// both onto axes. 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("_Measure", Axis::Page); } 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(); // Virtual categories default to Axis::None; _Measure is bumped to Page // so aggregated pivot views show a single measure by default // (improvise-kos, improvise-709f2df). assert_eq!(v.axis_of("_Index"), Axis::None); assert_eq!(v.axis_of("_Dim"), Axis::None); assert_eq!(v.axis_of("_Measure"), Axis::Page); } #[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"); } }