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:
135
src/ui/grid.rs
135
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<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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user