refactor(core): move model, view, workbook, format into improvise-core
Relocate the four pure-data module trees into the improvise-core sub-crate scaffolded in the previous commit. Phase A already made these modules UI/IO-free; this commit is purely mechanical: git mv src/format.rs -> crates/improvise-core/src/format.rs git mv src/workbook.rs -> crates/improvise-core/src/workbook.rs git mv src/model -> crates/improvise-core/src/model git mv src/view -> crates/improvise-core/src/view The moved code contains no path edits: the `crate::formula::*`, `crate::model::*`, `crate::view::*`, `crate::workbook::*`, `crate::format::*` imports inside the four trees all continue to resolve because the new crate mirrors the same module layout and re-exports improvise_formula under `formula` via its lib.rs. Main-crate `src/lib.rs` flips from declaring these as owned modules (`pub mod model;` etc.) to re-exporting them from improvise-core (`pub use improvise_core::model;` etc.). This keeps every `crate::model::*`, `crate::view::*`, `crate::workbook::*`, `crate::format::*` path inside the 26 consumer files in src/ (ui, command, persistence, import, draw, main) resolving unchanged — no downstream edits needed. Verification: - cargo check --workspace: clean - cargo test --workspace: 612 passing (357 main + 190 core + 65 formula) - cargo clippy --workspace --tests: clean - cargo build -p improvise-core: standalone build succeeds, confirming zero UI/IO leakage into the core crate Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
267
crates/improvise-core/src/workbook.rs
Normal file
267
crates/improvise-core/src/workbook.rs
Normal file
@ -0,0 +1,267 @@
|
||||
//! 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<String, View>,
|
||||
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<String>) -> 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<String>) -> Result<CategoryId> {
|
||||
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<String>) -> Result<CategoryId> {
|
||||
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<String>) -> &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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user