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:
Edward Langley
2026-04-15 21:08:11 -07:00
parent f02d905aac
commit 3fbf56ec8b
26 changed files with 1271 additions and 972 deletions

View File

@ -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<String> = 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),
);