refactor: break Model↔View cycle, introduce Workbook wrapper
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) <noreply@anthropic.com>
This commit is contained in:
@ -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<String, Category>,
|
||||
pub data: DataStore,
|
||||
formulas: Vec<Formula>,
|
||||
pub views: IndexMap<String, View>,
|
||||
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<String>) -> 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<String>) -> Result<CategoryId> {
|
||||
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<String>) -> Result<CategoryId> {
|
||||
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<CellKey> = 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<String>) -> &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")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user