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:
@ -15,7 +15,7 @@ mod tests {
|
|||||||
("Type".to_string(), "Food".to_string()),
|
("Type".to_string(), "Food".to_string()),
|
||||||
("Month".to_string(), "Jan".to_string()),
|
("Month".to_string(), "Jan".to_string()),
|
||||||
]);
|
]);
|
||||||
m.set_cell(key, CellValue::Number(42.0));
|
m.model.set_cell(key, CellValue::Number(42.0));
|
||||||
let layout = make_layout(&m);
|
let layout = make_layout(&m);
|
||||||
let reg = make_registry();
|
let reg = make_registry();
|
||||||
let ctx = make_ctx(&m, &layout, ®);
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
@ -33,7 +33,7 @@ mod tests {
|
|||||||
("Type".to_string(), "Food".to_string()),
|
("Type".to_string(), "Food".to_string()),
|
||||||
("Month".to_string(), "Jan".to_string()),
|
("Month".to_string(), "Jan".to_string()),
|
||||||
]);
|
]);
|
||||||
m.set_cell(key, CellValue::Number(99.0));
|
m.model.set_cell(key, CellValue::Number(99.0));
|
||||||
let layout = make_layout(&m);
|
let layout = make_layout(&m);
|
||||||
let reg = make_registry();
|
let reg = make_registry();
|
||||||
let ctx = make_ctx(&m, &layout, ®);
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
@ -47,7 +47,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn paste_with_yanked_value_produces_set_cell() {
|
fn paste_with_yanked_value_produces_set_cell() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("Type".into(), "Food".into()),
|
("Type".into(), "Food".into()),
|
||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
|
|||||||
@ -11,7 +11,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::command::cmd::test_helpers::*;
|
use crate::command::cmd::test_helpers::*;
|
||||||
use crate::model::Model;
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_formula_with_categories_adds_formula() {
|
fn commit_formula_with_categories_adds_formula() {
|
||||||
@ -38,7 +38,7 @@ mod tests {
|
|||||||
/// categories exist. _Measure is a virtual category that always exists.
|
/// categories exist. _Measure is a virtual category that always exists.
|
||||||
#[test]
|
#[test]
|
||||||
fn commit_formula_without_regular_categories_targets_measure() {
|
fn commit_formula_without_regular_categories_targets_measure() {
|
||||||
let m = Model::new("Empty");
|
let m = Workbook::new("Empty");
|
||||||
let layout = make_layout(&m);
|
let layout = make_layout(&m);
|
||||||
let reg = make_registry();
|
let reg = make_registry();
|
||||||
let mut bufs = HashMap::new();
|
let mut bufs = HashMap::new();
|
||||||
|
|||||||
@ -7,11 +7,18 @@ use crate::model::Model;
|
|||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::effect::{Effect, Panel};
|
use crate::ui::effect::{Effect, Panel};
|
||||||
use crate::view::{Axis, GridLayout};
|
use crate::view::{Axis, GridLayout, View};
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
/// Read-only context available to commands for decision-making.
|
/// Read-only context available to commands for decision-making.
|
||||||
|
///
|
||||||
|
/// Commands receive a `&Model` (pure data) and a `&View` (the active view).
|
||||||
|
/// The full `&Workbook` is also available for the rare command that needs
|
||||||
|
/// to enumerate all views (e.g. the view panel).
|
||||||
pub struct CmdContext<'a> {
|
pub struct CmdContext<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
|
pub workbook: &'a Workbook,
|
||||||
|
pub view: &'a View,
|
||||||
pub layout: &'a GridLayout,
|
pub layout: &'a GridLayout,
|
||||||
pub registry: &'a CmdRegistry,
|
pub registry: &'a CmdRegistry,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
|
|||||||
@ -119,29 +119,29 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drill_into_formula_cell_returns_data_records() {
|
fn drill_into_formula_cell_returns_data_records() {
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
let mut m = Model::new("Test");
|
let mut m = Workbook::new("Test");
|
||||||
m.add_category("Region").unwrap();
|
m.add_category("Region").unwrap();
|
||||||
m.category_mut("Region").unwrap().add_item("East");
|
m.model.category_mut("Region").unwrap().add_item("East");
|
||||||
m.category_mut("_Measure").unwrap().add_item("Revenue");
|
m.model.category_mut("_Measure").unwrap().add_item("Revenue");
|
||||||
m.category_mut("_Measure").unwrap().add_item("Cost");
|
m.model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("_Measure".into(), "Revenue".into()),
|
("_Measure".into(), "Revenue".into()),
|
||||||
("Region".into(), "East".into()),
|
("Region".into(), "East".into()),
|
||||||
]),
|
]),
|
||||||
CellValue::Number(1000.0),
|
CellValue::Number(1000.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("_Measure".into(), "Cost".into()),
|
("_Measure".into(), "Cost".into()),
|
||||||
("Region".into(), "East".into()),
|
("Region".into(), "East".into()),
|
||||||
]),
|
]),
|
||||||
CellValue::Number(600.0),
|
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());
|
||||||
|
|
||||||
let layout = make_layout(&m);
|
let layout = make_layout(&m);
|
||||||
let reg = make_registry();
|
let reg = make_registry();
|
||||||
@ -376,7 +376,7 @@ impl Cmd for TogglePruneEmpty {
|
|||||||
"toggle-prune-empty"
|
"toggle-prune-empty"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let currently_on = ctx.model.active_view().prune_empty;
|
let currently_on = ctx.view.prune_empty;
|
||||||
vec![
|
vec![
|
||||||
Box::new(effect::TogglePruneEmpty),
|
Box::new(effect::TogglePruneEmpty),
|
||||||
effect::set_status(if currently_on {
|
effect::set_status(if currently_on {
|
||||||
@ -454,7 +454,7 @@ impl Cmd for AddRecordRow {
|
|||||||
)];
|
)];
|
||||||
}
|
}
|
||||||
// Build a CellKey from the current page filters
|
// Build a CellKey from the current page filters
|
||||||
let view = ctx.model.active_view();
|
let view = ctx.view;
|
||||||
let page_cats: Vec<String> = view
|
let page_cats: Vec<String> = view
|
||||||
.categories_on(crate::view::Axis::Page)
|
.categories_on(crate::view::Axis::Page)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@ -21,10 +21,10 @@ pub(super) mod test_helpers {
|
|||||||
|
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::effect::Effect;
|
use crate::ui::effect::Effect;
|
||||||
use crate::view::GridLayout;
|
use crate::view::GridLayout;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
use super::core::CmdContext;
|
use super::core::CmdContext;
|
||||||
use super::registry::default_registry;
|
use super::registry::default_registry;
|
||||||
@ -36,19 +36,21 @@ pub(super) mod test_helpers {
|
|||||||
pub static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
pub static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
||||||
std::sync::LazyLock::new(std::collections::HashSet::new);
|
std::sync::LazyLock::new(std::collections::HashSet::new);
|
||||||
|
|
||||||
pub fn make_layout(model: &Model) -> GridLayout {
|
pub fn make_layout(workbook: &Workbook) -> GridLayout {
|
||||||
GridLayout::new(model, model.active_view())
|
GridLayout::new(&workbook.model, workbook.active_view())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn make_ctx<'a>(
|
pub fn make_ctx<'a>(
|
||||||
model: &'a Model,
|
workbook: &'a Workbook,
|
||||||
layout: &'a GridLayout,
|
layout: &'a GridLayout,
|
||||||
registry: &'a CmdRegistry,
|
registry: &'a CmdRegistry,
|
||||||
) -> CmdContext<'a> {
|
) -> CmdContext<'a> {
|
||||||
let view = model.active_view();
|
let view = workbook.active_view();
|
||||||
let (sr, sc) = view.selected;
|
let (sr, sc) = view.selected;
|
||||||
CmdContext {
|
CmdContext {
|
||||||
model,
|
model: &workbook.model,
|
||||||
|
workbook,
|
||||||
|
view,
|
||||||
layout,
|
layout,
|
||||||
registry,
|
registry,
|
||||||
mode: &AppMode::Normal,
|
mode: &AppMode::Normal,
|
||||||
@ -72,7 +74,7 @@ pub(super) mod test_helpers {
|
|||||||
display_value: {
|
display_value: {
|
||||||
let key = layout.cell_key(sr, sc);
|
let key = layout.cell_key(sr, sc);
|
||||||
key.as_ref()
|
key.as_ref()
|
||||||
.and_then(|k| model.get_cell(k).cloned())
|
.and_then(|k| workbook.model.get_cell(k).cloned())
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
},
|
},
|
||||||
@ -83,32 +85,32 @@ pub(super) mod test_helpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn two_cat_model() -> Model {
|
pub fn two_cat_model() -> Workbook {
|
||||||
let mut m = Model::new("Test");
|
let mut wb = Workbook::new("Test");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Type").unwrap().add_item("Clothing");
|
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Month").unwrap().add_item("Feb");
|
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||||
m
|
wb
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn three_cat_model_with_page() -> Model {
|
pub fn three_cat_model_with_page() -> Workbook {
|
||||||
let mut m = Model::new("Test");
|
let mut wb = Workbook::new("Test");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Region").unwrap();
|
wb.add_category("Region").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Type").unwrap().add_item("Clothing");
|
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Month").unwrap().add_item("Feb");
|
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||||
m.category_mut("Region").unwrap().add_item("North");
|
wb.model.category_mut("Region").unwrap().add_item("North");
|
||||||
m.category_mut("Region").unwrap().add_item("South");
|
wb.model.category_mut("Region").unwrap().add_item("South");
|
||||||
m.category_mut("Region").unwrap().add_item("East");
|
wb.model.category_mut("Region").unwrap().add_item("East");
|
||||||
let view = m.active_view_mut();
|
wb.active_view_mut()
|
||||||
view.set_axis("Region", crate::view::Axis::Page);
|
.set_axis("Region", crate::view::Axis::Page);
|
||||||
m
|
wb
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
|
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use super::grid::DrillIntoCell;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::command::cmd::test_helpers::*;
|
use crate::command::cmd::test_helpers::*;
|
||||||
use crate::model::Model;
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_edit_mode_produces_editing_mode() {
|
fn enter_edit_mode_produces_editing_mode() {
|
||||||
@ -43,7 +43,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_tile_select_no_categories() {
|
fn enter_tile_select_no_categories() {
|
||||||
let m = Model::new("Empty");
|
let m = Workbook::new("Empty");
|
||||||
let layout = make_layout(&m);
|
let layout = make_layout(&m);
|
||||||
let reg = make_registry();
|
let reg = make_registry();
|
||||||
let ctx = make_ctx(&m, &layout, ®);
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
|||||||
@ -245,7 +245,7 @@ impl Cmd for PagePrev {
|
|||||||
|
|
||||||
/// Gather (cat_name, items, current_idx) for page-axis categories.
|
/// Gather (cat_name, items, current_idx) for page-axis categories.
|
||||||
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
|
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
|
||||||
let view = ctx.model.active_view();
|
let view = ctx.view;
|
||||||
let page_cats: Vec<String> = view
|
let page_cats: Vec<String> = view
|
||||||
.categories_on(Axis::Page)
|
.categories_on(Axis::Page)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@ -158,7 +158,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_formula_at_cursor_with_formulas() {
|
fn delete_formula_at_cursor_with_formulas() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.add_formula(crate::formula::ast::Formula {
|
m.model.add_formula(crate::formula::ast::Formula {
|
||||||
raw: "Profit = Revenue - Cost".to_string(),
|
raw: "Profit = Revenue - Cost".to_string(),
|
||||||
target: "Profit".to_string(),
|
target: "Profit".to_string(),
|
||||||
target_category: "Type".to_string(),
|
target_category: "Type".to_string(),
|
||||||
@ -529,7 +529,7 @@ impl Cmd for SwitchViewAtCursor {
|
|||||||
"switch-view-at-cursor"
|
"switch-view-at-cursor"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
|
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
|
||||||
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
||||||
vec![
|
vec![
|
||||||
Box::new(effect::SwitchView(name.clone())),
|
Box::new(effect::SwitchView(name.clone())),
|
||||||
@ -549,7 +549,7 @@ impl Cmd for CreateAndSwitchView {
|
|||||||
"create-and-switch-view"
|
"create-and-switch-view"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let name = format!("View {}", ctx.model.views.len() + 1);
|
let name = format!("View {}", ctx.workbook.views.len() + 1);
|
||||||
vec![
|
vec![
|
||||||
Box::new(effect::CreateView(name.clone())),
|
Box::new(effect::CreateView(name.clone())),
|
||||||
Box::new(effect::SwitchView(name)),
|
Box::new(effect::SwitchView(name)),
|
||||||
@ -567,7 +567,7 @@ impl Cmd for DeleteViewAtCursor {
|
|||||||
"delete-view-at-cursor"
|
"delete-view-at-cursor"
|
||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
|
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
|
||||||
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
||||||
let mut effects: Vec<Box<dyn Effect>> = vec![
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||||
Box::new(effect::DeleteView(name.clone())),
|
Box::new(effect::DeleteView(name.clone())),
|
||||||
|
|||||||
@ -459,7 +459,7 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
let (current, max) = match panel {
|
let (current, max) = match panel {
|
||||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||||
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
|
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||||
};
|
};
|
||||||
Ok(Box::new(MovePanelCursor {
|
Ok(Box::new(MovePanelCursor {
|
||||||
panel,
|
panel,
|
||||||
|
|||||||
@ -55,14 +55,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn search_navigate_forward_with_matching_value() {
|
fn search_navigate_forward_with_matching_value() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("Type".into(), "Food".into()),
|
("Type".into(), "Food".into()),
|
||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]),
|
]),
|
||||||
CellValue::Number(42.0),
|
CellValue::Number(42.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("Type".into(), "Clothing".into()),
|
("Type".into(), "Clothing".into()),
|
||||||
("Month".into(), "Feb".into()),
|
("Month".into(), "Feb".into()),
|
||||||
|
|||||||
@ -131,7 +131,7 @@ impl Cmd for TileAxisOp {
|
|||||||
let new_axis = match self.axis {
|
let new_axis = match self.axis {
|
||||||
Some(axis) => axis,
|
Some(axis) => axis,
|
||||||
None => {
|
None => {
|
||||||
let current = ctx.model.active_view().axis_of(name);
|
let current = ctx.view.axis_of(name);
|
||||||
match current {
|
match current {
|
||||||
Axis::Row => Axis::Column,
|
Axis::Row => Axis::Column,
|
||||||
Axis::Column => Axis::Page,
|
Axis::Column => Axis::Page,
|
||||||
|
|||||||
33
src/draw.rs
33
src/draw.rs
@ -16,7 +16,6 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::ui::app::{App, AppMode};
|
use crate::ui::app::{App, AppMode};
|
||||||
use crate::ui::category_panel::CategoryContent;
|
use crate::ui::category_panel::CategoryContent;
|
||||||
use crate::ui::formula_panel::FormulaContent;
|
use crate::ui::formula_panel::FormulaContent;
|
||||||
@ -51,13 +50,13 @@ impl<'a> Drop for TuiContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_tui(
|
pub fn run_tui(
|
||||||
model: Model,
|
workbook: crate::workbook::Workbook,
|
||||||
file_path: Option<PathBuf>,
|
file_path: Option<PathBuf>,
|
||||||
import_value: Option<serde_json::Value>,
|
import_value: Option<serde_json::Value>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut tui_context = TuiContext::enter(&mut stdout)?;
|
let mut tui_context = TuiContext::enter(&mut stdout)?;
|
||||||
let mut app = App::new(model, file_path);
|
let mut app = App::new(workbook, file_path);
|
||||||
|
|
||||||
if let Some(json) = import_value {
|
if let Some(json) = import_value {
|
||||||
app.start_import_wizard(json);
|
app.start_import_wizard(json);
|
||||||
@ -193,7 +192,10 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.map(|n| format!(" ({n})"))
|
.map(|n| format!(" ({n})"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
|
let title = format!(
|
||||||
|
" improvise · {}{}{} ",
|
||||||
|
app.workbook.model.name, file, dirty
|
||||||
|
);
|
||||||
let right = " ?:help :q quit ";
|
let right = " ?:help :q quit ";
|
||||||
let line = fill_line(title, right, area.width);
|
let line = fill_line(title, right, area.width);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
@ -234,19 +236,20 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
if app.formula_panel_open {
|
if app.formula_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
let content = FormulaContent::new(&app.model, &app.mode);
|
let content = FormulaContent::new(&app.workbook.model, &app.mode);
|
||||||
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
|
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
|
||||||
y += ph;
|
y += ph;
|
||||||
}
|
}
|
||||||
if app.category_panel_open {
|
if app.category_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
let content = CategoryContent::new(&app.model, &app.expanded_cats);
|
let content =
|
||||||
|
CategoryContent::new(&app.workbook.model, app.workbook.active_view(), &app.expanded_cats);
|
||||||
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
|
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
|
||||||
y += ph;
|
y += ph;
|
||||||
}
|
}
|
||||||
if app.view_panel_open {
|
if app.view_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
let content = ViewContent::new(&app.model);
|
let content = ViewContent::new(&app.workbook);
|
||||||
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
|
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -255,7 +258,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
GridWidget::new(
|
GridWidget::new(
|
||||||
&app.model,
|
&app.workbook.model,
|
||||||
|
app.workbook.active_view(),
|
||||||
|
&app.workbook.active_view,
|
||||||
&app.layout,
|
&app.layout,
|
||||||
&app.mode,
|
&app.mode,
|
||||||
&app.search_query,
|
&app.search_query,
|
||||||
@ -267,7 +272,15 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
f.render_widget(TileBar::new(&app.model, &app.mode, app.tile_cat_idx), area);
|
f.render_widget(
|
||||||
|
TileBar::new(
|
||||||
|
&app.workbook.model,
|
||||||
|
app.workbook.active_view(),
|
||||||
|
&app.mode,
|
||||||
|
app.tile_cat_idx,
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
@ -306,7 +319,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
|
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
|
||||||
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
|
let view_badge = format!(" {}{} ", app.workbook.active_view, yank_indicator);
|
||||||
|
|
||||||
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
|
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
|
||||||
let line = fill_line(left, &view_badge, area.width);
|
let line = fill_line(left, &view_badge, area.width);
|
||||||
|
|||||||
@ -6,8 +6,8 @@ use super::analyzer::{
|
|||||||
extract_date_component, find_array_paths,
|
extract_date_component, find_array_paths,
|
||||||
};
|
};
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -80,8 +80,8 @@ impl ImportPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a Model from the current proposals. Pure — no side effects.
|
/// Build a Workbook from the current proposals. Pure — no side effects.
|
||||||
pub fn build_model(&self) -> Result<Model> {
|
pub fn build_model(&self) -> Result<Workbook> {
|
||||||
let categories: Vec<&FieldProposal> = self
|
let categories: Vec<&FieldProposal> = self
|
||||||
.proposals
|
.proposals
|
||||||
.iter()
|
.iter()
|
||||||
@ -128,11 +128,11 @@ impl ImportPipeline {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut model = Model::new(&self.model_name);
|
let mut wb = Workbook::new(&self.model_name);
|
||||||
|
|
||||||
for cat_proposal in &categories {
|
for cat_proposal in &categories {
|
||||||
model.add_category(&cat_proposal.field)?;
|
wb.add_category(&cat_proposal.field)?;
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
|
||||||
for val in &cat_proposal.distinct_values {
|
for val in &cat_proposal.distinct_values {
|
||||||
cat.add_item(val);
|
cat.add_item(val);
|
||||||
}
|
}
|
||||||
@ -141,16 +141,16 @@ impl ImportPipeline {
|
|||||||
|
|
||||||
// Create derived date-component categories
|
// Create derived date-component categories
|
||||||
for (_, _, _, derived_name) in &date_extractions {
|
for (_, _, _, derived_name) in &date_extractions {
|
||||||
model.add_category(derived_name)?;
|
wb.add_category(derived_name)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create label categories (stored but not pivoted by default)
|
// Create label categories (stored but not pivoted by default)
|
||||||
for lab in &labels {
|
for lab in &labels {
|
||||||
model.add_label_category(&lab.field)?;
|
wb.add_label_category(&lab.field)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !measures.is_empty()
|
if !measures.is_empty()
|
||||||
&& let Some(cat) = model.category_mut("_Measure")
|
&& let Some(cat) = wb.model.category_mut("_Measure")
|
||||||
{
|
{
|
||||||
for m in &measures {
|
for m in &measures {
|
||||||
cat.add_item(&m.field);
|
cat.add_item(&m.field);
|
||||||
@ -170,7 +170,7 @@ impl ImportPipeline {
|
|||||||
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
||||||
|
|
||||||
if let Some(v) = val {
|
if let Some(v) = val {
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
|
||||||
cat.add_item(&v);
|
cat.add_item(&v);
|
||||||
}
|
}
|
||||||
coords.push((cat_proposal.field.clone(), v.clone()));
|
coords.push((cat_proposal.field.clone(), v.clone()));
|
||||||
@ -180,7 +180,7 @@ impl ImportPipeline {
|
|||||||
if *field == cat_proposal.field
|
if *field == cat_proposal.field
|
||||||
&& let Some(derived_val) = extract_date_component(&v, fmt, *comp)
|
&& let Some(derived_val) = extract_date_component(&v, fmt, *comp)
|
||||||
{
|
{
|
||||||
if let Some(cat) = model.category_mut(derived_name) {
|
if let Some(cat) = wb.model.category_mut(derived_name) {
|
||||||
cat.add_item(&derived_val);
|
cat.add_item(&derived_val);
|
||||||
}
|
}
|
||||||
coords.push((derived_name.clone(), derived_val));
|
coords.push((derived_name.clone(), derived_val));
|
||||||
@ -212,7 +212,7 @@ impl ImportPipeline {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if let Some(cat) = model.category_mut(&lab.field) {
|
if let Some(cat) = wb.model.category_mut(&lab.field) {
|
||||||
cat.add_item(&val);
|
cat.add_item(&val);
|
||||||
}
|
}
|
||||||
coords.push((lab.field.clone(), val));
|
coords.push((lab.field.clone(), val));
|
||||||
@ -222,7 +222,7 @@ impl ImportPipeline {
|
|||||||
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
||||||
let mut cell_coords = coords.clone();
|
let mut cell_coords = coords.clone();
|
||||||
cell_coords.push(("_Measure".to_string(), measure.field.clone()));
|
cell_coords.push(("_Measure".to_string(), measure.field.clone()));
|
||||||
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
|
wb.model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,11 +233,11 @@ impl ImportPipeline {
|
|||||||
let formula_cat: String = "_Measure".to_string();
|
let formula_cat: String = "_Measure".to_string();
|
||||||
for raw in &self.formulas {
|
for raw in &self.formulas {
|
||||||
if let Ok(formula) = parse_formula(raw, &formula_cat) {
|
if let Ok(formula) = parse_formula(raw, &formula_cat) {
|
||||||
model.add_formula(formula);
|
wb.model.add_formula(formula);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(model)
|
Ok(wb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,7 +521,7 @@ impl ImportWizard {
|
|||||||
|
|
||||||
// ── Delegate build to pipeline ────────────────────────────────────────────
|
// ── Delegate build to pipeline ────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn build_model(&self) -> Result<Model> {
|
pub fn build_model(&self) -> Result<Workbook> {
|
||||||
self.pipeline.build_model()
|
self.pipeline.build_model()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -616,9 +616,9 @@ mod tests {
|
|||||||
{"region": "West", "revenue": 200.0},
|
{"region": "West", "revenue": 200.0},
|
||||||
]);
|
]);
|
||||||
let p = ImportPipeline::new(raw);
|
let p = ImportPipeline::new(raw);
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
assert!(model.category("region").is_some());
|
assert!(wb.model.category("region").is_some());
|
||||||
assert!(model.category("_Measure").is_some());
|
assert!(wb.model.category("_Measure").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -634,9 +634,9 @@ mod tests {
|
|||||||
assert_eq!(desc.kind, FieldKind::Label);
|
assert_eq!(desc.kind, FieldKind::Label);
|
||||||
assert!(desc.accepted, "labels should default to accepted");
|
assert!(desc.accepted, "labels should default to accepted");
|
||||||
|
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
// Label field exists as a category with Label kind
|
// Label field exists as a category with Label kind
|
||||||
let cat = model.category("desc").expect("desc category exists");
|
let cat = wb.model.category("desc").expect("desc category exists");
|
||||||
assert_eq!(cat.kind, CategoryKind::Label);
|
assert_eq!(cat.kind, CategoryKind::Label);
|
||||||
// Each record's cell key carries the desc label coord
|
// Each record's cell key carries the desc label coord
|
||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::CellKey;
|
||||||
@ -645,7 +645,7 @@ mod tests {
|
|||||||
("desc".to_string(), "row-7".to_string()),
|
("desc".to_string(), "row-7".to_string()),
|
||||||
("region".to_string(), "East".to_string()),
|
("region".to_string(), "East".to_string()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
|
assert_eq!(wb.model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -656,8 +656,8 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
let raw = serde_json::Value::Array(records);
|
let raw = serde_json::Value::Array(records);
|
||||||
let p = ImportPipeline::new(raw);
|
let p = ImportPipeline::new(raw);
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
let v = model.active_view();
|
let v = wb.active_view();
|
||||||
assert_eq!(v.axis_of("desc"), Axis::None);
|
assert_eq!(v.axis_of("desc"), Axis::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -668,7 +668,7 @@ mod tests {
|
|||||||
{"region": "West", "revenue": 200.0},
|
{"region": "West", "revenue": 200.0},
|
||||||
]);
|
]);
|
||||||
let p = ImportPipeline::new(raw);
|
let p = ImportPipeline::new(raw);
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::CellKey;
|
||||||
let k_east = CellKey::new(vec![
|
let k_east = CellKey::new(vec![
|
||||||
("_Measure".to_string(), "revenue".to_string()),
|
("_Measure".to_string(), "revenue".to_string()),
|
||||||
@ -679,11 +679,11 @@ mod tests {
|
|||||||
("region".to_string(), "West".to_string()),
|
("region".to_string(), "West".to_string()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
model.get_cell(&k_east).and_then(|v| v.as_f64()),
|
wb.model.get_cell(&k_east).and_then(|v| v.as_f64()),
|
||||||
Some(100.0)
|
Some(100.0)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
model.get_cell(&k_west).and_then(|v| v.as_f64()),
|
wb.model.get_cell(&k_west).and_then(|v| v.as_f64()),
|
||||||
Some(200.0)
|
Some(200.0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -703,14 +703,14 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let mut p = ImportPipeline::new(raw);
|
let mut p = ImportPipeline::new(raw);
|
||||||
p.formulas.push("Profit = revenue - cost".to_string());
|
p.formulas.push("Profit = revenue - cost".to_string());
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
// The formula should produce Profit = 60 for East (100-40)
|
// The formula should produce Profit = 60 for East (100-40)
|
||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::CellKey;
|
||||||
let key = CellKey::new(vec![
|
let key = CellKey::new(vec![
|
||||||
("_Measure".to_string(), "Profit".to_string()),
|
("_Measure".to_string(), "Profit".to_string()),
|
||||||
("region".to_string(), "East".to_string()),
|
("region".to_string(), "East".to_string()),
|
||||||
]);
|
]);
|
||||||
let val = model.evaluate(&key).and_then(|v| v.as_f64());
|
let val = wb.model.evaluate(&key).and_then(|v| v.as_f64());
|
||||||
assert_eq!(val, Some(60.0));
|
assert_eq!(val, Some(60.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -730,9 +730,9 @@ mod tests {
|
|||||||
prop.date_components.push(DateComponent::Month);
|
prop.date_components.push(DateComponent::Month);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
assert!(model.category("Date_Month").is_some());
|
assert!(wb.model.category("Date_Month").is_some());
|
||||||
let cat = model.category("Date_Month").unwrap();
|
let cat = wb.model.category("Date_Month").unwrap();
|
||||||
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
|
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
|
||||||
assert!(items.contains(&"2025-01"));
|
assert!(items.contains(&"2025-01"));
|
||||||
assert!(items.contains(&"2025-02"));
|
assert!(items.contains(&"2025-02"));
|
||||||
@ -1046,14 +1046,14 @@ mod tests {
|
|||||||
{"revenue": 200.0}, // missing "region"
|
{"revenue": 200.0}, // missing "region"
|
||||||
]);
|
]);
|
||||||
let p = ImportPipeline::new(raw);
|
let p = ImportPipeline::new(raw);
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
// Only one cell should exist (the East record)
|
// Only one cell should exist (the East record)
|
||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::CellKey;
|
||||||
let k = CellKey::new(vec![
|
let k = CellKey::new(vec![
|
||||||
("_Measure".to_string(), "revenue".to_string()),
|
("_Measure".to_string(), "revenue".to_string()),
|
||||||
("region".to_string(), "East".to_string()),
|
("region".to_string(), "East".to_string()),
|
||||||
]);
|
]);
|
||||||
assert!(model.get_cell(&k).is_some());
|
assert!(wb.model.get_cell(&k).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1067,8 +1067,8 @@ mod tests {
|
|||||||
{"id": "A", "type": "y", "value": 150.0},
|
{"id": "A", "type": "y", "value": 150.0},
|
||||||
]);
|
]);
|
||||||
let p = ImportPipeline::new(raw);
|
let p = ImportPipeline::new(raw);
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
let cat = model.category("id").expect("id should be a category");
|
let cat = wb.model.category("id").expect("id should be a category");
|
||||||
let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
|
let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
|
||||||
assert!(items.contains(&"A"));
|
assert!(items.contains(&"A"));
|
||||||
assert!(items.contains(&"B"));
|
assert!(items.contains(&"B"));
|
||||||
@ -1085,11 +1085,11 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let mut p = ImportPipeline::new(raw);
|
let mut p = ImportPipeline::new(raw);
|
||||||
p.formulas.push("Test = A + B".to_string());
|
p.formulas.push("Test = A + B".to_string());
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
// Formula should still be added (even if target category is suboptimal)
|
// Formula should still be added (even if target category is suboptimal)
|
||||||
// The formula may fail to parse against a non-measure category, which is OK
|
// The formula may fail to parse against a non-measure category, which is OK
|
||||||
// Just ensure build_model doesn't panic
|
// Just ensure build_model doesn't panic
|
||||||
assert!(model.category("region").is_some());
|
assert!(wb.model.category("region").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1106,12 +1106,12 @@ mod tests {
|
|||||||
prop.date_components.push(DateComponent::Month);
|
prop.date_components.push(DateComponent::Month);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let model = p.build_model().unwrap();
|
let wb = p.build_model().unwrap();
|
||||||
let key = CellKey::new(vec![
|
let key = CellKey::new(vec![
|
||||||
("Date".to_string(), "03/31/2026".to_string()),
|
("Date".to_string(), "03/31/2026".to_string()),
|
||||||
("Date_Month".to_string(), "2026-03".to_string()),
|
("Date_Month".to_string(), "2026-03".to_string()),
|
||||||
("_Measure".to_string(), "Amount".to_string()),
|
("_Measure".to_string(), "Amount".to_string()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
|
assert_eq!(wb.model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,3 +7,4 @@ pub mod model;
|
|||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
pub mod workbook;
|
||||||
|
|||||||
41
src/main.rs
41
src/main.rs
@ -1,10 +1,10 @@
|
|||||||
use improvise::command;
|
use improvise::command;
|
||||||
use improvise::draw;
|
use improvise::draw;
|
||||||
use improvise::import;
|
use improvise::import;
|
||||||
use improvise::model;
|
|
||||||
use improvise::persistence;
|
use improvise::persistence;
|
||||||
use improvise::ui;
|
use improvise::ui;
|
||||||
use improvise::view;
|
use improvise::view;
|
||||||
|
use improvise::workbook::Workbook;
|
||||||
|
|
||||||
use improvise::import::csv_parser::csv_path_p;
|
use improvise::import::csv_parser::csv_path_p;
|
||||||
|
|
||||||
@ -15,7 +15,6 @@ use clap::{Parser, Subcommand};
|
|||||||
use enum_dispatch::enum_dispatch;
|
use enum_dispatch::enum_dispatch;
|
||||||
|
|
||||||
use draw::run_tui;
|
use draw::run_tui;
|
||||||
use model::Model;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@ -122,8 +121,8 @@ struct ScriptArgs {
|
|||||||
struct OpenTui;
|
struct OpenTui;
|
||||||
impl Runnable for OpenTui {
|
impl Runnable for OpenTui {
|
||||||
fn run(self, model_file: Option<PathBuf>) -> Result<()> {
|
fn run(self, model_file: Option<PathBuf>) -> Result<()> {
|
||||||
let model = get_initial_model(&model_file)?;
|
let workbook = get_initial_workbook(&model_file)?;
|
||||||
run_tui(model, model_file, None)
|
run_tui(workbook, model_file, None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,9 +230,9 @@ fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, confi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) {
|
fn apply_axis_overrides(wb: &mut Workbook, axes: &[(String, String)]) {
|
||||||
use view::Axis;
|
use view::Axis;
|
||||||
let view = model.active_view_mut();
|
let view = wb.active_view_mut();
|
||||||
for (cat, axis_str) in axes {
|
for (cat, axis_str) in axes {
|
||||||
let axis = match axis_str.to_lowercase().as_str() {
|
let axis = match axis_str.to_lowercase().as_str() {
|
||||||
"row" => Axis::Row,
|
"row" => Axis::Row,
|
||||||
@ -254,12 +253,12 @@ fn run_headless_import(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
|
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
|
||||||
apply_config_to_pipeline(&mut pipeline, config);
|
apply_config_to_pipeline(&mut pipeline, config);
|
||||||
let mut model = pipeline.build_model()?;
|
let mut wb = pipeline.build_model()?;
|
||||||
model.normalize_view_state();
|
wb.normalize_view_state();
|
||||||
apply_axis_overrides(&mut model, &config.axes);
|
apply_axis_overrides(&mut wb, &config.axes);
|
||||||
|
|
||||||
if let Some(path) = output.or(model_file) {
|
if let Some(path) = output.or(model_file) {
|
||||||
persistence::save(&model, &path)?;
|
persistence::save(&wb, &path)?;
|
||||||
eprintln!("Saved to {}", path.display());
|
eprintln!("Saved to {}", path.display());
|
||||||
} else {
|
} else {
|
||||||
eprintln!("No output path specified; use -o <path> or provide a model file");
|
eprintln!("No output path specified; use -o <path> or provide a model file");
|
||||||
@ -272,11 +271,11 @@ fn run_wizard_import(
|
|||||||
_config: &ImportConfig,
|
_config: &ImportConfig,
|
||||||
model_file: Option<PathBuf>,
|
model_file: Option<PathBuf>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let model = get_initial_model(&model_file)?;
|
let workbook = get_initial_workbook(&model_file)?;
|
||||||
// Pre-configure will happen inside the TUI via the wizard
|
// Pre-configure will happen inside the TUI via the wizard
|
||||||
// For now, pass import_value and let the wizard handle it
|
// For now, pass import_value and let the wizard handle it
|
||||||
// TODO: pass config to wizard for pre-population
|
// TODO: pass config to wizard for pre-population
|
||||||
run_tui(model, model_file, Some(import_value))
|
run_tui(workbook, model_file, Some(import_value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import data loading ──────────────────────────────────────────────────────
|
// ── Import data loading ──────────────────────────────────────────────────────
|
||||||
@ -331,8 +330,8 @@ fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
|
|||||||
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
|
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
|
||||||
let model = get_initial_model(file)?;
|
let workbook = get_initial_workbook(file)?;
|
||||||
let mut app = ui::app::App::new(model, file.clone());
|
let mut app = ui::app::App::new(workbook, file.clone());
|
||||||
let mut exit_code = 0;
|
let mut exit_code = 0;
|
||||||
|
|
||||||
for line in cmds {
|
for line in cmds {
|
||||||
@ -354,7 +353,7 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path) = file {
|
if let Some(path) = file {
|
||||||
persistence::save(&app.model, path)?;
|
persistence::save(&app.workbook, path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::process::exit(exit_code);
|
std::process::exit(exit_code);
|
||||||
@ -368,22 +367,22 @@ fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
|
fn get_initial_workbook(file_path: &Option<PathBuf>) -> Result<Workbook> {
|
||||||
if let Some(path) = file_path {
|
if let Some(path) = file_path {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let mut m = persistence::load(path)
|
let mut wb = persistence::load(path)
|
||||||
.with_context(|| format!("Failed to load {}", path.display()))?;
|
.with_context(|| format!("Failed to load {}", path.display()))?;
|
||||||
m.normalize_view_state();
|
wb.normalize_view_state();
|
||||||
Ok(m)
|
Ok(wb)
|
||||||
} else {
|
} else {
|
||||||
let name = path
|
let name = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("New Model")
|
.unwrap_or("New Model")
|
||||||
.to_string();
|
.to_string();
|
||||||
Ok(Model::new(name))
|
Ok(Workbook::new(name))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(Model::new("New Model"))
|
Ok(Workbook::new("New Model"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,18 +7,24 @@ use serde::{Deserialize, Serialize};
|
|||||||
use super::category::{Category, CategoryId};
|
use super::category::{Category, CategoryId};
|
||||||
use super::cell::{CellKey, CellValue, DataStore};
|
use super::cell::{CellKey, CellValue, DataStore};
|
||||||
use crate::formula::{AggFunc, Formula};
|
use crate::formula::{AggFunc, Formula};
|
||||||
use crate::view::View;
|
|
||||||
|
|
||||||
const MAX_CATEGORIES: usize = 12;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub categories: IndexMap<String, Category>,
|
pub categories: IndexMap<String, Category>,
|
||||||
pub data: DataStore,
|
pub data: DataStore,
|
||||||
formulas: Vec<Formula>,
|
formulas: Vec<Formula>,
|
||||||
pub views: IndexMap<String, View>,
|
|
||||||
pub active_view: String,
|
|
||||||
next_category_id: CategoryId,
|
next_category_id: CategoryId,
|
||||||
/// Per-measure aggregation function (measure item name → agg func).
|
/// Per-measure aggregation function (measure item name → agg func).
|
||||||
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
|
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
|
||||||
@ -33,11 +39,8 @@ impl Model {
|
|||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
use crate::model::category::CategoryKind;
|
use crate::model::category::CategoryKind;
|
||||||
let name = name.into();
|
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();
|
let mut categories = IndexMap::new();
|
||||||
// Virtual categories — always present, default to Axis::None
|
// Virtual categories — always present.
|
||||||
categories.insert(
|
categories.insert(
|
||||||
"_Index".to_string(),
|
"_Index".to_string(),
|
||||||
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
|
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
|
||||||
@ -50,30 +53,20 @@ impl Model {
|
|||||||
"_Measure".to_string(),
|
"_Measure".to_string(),
|
||||||
Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure),
|
Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure),
|
||||||
);
|
);
|
||||||
let mut m = Self {
|
Self {
|
||||||
name,
|
name,
|
||||||
categories,
|
categories,
|
||||||
data: DataStore::new(),
|
data: DataStore::new(),
|
||||||
formulas: Vec::new(),
|
formulas: Vec::new(),
|
||||||
views,
|
|
||||||
active_view: "Default".to_string(),
|
|
||||||
next_category_id: 3,
|
next_category_id: 3,
|
||||||
measure_agg: HashMap::new(),
|
measure_agg: HashMap::new(),
|
||||||
formula_cache: 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> {
|
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
// Only regular pivot categories count against the limit.
|
// Only regular pivot categories count against the limit.
|
||||||
@ -92,19 +85,15 @@ impl Model {
|
|||||||
self.next_category_id += 1;
|
self.next_category_id += 1;
|
||||||
self.categories
|
self.categories
|
||||||
.insert(name.clone(), Category::new(id, name.clone()));
|
.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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a Label-kind category: stored alongside regular categories so
|
/// Add a Label-kind category: stored alongside regular categories so
|
||||||
/// records views can display it, but default to `Axis::None` and
|
/// records views can display it, but excluded from the pivot-category
|
||||||
/// excluded from the pivot-category count limit.
|
/// 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> {
|
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
use crate::model::category::CategoryKind;
|
use crate::model::category::CategoryKind;
|
||||||
use crate::view::Axis;
|
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
if self.categories.contains_key(&name) {
|
if self.categories.contains_key(&name) {
|
||||||
return Ok(self.categories[&name].id);
|
return Ok(self.categories[&name].id);
|
||||||
@ -113,23 +102,17 @@ impl Model {
|
|||||||
self.next_category_id += 1;
|
self.next_category_id += 1;
|
||||||
let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label);
|
let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label);
|
||||||
self.categories.insert(name.clone(), cat);
|
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)
|
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) {
|
pub fn remove_category(&mut self, name: &str) {
|
||||||
if !self.categories.contains_key(name) {
|
if !self.categories.contains_key(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.categories.shift_remove(name);
|
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
|
// Remove cells that have a coord in this category
|
||||||
let to_remove: Vec<CellKey> = self
|
let to_remove: Vec<CellKey> = self
|
||||||
.data
|
.data
|
||||||
@ -208,59 +191,6 @@ impl Model {
|
|||||||
&self.formulas
|
&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
|
/// Return all category names
|
||||||
/// Names of all categories (including virtual ones).
|
/// Names of all categories (including virtual ones).
|
||||||
pub fn category_names(&self) -> Vec<&str> {
|
pub fn category_names(&self) -> Vec<&str> {
|
||||||
@ -848,7 +778,6 @@ impl Model {
|
|||||||
mod model_tests {
|
mod model_tests {
|
||||||
use super::Model;
|
use super::Model;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::view::Axis;
|
|
||||||
|
|
||||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
CellKey::new(
|
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]
|
#[test]
|
||||||
fn add_category_creates_entry() {
|
fn add_category_creates_entry() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
@ -892,14 +814,6 @@ mod model_tests {
|
|||||||
assert!(m.add_category("TooMany").is_err());
|
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]
|
#[test]
|
||||||
fn set_and_get_cell_roundtrip() {
|
fn set_and_get_cell_roundtrip() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
@ -981,80 +895,6 @@ mod model_tests {
|
|||||||
0,
|
0,
|
||||||
"all cells with Region coord should be removed"
|
"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]
|
#[test]
|
||||||
@ -1702,6 +1542,7 @@ mod five_category {
|
|||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[
|
const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[
|
||||||
("East", "Shirts", "Online", "Q1", 1_000.0, 600.0),
|
("East", "Shirts", "Online", "Q1", 1_000.0, 600.0),
|
||||||
@ -1772,6 +1613,52 @@ mod five_category {
|
|||||||
m
|
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 {
|
fn approx(a: f64, b: f64) -> bool {
|
||||||
(a - b).abs() < 1e-9
|
(a - b).abs() < 1e-9
|
||||||
}
|
}
|
||||||
@ -1938,8 +1825,8 @@ mod five_category {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_view_first_two_on_axes_rest_on_page() {
|
fn default_view_first_two_on_axes_rest_on_page() {
|
||||||
let m = build_model();
|
let wb = build_workbook();
|
||||||
let v = m.active_view();
|
let v = wb.active_view();
|
||||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||||
assert_eq!(v.axis_of("Channel"), Axis::Page);
|
assert_eq!(v.axis_of("Channel"), Axis::Page);
|
||||||
@ -1949,9 +1836,9 @@ mod five_category {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rearranging_axes_does_not_affect_data() {
|
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("Region", Axis::Page);
|
||||||
v.set_axis("Product", Axis::Page);
|
v.set_axis("Product", Axis::Page);
|
||||||
v.set_axis("Channel", Axis::Row);
|
v.set_axis("Channel", Axis::Row);
|
||||||
@ -1959,44 +1846,48 @@ mod five_category {
|
|||||||
v.set_axis("_Measure", Axis::Page);
|
v.set_axis("_Measure", Axis::Page);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
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))
|
Some(&CellValue::Number(1_000.0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_views_have_independent_axis_assignments() {
|
fn two_views_have_independent_axis_assignments() {
|
||||||
let mut m = build_model();
|
let mut wb = build_workbook();
|
||||||
m.create_view("Pivot");
|
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("Time", Axis::Row);
|
||||||
v.set_axis("Channel", Axis::Column);
|
v.set_axis("Channel", Axis::Column);
|
||||||
v.set_axis("Region", Axis::Page);
|
v.set_axis("Region", Axis::Page);
|
||||||
v.set_axis("Product", Axis::Page);
|
v.set_axis("Product", Axis::Page);
|
||||||
v.set_axis("_Measure", 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!(
|
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
|
Axis::Column
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_selections_are_per_view() {
|
fn page_selections_are_per_view() {
|
||||||
let mut m = build_model();
|
let mut wb = build_workbook();
|
||||||
m.create_view("West only");
|
wb.create_view("West only");
|
||||||
if let Some(v) = m.views.get_mut("West only") {
|
if let Some(v) = wb.views.get_mut("West only") {
|
||||||
v.set_page_selection("Region", "West");
|
v.set_page_selection("Region", "West");
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.views.get("Default").unwrap().page_selection("Region"),
|
wb.views.get("Default").unwrap().page_selection("Region"),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.views.get("West only").unwrap().page_selection("Region"),
|
wb.views.get("West only").unwrap().page_selection("Region"),
|
||||||
Some("West")
|
Some("West")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
192
src/ui/app.rs
192
src/ui/app.rs
@ -12,13 +12,13 @@ use ratatui::style::Color;
|
|||||||
use crate::command::cmd::CmdContext;
|
use crate::command::cmd::CmdContext;
|
||||||
use crate::command::keymap::{Keymap, KeymapSet};
|
use crate::command::keymap::{Keymap, KeymapSet};
|
||||||
use crate::import::wizard::ImportWizard;
|
use crate::import::wizard::ImportWizard;
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::CellValue;
|
use crate::model::cell::CellValue;
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
use crate::ui::grid::{
|
use crate::ui::grid::{
|
||||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||||
};
|
};
|
||||||
use crate::view::GridLayout;
|
use crate::view::GridLayout;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||||
/// yet been applied to the model.
|
/// yet been applied to the model.
|
||||||
@ -152,7 +152,7 @@ impl AppMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub model: Model,
|
pub workbook: Workbook,
|
||||||
pub file_path: Option<PathBuf>,
|
pub file_path: Option<PathBuf>,
|
||||||
pub mode: AppMode,
|
pub mode: AppMode,
|
||||||
pub status_msg: String,
|
pub status_msg: String,
|
||||||
@ -199,22 +199,19 @@ pub struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(mut model: Model, file_path: Option<PathBuf>) -> Self {
|
pub fn new(mut workbook: Workbook, file_path: Option<PathBuf>) -> Self {
|
||||||
// Recompute formula cache before building the initial layout so
|
// Recompute formula cache before building the initial layout so
|
||||||
// formula-derived values are available on the first frame.
|
// formula-derived values are available on the first frame. The
|
||||||
let none_cats: Vec<String> = model
|
// cache is keyed by the active view's None-axis categories, so
|
||||||
.active_view()
|
// the caller must gather them explicitly.
|
||||||
.categories_on(crate::view::Axis::None)
|
let none_cats = workbook.active_view().none_cats();
|
||||||
.into_iter()
|
workbook.model.recompute_formulas(&none_cats);
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
model.recompute_formulas(&none_cats);
|
|
||||||
let layout = {
|
let layout = {
|
||||||
let view = model.active_view();
|
let view = workbook.active_view();
|
||||||
GridLayout::with_frozen_records(&model, view, None)
|
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
model,
|
workbook,
|
||||||
file_path,
|
file_path,
|
||||||
mode: AppMode::Normal,
|
mode: AppMode::Normal,
|
||||||
status_msg: String::new(),
|
status_msg: String::new(),
|
||||||
@ -245,29 +242,24 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the grid layout from current model, view, and drill state.
|
/// Rebuild the grid layout from current workbook, active view, and drill
|
||||||
/// Note: `with_frozen_records` already handles pruning internally.
|
/// state. Note: `with_frozen_records` already handles pruning internally.
|
||||||
pub fn rebuild_layout(&mut self) {
|
pub fn rebuild_layout(&mut self) {
|
||||||
// Gather none_cats before mutable borrow for formula recomputation
|
let none_cats = self.workbook.active_view().none_cats();
|
||||||
let none_cats: Vec<String> = self
|
self.workbook.model.recompute_formulas(&none_cats);
|
||||||
.model
|
let view = self.workbook.active_view();
|
||||||
.active_view()
|
|
||||||
.categories_on(crate::view::Axis::None)
|
|
||||||
.into_iter()
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
self.model.recompute_formulas(&none_cats);
|
|
||||||
let view = self.model.active_view();
|
|
||||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||||
self.layout = GridLayout::with_frozen_records(&self.model, view, frozen);
|
self.layout = GridLayout::with_frozen_records(&self.workbook.model, view, frozen);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||||||
let view = self.model.active_view();
|
let view = self.workbook.active_view();
|
||||||
let layout = &self.layout;
|
let layout = &self.layout;
|
||||||
let (sel_row, sel_col) = view.selected;
|
let (sel_row, sel_col) = view.selected;
|
||||||
CmdContext {
|
CmdContext {
|
||||||
model: &self.model,
|
model: &self.workbook.model,
|
||||||
|
workbook: &self.workbook,
|
||||||
|
view,
|
||||||
layout,
|
layout,
|
||||||
registry: self.keymap_set.registry(),
|
registry: self.keymap_set.registry(),
|
||||||
mode: &self.mode,
|
mode: &self.mode,
|
||||||
@ -298,7 +290,8 @@ impl App {
|
|||||||
.or_else(|| layout.resolve_display(k))
|
.or_else(|| layout.resolve_display(k))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
self.model
|
self.workbook
|
||||||
|
.model
|
||||||
.get_cell(k)
|
.get_cell(k)
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@ -310,7 +303,7 @@ impl App {
|
|||||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||||
visible_cols: {
|
visible_cols: {
|
||||||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||||
let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals);
|
let col_widths = compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals);
|
||||||
let row_header_width = compute_row_header_width(layout);
|
let row_header_width = compute_row_header_width(layout);
|
||||||
compute_visible_cols(
|
compute_visible_cols(
|
||||||
&col_widths,
|
&col_widths,
|
||||||
@ -335,7 +328,7 @@ impl App {
|
|||||||
/// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
|
/// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
|
||||||
pub fn is_empty_model(&self) -> bool {
|
pub fn is_empty_model(&self) -> bool {
|
||||||
use crate::model::category::CategoryKind;
|
use crate::model::category::CategoryKind;
|
||||||
self.model.categories.values().all(|c| {
|
self.workbook.model.categories.values().all(|c| {
|
||||||
matches!(
|
matches!(
|
||||||
c.kind,
|
c.kind,
|
||||||
CategoryKind::VirtualIndex
|
CategoryKind::VirtualIndex
|
||||||
@ -379,7 +372,7 @@ impl App {
|
|||||||
&& let Some(path) = &self.file_path.clone()
|
&& let Some(path) = &self.file_path.clone()
|
||||||
{
|
{
|
||||||
let ap = persistence::autosave_path(path);
|
let ap = persistence::autosave_path(path);
|
||||||
let _ = persistence::save(&self.model, &ap);
|
let _ = persistence::save(&self.workbook, &ap);
|
||||||
self.last_autosave = Instant::now();
|
self.last_autosave = Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -422,18 +415,17 @@ impl App {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::Model;
|
|
||||||
|
|
||||||
fn two_col_model() -> App {
|
fn two_col_model() -> App {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Row").unwrap(); // → Row axis
|
wb.add_category("Row").unwrap(); // → Row axis
|
||||||
m.add_category("Col").unwrap(); // → Column axis
|
wb.add_category("Col").unwrap(); // → Column axis
|
||||||
m.category_mut("Row").unwrap().add_item("A");
|
wb.model.category_mut("Row").unwrap().add_item("A");
|
||||||
m.category_mut("Row").unwrap().add_item("B");
|
wb.model.category_mut("Row").unwrap().add_item("B");
|
||||||
m.category_mut("Row").unwrap().add_item("C");
|
wb.model.category_mut("Row").unwrap().add_item("C");
|
||||||
m.category_mut("Col").unwrap().add_item("X");
|
wb.model.category_mut("Col").unwrap().add_item("X");
|
||||||
m.category_mut("Col").unwrap().add_item("Y");
|
wb.model.category_mut("Col").unwrap().add_item("Y");
|
||||||
App::new(m, None)
|
App::new(wb, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
|
fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
|
||||||
@ -445,7 +437,7 @@ mod tests {
|
|||||||
|
|
||||||
fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance {
|
fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance {
|
||||||
use crate::command::cmd::navigation::CursorState;
|
use crate::command::cmd::navigation::CursorState;
|
||||||
let view = app.model.active_view();
|
let view = app.workbook.active_view();
|
||||||
let cursor = CursorState {
|
let cursor = CursorState {
|
||||||
row: view.selected.0,
|
row: view.selected.0,
|
||||||
col: view.selected.1,
|
col: view.selected.1,
|
||||||
@ -462,29 +454,29 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn enter_advance_moves_down_within_column() {
|
fn enter_advance_moves_down_within_column() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.workbook.active_view_mut().selected = (0, 0);
|
||||||
let cmd = enter_advance_cmd(&app);
|
let cmd = enter_advance_cmd(&app);
|
||||||
run_cmd(&mut app, &cmd);
|
run_cmd(&mut app, &cmd);
|
||||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_advance_wraps_to_top_of_next_column() {
|
fn enter_advance_wraps_to_top_of_next_column() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
|
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
|
||||||
app.model.active_view_mut().selected = (2, 0);
|
app.workbook.active_view_mut().selected = (2, 0);
|
||||||
let cmd = enter_advance_cmd(&app);
|
let cmd = enter_advance_cmd(&app);
|
||||||
run_cmd(&mut app, &cmd);
|
run_cmd(&mut app, &cmd);
|
||||||
assert_eq!(app.model.active_view().selected, (0, 1));
|
assert_eq!(app.workbook.active_view().selected, (0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_advance_stays_at_bottom_right() {
|
fn enter_advance_stays_at_bottom_right() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model.active_view_mut().selected = (2, 1);
|
app.workbook.active_view_mut().selected = (2, 1);
|
||||||
let cmd = enter_advance_cmd(&app);
|
let cmd = enter_advance_cmd(&app);
|
||||||
run_cmd(&mut app, &cmd);
|
run_cmd(&mut app, &cmd);
|
||||||
assert_eq!(app.model.active_view().selected, (2, 1));
|
assert_eq!(app.workbook.active_view().selected, (2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -535,22 +527,22 @@ mod tests {
|
|||||||
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
||||||
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
||||||
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Row").unwrap();
|
wb.add_category("Row").unwrap();
|
||||||
m.add_category("Col").unwrap();
|
wb.add_category("Col").unwrap();
|
||||||
m.category_mut("Row").unwrap().add_item("R1");
|
wb.model.category_mut("Row").unwrap().add_item("R1");
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
let name = format!("VeryLongColumnItemName_{i:03}");
|
let name = format!("VeryLongColumnItemName_{i:03}");
|
||||||
m.category_mut("Col").unwrap().add_item(&name);
|
wb.model.category_mut("Col").unwrap().add_item(&name);
|
||||||
}
|
}
|
||||||
// Populate a value so the model isn't empty
|
// Populate a value so the workbook isn't empty
|
||||||
let key = CellKey::new(vec![
|
let key = CellKey::new(vec![
|
||||||
("Row".to_string(), "R1".to_string()),
|
("Row".to_string(), "R1".to_string()),
|
||||||
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
|
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
|
||||||
]);
|
]);
|
||||||
m.set_cell(key, CellValue::Number(1.0));
|
wb.model.set_cell(key, CellValue::Number(1.0));
|
||||||
|
|
||||||
let mut app = App::new(m, None);
|
let mut app = App::new(wb, None);
|
||||||
app.term_width = 80;
|
app.term_width = 80;
|
||||||
|
|
||||||
// Press 'l' (right) 3 times to move cursor to column 3.
|
// Press 'l' (right) 3 times to move cursor to column 3.
|
||||||
@ -563,34 +555,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.model.active_view().selected.1,
|
app.workbook.active_view().selected.1,
|
||||||
3,
|
3,
|
||||||
"cursor should be at column 3"
|
"cursor should be at column 3"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.model.active_view().col_offset > 0,
|
app.workbook.active_view().col_offset > 0,
|
||||||
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
||||||
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
||||||
app.model.active_view().col_offset
|
app.workbook.active_view().col_offset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn home_jumps_to_first_col() {
|
fn home_jumps_to_first_col() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model.active_view_mut().selected = (1, 1);
|
app.workbook.active_view_mut().selected = (1, 1);
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn end_jumps_to_last_col() {
|
fn end_jumps_to_last_col() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model.active_view_mut().selected = (1, 0);
|
app.workbook.active_view_mut().selected = (1, 0);
|
||||||
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected, (1, 1));
|
assert_eq!(app.workbook.active_view().selected, (1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -598,38 +590,40 @@ mod tests {
|
|||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
// Add enough rows
|
// Add enough rows
|
||||||
for i in 0..30 {
|
for i in 0..30 {
|
||||||
app.model
|
app.workbook
|
||||||
|
.model
|
||||||
.category_mut("Row")
|
.category_mut("Row")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item(format!("R{i}"));
|
.add_item(format!("R{i}"));
|
||||||
}
|
}
|
||||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.workbook.active_view_mut().selected = (0, 0);
|
||||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
|
assert_eq!(app.workbook.active_view().selected.1, 0, "column preserved");
|
||||||
assert!(
|
assert!(
|
||||||
app.model.active_view().selected.0 > 0,
|
app.workbook.active_view().selected.0 > 0,
|
||||||
"row should advance on PageDown"
|
"row should advance on PageDown"
|
||||||
);
|
);
|
||||||
// 3/4 of ~20 = 15
|
// 3/4 of ~20 = 15
|
||||||
assert_eq!(app.model.active_view().selected.0, 15);
|
assert_eq!(app.workbook.active_view().selected.0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_up_scrolls_backward() {
|
fn page_up_scrolls_backward() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
for i in 0..30 {
|
for i in 0..30 {
|
||||||
app.model
|
app.workbook
|
||||||
|
.model
|
||||||
.category_mut("Row")
|
.category_mut("Row")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item(format!("R{i}"));
|
.add_item(format!("R{i}"));
|
||||||
}
|
}
|
||||||
app.term_height = 28;
|
app.term_height = 28;
|
||||||
app.model.active_view_mut().selected = (20, 0);
|
app.workbook.active_view_mut().selected = (20, 0);
|
||||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected.0, 5);
|
assert_eq!(app.workbook.active_view().selected.0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -637,21 +631,22 @@ mod tests {
|
|||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
app.model
|
app.workbook
|
||||||
|
.model
|
||||||
.category_mut("Row")
|
.category_mut("Row")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item(format!("R{i}"));
|
.add_item(format!("R{i}"));
|
||||||
}
|
}
|
||||||
app.term_height = 13; // ~5 visible rows
|
app.term_height = 13; // ~5 visible rows
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.workbook.active_view_mut().selected = (0, 0);
|
||||||
// G jumps to last row (row 12)
|
// G jumps to last row (row 12)
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let last = app.model.active_view().selected.0;
|
let last = app.workbook.active_view().selected.0;
|
||||||
assert_eq!(last, 12, "should be at last row");
|
assert_eq!(last, 12, "should be at last row");
|
||||||
// With only ~5 visible rows and 13 rows, offset should scroll.
|
// With only ~5 visible rows and 13 rows, offset should scroll.
|
||||||
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
||||||
let offset = app.model.active_view().row_offset;
|
let offset = app.workbook.active_view().row_offset;
|
||||||
assert!(
|
assert!(
|
||||||
offset > 0,
|
offset > 0,
|
||||||
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
||||||
@ -662,33 +657,34 @@ mod tests {
|
|||||||
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
for i in 0..30 {
|
for i in 0..30 {
|
||||||
app.model
|
app.workbook
|
||||||
|
.model
|
||||||
.category_mut("Row")
|
.category_mut("Row")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item(format!("R{i}"));
|
.add_item(format!("R{i}"));
|
||||||
}
|
}
|
||||||
app.term_height = 13; // ~5 visible rows
|
app.term_height = 13; // ~5 visible rows
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.workbook.active_view_mut().selected = (0, 0);
|
||||||
// Ctrl+d scrolls by 5 rows
|
// Ctrl+d scrolls by 5 rows
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected.0, 5);
|
assert_eq!(app.workbook.active_view().selected.0, 5);
|
||||||
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
||||||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.model.active_view().selected.0, 10);
|
assert_eq!(app.workbook.active_view().selected.0, 10);
|
||||||
assert!(
|
assert!(
|
||||||
app.model.active_view().row_offset > 0,
|
app.workbook.active_view().row_offset > 0,
|
||||||
"row_offset should scroll with small terminal, but is {}",
|
"row_offset should scroll with small terminal, but is {}",
|
||||||
app.model.active_view().row_offset
|
app.workbook.active_view().row_offset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_in_edit_mode_commits_and_moves_right() {
|
fn tab_in_edit_mode_commits_and_moves_right() {
|
||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model.active_view_mut().selected = (0, 0);
|
app.workbook.active_view_mut().selected = (0, 0);
|
||||||
// Enter edit mode
|
// Enter edit mode
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -706,7 +702,7 @@ mod tests {
|
|||||||
app.mode
|
app.mode
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.model.active_view().selected.1,
|
app.workbook.active_view().selected.1,
|
||||||
1,
|
1,
|
||||||
"should have moved to column 1"
|
"should have moved to column 1"
|
||||||
);
|
);
|
||||||
@ -735,7 +731,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_model_is_empty() {
|
fn fresh_model_is_empty() {
|
||||||
let app = App::new(Model::new("T"), None);
|
let app = App::new(Workbook::new("T"), None);
|
||||||
assert!(
|
assert!(
|
||||||
app.is_empty_model(),
|
app.is_empty_model(),
|
||||||
"a brand-new model with only virtual categories should be empty"
|
"a brand-new model with only virtual categories should be empty"
|
||||||
@ -744,9 +740,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_with_user_category_is_not_empty() {
|
fn model_with_user_category_is_not_empty() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Sales").unwrap();
|
wb.add_category("Sales").unwrap();
|
||||||
let app = App::new(m, None);
|
let app = App::new(wb, None);
|
||||||
assert!(
|
assert!(
|
||||||
!app.is_empty_model(),
|
!app.is_empty_model(),
|
||||||
"a model with a user-defined category should not be empty"
|
"a model with a user-defined category should not be empty"
|
||||||
@ -757,7 +753,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_page_next_advances_page() {
|
fn help_page_next_advances_page() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
app.help_page = 0;
|
app.help_page = 0;
|
||||||
|
|
||||||
@ -768,7 +764,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_page_prev_goes_back() {
|
fn help_page_prev_goes_back() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
app.help_page = 2;
|
app.help_page = 2;
|
||||||
|
|
||||||
@ -779,7 +775,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_page_clamps_at_zero() {
|
fn help_page_clamps_at_zero() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
app.help_page = 0;
|
app.help_page = 0;
|
||||||
|
|
||||||
@ -792,7 +788,7 @@ mod tests {
|
|||||||
fn help_page_clamps_at_max() {
|
fn help_page_clamps_at_max() {
|
||||||
use crate::ui::help::HELP_PAGE_COUNT;
|
use crate::ui::help::HELP_PAGE_COUNT;
|
||||||
|
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
app.help_page = HELP_PAGE_COUNT - 1;
|
app.help_page = HELP_PAGE_COUNT - 1;
|
||||||
|
|
||||||
@ -809,7 +805,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_q_returns_to_normal() {
|
fn help_q_returns_to_normal() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
|
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
||||||
@ -822,7 +818,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_esc_returns_to_normal() {
|
fn help_esc_returns_to_normal() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
|
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||||
@ -835,7 +831,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_colon_enters_command_mode() {
|
fn help_colon_enters_command_mode() {
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
app.mode = AppMode::Help;
|
app.mode = AppMode::Help;
|
||||||
|
|
||||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||||
@ -852,7 +848,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_item_to_nonexistent_category_sets_status() {
|
fn add_item_to_nonexistent_category_sets_status() {
|
||||||
use crate::ui::effect::Effect;
|
use crate::ui::effect::Effect;
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
let effect = crate::ui::effect::AddItem {
|
let effect = crate::ui::effect::AddItem {
|
||||||
category: "Nonexistent".to_string(),
|
category: "Nonexistent".to_string(),
|
||||||
item: "x".to_string(),
|
item: "x".to_string(),
|
||||||
@ -868,7 +864,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_formula_with_bad_syntax_sets_status() {
|
fn add_formula_with_bad_syntax_sets_status() {
|
||||||
use crate::ui::effect::Effect;
|
use crate::ui::effect::Effect;
|
||||||
let mut app = App::new(Model::new("T"), None);
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
let effect = crate::ui::effect::AddFormula {
|
let effect = crate::ui::effect::AddFormula {
|
||||||
raw: "!!!invalid".to_string(),
|
raw: "!!!invalid".to_string(),
|
||||||
target_category: "X".to_string(),
|
target_category: "X".to_string(),
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use crate::model::Model;
|
|||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
|
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
|
||||||
use crate::ui::panel::PanelContent;
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||||
match axis {
|
match axis {
|
||||||
@ -20,14 +20,18 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct CategoryContent<'a> {
|
pub struct CategoryContent<'a> {
|
||||||
model: &'a Model,
|
view: &'a View,
|
||||||
tree: Vec<CatTreeEntry>,
|
tree: Vec<CatTreeEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> CategoryContent<'a> {
|
impl<'a> CategoryContent<'a> {
|
||||||
pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet<String>) -> Self {
|
pub fn new(
|
||||||
|
model: &'a Model,
|
||||||
|
view: &'a View,
|
||||||
|
expanded: &'a std::collections::HashSet<String>,
|
||||||
|
) -> Self {
|
||||||
let tree = build_cat_tree(model, expanded);
|
let tree = build_cat_tree(model, expanded);
|
||||||
Self { model, tree }
|
Self { view, tree }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +61,7 @@ impl PanelContent for CategoryContent<'_> {
|
|||||||
|
|
||||||
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
|
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
|
||||||
let y = inner.y + index as u16;
|
let y = inner.y + index as u16;
|
||||||
let view = self.model.active_view();
|
let view = self.view;
|
||||||
|
|
||||||
let base_style = if is_selected {
|
let base_style = if is_selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|||||||
207
src/ui/effect.rs
207
src/ui/effect.rs
@ -22,7 +22,7 @@ pub trait Effect: Debug {
|
|||||||
pub struct AddCategory(pub String);
|
pub struct AddCategory(pub String);
|
||||||
impl Effect for AddCategory {
|
impl Effect for AddCategory {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let _ = app.model.add_category(&self.0);
|
let _ = app.workbook.add_category(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ pub struct AddItem {
|
|||||||
}
|
}
|
||||||
impl Effect for AddItem {
|
impl Effect for AddItem {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(cat) = app.model.category_mut(&self.category) {
|
if let Some(cat) = app.workbook.model.category_mut(&self.category) {
|
||||||
cat.add_item(&self.item);
|
cat.add_item(&self.item);
|
||||||
} else {
|
} else {
|
||||||
app.status_msg = format!("Unknown category '{}'", self.category);
|
app.status_msg = format!("Unknown category '{}'", self.category);
|
||||||
@ -49,7 +49,7 @@ pub struct AddItemInGroup {
|
|||||||
}
|
}
|
||||||
impl Effect for AddItemInGroup {
|
impl Effect for AddItemInGroup {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(cat) = app.model.category_mut(&self.category) {
|
if let Some(cat) = app.workbook.model.category_mut(&self.category) {
|
||||||
cat.add_item_in_group(&self.item, &self.group);
|
cat.add_item_in_group(&self.item, &self.group);
|
||||||
} else {
|
} else {
|
||||||
app.status_msg = format!("Unknown category '{}'", self.category);
|
app.status_msg = format!("Unknown category '{}'", self.category);
|
||||||
@ -61,7 +61,7 @@ impl Effect for AddItemInGroup {
|
|||||||
pub struct SetCell(pub CellKey, pub CellValue);
|
pub struct SetCell(pub CellKey, pub CellValue);
|
||||||
impl Effect for SetCell {
|
impl Effect for SetCell {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.set_cell(self.0.clone(), self.1.clone());
|
app.workbook.model.set_cell(self.0.clone(), self.1.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ impl Effect for SetCell {
|
|||||||
pub struct ClearCell(pub CellKey);
|
pub struct ClearCell(pub CellKey);
|
||||||
impl Effect for ClearCell {
|
impl Effect for ClearCell {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.clear_cell(&self.0);
|
app.workbook.model.clear_cell(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,11 +86,11 @@ impl Effect for AddFormula {
|
|||||||
// appears in the grid. _Measure targets are dynamically included
|
// appears in the grid. _Measure targets are dynamically included
|
||||||
// via Model::measure_item_names().
|
// via Model::measure_item_names().
|
||||||
if formula.target_category != "_Measure"
|
if formula.target_category != "_Measure"
|
||||||
&& let Some(cat) = app.model.category_mut(&formula.target_category)
|
&& let Some(cat) = app.workbook.model.category_mut(&formula.target_category)
|
||||||
{
|
{
|
||||||
cat.add_item(&formula.target);
|
cat.add_item(&formula.target);
|
||||||
}
|
}
|
||||||
app.model.add_formula(formula);
|
app.workbook.model.add_formula(formula);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.status_msg = format!("Formula error: {e}");
|
app.status_msg = format!("Formula error: {e}");
|
||||||
@ -106,7 +106,8 @@ pub struct RemoveFormula {
|
|||||||
}
|
}
|
||||||
impl Effect for RemoveFormula {
|
impl Effect for RemoveFormula {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
|
.model
|
||||||
.remove_formula(&self.target, &self.target_category);
|
.remove_formula(&self.target, &self.target_category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -133,7 +134,7 @@ impl Effect for EnterEditAtCursor {
|
|||||||
pub struct TogglePruneEmpty;
|
pub struct TogglePruneEmpty;
|
||||||
impl Effect for TogglePruneEmpty {
|
impl Effect for TogglePruneEmpty {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let v = app.model.active_view_mut();
|
let v = app.workbook.active_view_mut();
|
||||||
v.prune_empty = !v.prune_empty;
|
v.prune_empty = !v.prune_empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,7 +156,7 @@ pub struct RemoveItem {
|
|||||||
}
|
}
|
||||||
impl Effect for RemoveItem {
|
impl Effect for RemoveItem {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.remove_item(&self.category, &self.item);
|
app.workbook.model.remove_item(&self.category, &self.item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +164,7 @@ impl Effect for RemoveItem {
|
|||||||
pub struct RemoveCategory(pub String);
|
pub struct RemoveCategory(pub String);
|
||||||
impl Effect for RemoveCategory {
|
impl Effect for RemoveCategory {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.remove_category(&self.0);
|
app.workbook.remove_category(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +174,7 @@ impl Effect for RemoveCategory {
|
|||||||
pub struct CreateView(pub String);
|
pub struct CreateView(pub String);
|
||||||
impl Effect for CreateView {
|
impl Effect for CreateView {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.create_view(&self.0);
|
app.workbook.create_view(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +182,7 @@ impl Effect for CreateView {
|
|||||||
pub struct DeleteView(pub String);
|
pub struct DeleteView(pub String);
|
||||||
impl Effect for DeleteView {
|
impl Effect for DeleteView {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let _ = app.model.delete_view(&self.0);
|
let _ = app.workbook.delete_view(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,12 +190,12 @@ impl Effect for DeleteView {
|
|||||||
pub struct SwitchView(pub String);
|
pub struct SwitchView(pub String);
|
||||||
impl Effect for SwitchView {
|
impl Effect for SwitchView {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let current = app.model.active_view.clone();
|
let current = app.workbook.active_view.clone();
|
||||||
if current != self.0 {
|
if current != self.0 {
|
||||||
app.view_back_stack.push(current);
|
app.view_back_stack.push(current);
|
||||||
app.view_forward_stack.clear();
|
app.view_forward_stack.clear();
|
||||||
}
|
}
|
||||||
let _ = app.model.switch_view(&self.0);
|
let _ = app.workbook.switch_view(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,9 +205,9 @@ pub struct ViewBack;
|
|||||||
impl Effect for ViewBack {
|
impl Effect for ViewBack {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(prev) = app.view_back_stack.pop() {
|
if let Some(prev) = app.view_back_stack.pop() {
|
||||||
let current = app.model.active_view.clone();
|
let current = app.workbook.active_view.clone();
|
||||||
app.view_forward_stack.push(current);
|
app.view_forward_stack.push(current);
|
||||||
let _ = app.model.switch_view(&prev);
|
let _ = app.workbook.switch_view(&prev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,9 +218,9 @@ pub struct ViewForward;
|
|||||||
impl Effect for ViewForward {
|
impl Effect for ViewForward {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(next) = app.view_forward_stack.pop() {
|
if let Some(next) = app.view_forward_stack.pop() {
|
||||||
let current = app.model.active_view.clone();
|
let current = app.workbook.active_view.clone();
|
||||||
app.view_back_stack.push(current);
|
app.view_back_stack.push(current);
|
||||||
let _ = app.model.switch_view(&next);
|
let _ = app.workbook.switch_view(&next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -231,7 +232,7 @@ pub struct SetAxis {
|
|||||||
}
|
}
|
||||||
impl Effect for SetAxis {
|
impl Effect for SetAxis {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
.active_view_mut()
|
.active_view_mut()
|
||||||
.set_axis(&self.category, self.axis);
|
.set_axis(&self.category, self.axis);
|
||||||
}
|
}
|
||||||
@ -244,7 +245,7 @@ pub struct SetPageSelection {
|
|||||||
}
|
}
|
||||||
impl Effect for SetPageSelection {
|
impl Effect for SetPageSelection {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
.active_view_mut()
|
.active_view_mut()
|
||||||
.set_page_selection(&self.category, &self.item);
|
.set_page_selection(&self.category, &self.item);
|
||||||
}
|
}
|
||||||
@ -257,7 +258,7 @@ pub struct ToggleGroup {
|
|||||||
}
|
}
|
||||||
impl Effect for ToggleGroup {
|
impl Effect for ToggleGroup {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
.active_view_mut()
|
.active_view_mut()
|
||||||
.toggle_group_collapse(&self.category, &self.group);
|
.toggle_group_collapse(&self.category, &self.group);
|
||||||
}
|
}
|
||||||
@ -270,7 +271,7 @@ pub struct HideItem {
|
|||||||
}
|
}
|
||||||
impl Effect for HideItem {
|
impl Effect for HideItem {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
.active_view_mut()
|
.active_view_mut()
|
||||||
.hide_item(&self.category, &self.item);
|
.hide_item(&self.category, &self.item);
|
||||||
}
|
}
|
||||||
@ -283,7 +284,7 @@ pub struct ShowItem {
|
|||||||
}
|
}
|
||||||
impl Effect for ShowItem {
|
impl Effect for ShowItem {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model
|
app.workbook
|
||||||
.active_view_mut()
|
.active_view_mut()
|
||||||
.show_item(&self.category, &self.item);
|
.show_item(&self.category, &self.item);
|
||||||
}
|
}
|
||||||
@ -293,7 +294,7 @@ impl Effect for ShowItem {
|
|||||||
pub struct TransposeAxes;
|
pub struct TransposeAxes;
|
||||||
impl Effect for TransposeAxes {
|
impl Effect for TransposeAxes {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().transpose_axes();
|
app.workbook.active_view_mut().transpose_axes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,7 +302,7 @@ impl Effect for TransposeAxes {
|
|||||||
pub struct CycleAxis(pub String);
|
pub struct CycleAxis(pub String);
|
||||||
impl Effect for CycleAxis {
|
impl Effect for CycleAxis {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().cycle_axis(&self.0);
|
app.workbook.active_view_mut().cycle_axis(&self.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,7 +310,7 @@ impl Effect for CycleAxis {
|
|||||||
pub struct SetNumberFormat(pub String);
|
pub struct SetNumberFormat(pub String);
|
||||||
impl Effect for SetNumberFormat {
|
impl Effect for SetNumberFormat {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().number_format = self.0.clone();
|
app.workbook.active_view_mut().number_format = self.0.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,7 +320,7 @@ impl Effect for SetNumberFormat {
|
|||||||
pub struct SetSelected(pub usize, pub usize);
|
pub struct SetSelected(pub usize, pub usize);
|
||||||
impl Effect for SetSelected {
|
impl Effect for SetSelected {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().selected = (self.0, self.1);
|
app.workbook.active_view_mut().selected = (self.0, self.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,7 +328,7 @@ impl Effect for SetSelected {
|
|||||||
pub struct SetRowOffset(pub usize);
|
pub struct SetRowOffset(pub usize);
|
||||||
impl Effect for SetRowOffset {
|
impl Effect for SetRowOffset {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().row_offset = self.0;
|
app.workbook.active_view_mut().row_offset = self.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,7 +336,7 @@ impl Effect for SetRowOffset {
|
|||||||
pub struct SetColOffset(pub usize);
|
pub struct SetColOffset(pub usize);
|
||||||
impl Effect for SetColOffset {
|
impl Effect for SetColOffset {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
app.model.active_view_mut().col_offset = self.0;
|
app.workbook.active_view_mut().col_offset = self.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,21 +450,21 @@ impl Effect for ApplyAndClearDrill {
|
|||||||
if col_name == "Value" {
|
if col_name == "Value" {
|
||||||
// Update the cell's value
|
// Update the cell's value
|
||||||
let value = if new_value.is_empty() {
|
let value = if new_value.is_empty() {
|
||||||
app.model.clear_cell(orig_key);
|
app.workbook.model.clear_cell(orig_key);
|
||||||
continue;
|
continue;
|
||||||
} else if let Ok(n) = new_value.parse::<f64>() {
|
} else if let Ok(n) = new_value.parse::<f64>() {
|
||||||
CellValue::Number(n)
|
CellValue::Number(n)
|
||||||
} else {
|
} else {
|
||||||
CellValue::Text(new_value.clone())
|
CellValue::Text(new_value.clone())
|
||||||
};
|
};
|
||||||
app.model.set_cell(orig_key.clone(), value);
|
app.workbook.model.set_cell(orig_key.clone(), value);
|
||||||
} else {
|
} else {
|
||||||
// Rename a coordinate: remove old cell, insert new with updated coord
|
// Rename a coordinate: remove old cell, insert new with updated coord
|
||||||
let value = match app.model.get_cell(orig_key) {
|
let value = match app.workbook.model.get_cell(orig_key) {
|
||||||
Some(v) => v.clone(),
|
Some(v) => v.clone(),
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
app.model.clear_cell(orig_key);
|
app.workbook.model.clear_cell(orig_key);
|
||||||
// Build new key by replacing the coord
|
// Build new key by replacing the coord
|
||||||
let new_coords: Vec<(String, String)> = orig_key
|
let new_coords: Vec<(String, String)> = orig_key
|
||||||
.0
|
.0
|
||||||
@ -478,10 +479,10 @@ impl Effect for ApplyAndClearDrill {
|
|||||||
.collect();
|
.collect();
|
||||||
let new_key = CellKey::new(new_coords);
|
let new_key = CellKey::new(new_coords);
|
||||||
// Ensure the new item exists in that category
|
// Ensure the new item exists in that category
|
||||||
if let Some(cat) = app.model.category_mut(col_name) {
|
if let Some(cat) = app.workbook.model.category_mut(col_name) {
|
||||||
cat.add_item(new_value.clone());
|
cat.add_item(new_value.clone());
|
||||||
}
|
}
|
||||||
app.model.set_cell(new_key, value);
|
app.workbook.model.set_cell(new_key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.dirty = true;
|
app.dirty = true;
|
||||||
@ -513,7 +514,7 @@ pub struct Save;
|
|||||||
impl Effect for Save {
|
impl Effect for Save {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
if let Some(ref path) = app.file_path {
|
if let Some(ref path) = app.file_path {
|
||||||
match crate::persistence::save(&app.model, path) {
|
match crate::persistence::save(&app.workbook,path) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
app.dirty = false;
|
app.dirty = false;
|
||||||
app.status_msg = format!("Saved to {}", path.display());
|
app.status_msg = format!("Saved to {}", path.display());
|
||||||
@ -532,7 +533,7 @@ impl Effect for Save {
|
|||||||
pub struct SaveAs(pub PathBuf);
|
pub struct SaveAs(pub PathBuf);
|
||||||
impl Effect for SaveAs {
|
impl Effect for SaveAs {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
match crate::persistence::save(&app.model, &self.0) {
|
match crate::persistence::save(&app.workbook,&self.0) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
app.file_path = Some(self.0.clone());
|
app.file_path = Some(self.0.clone());
|
||||||
app.dirty = false;
|
app.dirty = false;
|
||||||
@ -648,9 +649,9 @@ impl Effect for WizardKey {
|
|||||||
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
|
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
|
||||||
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
|
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
|
||||||
crossterm::event::KeyCode::Enter => match wizard.build_model() {
|
crossterm::event::KeyCode::Enter => match wizard.build_model() {
|
||||||
Ok(mut model) => {
|
Ok(mut workbook) => {
|
||||||
model.normalize_view_state();
|
workbook.normalize_view_state();
|
||||||
app.model = model;
|
app.workbook = workbook;
|
||||||
app.formula_cursor = 0;
|
app.formula_cursor = 0;
|
||||||
app.dirty = true;
|
app.dirty = true;
|
||||||
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
|
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
|
||||||
@ -703,8 +704,8 @@ impl Effect for StartImportWizard {
|
|||||||
pub struct ExportCsv(pub PathBuf);
|
pub struct ExportCsv(pub PathBuf);
|
||||||
impl Effect for ExportCsv {
|
impl Effect for ExportCsv {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let view_name = app.model.active_view.clone();
|
let view_name = app.workbook.active_view.clone();
|
||||||
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
|
match crate::persistence::export_csv(&app.workbook, &view_name, &self.0) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
app.status_msg = format!("Exported to {}", self.0.display());
|
app.status_msg = format!("Exported to {}", self.0.display());
|
||||||
}
|
}
|
||||||
@ -723,7 +724,7 @@ impl Effect for LoadModel {
|
|||||||
match crate::persistence::load(&self.0) {
|
match crate::persistence::load(&self.0) {
|
||||||
Ok(mut loaded) => {
|
Ok(mut loaded) => {
|
||||||
loaded.normalize_view_state();
|
loaded.normalize_view_state();
|
||||||
app.model = loaded;
|
app.workbook = loaded;
|
||||||
app.status_msg = format!("Loaded from {}", self.0.display());
|
app.status_msg = format!("Loaded from {}", self.0.display());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -833,8 +834,8 @@ impl Effect for ImportJsonHeadless {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match pipeline.build_model() {
|
match pipeline.build_model() {
|
||||||
Ok(new_model) => {
|
Ok(new_workbook) => {
|
||||||
app.model = new_model;
|
app.workbook = new_workbook;
|
||||||
app.status_msg = "Imported successfully".to_string();
|
app.status_msg = "Imported successfully".to_string();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -952,18 +953,18 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::Model;
|
use crate::workbook::Workbook;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
fn test_app() -> App {
|
fn test_app() -> App {
|
||||||
let mut m = Model::new("Test");
|
let mut wb = Workbook::new("Test");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Type").unwrap().add_item("Clothing");
|
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Month").unwrap().add_item("Feb");
|
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||||
App::new(m, None)
|
App::new(wb, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Model mutation effects ──────────────────────────────────────────
|
// ── Model mutation effects ──────────────────────────────────────────
|
||||||
@ -972,7 +973,7 @@ mod tests {
|
|||||||
fn add_category_effect() {
|
fn add_category_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
AddCategory("Region".to_string()).apply(&mut app);
|
AddCategory("Region".to_string()).apply(&mut app);
|
||||||
assert!(app.model.category("Region").is_some());
|
assert!(app.workbook.model.category("Region").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -984,6 +985,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
let items: Vec<&str> = app
|
let items: Vec<&str> = app
|
||||||
|
.workbook
|
||||||
.model
|
.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -1012,10 +1014,10 @@ mod tests {
|
|||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]);
|
]);
|
||||||
SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app);
|
SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app);
|
||||||
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(42.0)));
|
assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(42.0)));
|
||||||
|
|
||||||
ClearCell(key.clone()).apply(&mut app);
|
ClearCell(key.clone()).apply(&mut app);
|
||||||
assert_eq!(app.model.get_cell(&key), None);
|
assert_eq!(app.workbook.model.get_cell(&key), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1026,7 +1028,7 @@ mod tests {
|
|||||||
target_category: "Type".to_string(),
|
target_category: "Type".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(!app.model.formulas().is_empty());
|
assert!(!app.workbook.model.formulas().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: AddFormula must add the target item to the target category
|
/// Regression: AddFormula must add the target item to the target category
|
||||||
@ -1037,7 +1039,7 @@ mod tests {
|
|||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
// "Margin" does not exist as an item in "Type" before adding the formula
|
// "Margin" does not exist as an item in "Type" before adding the formula
|
||||||
assert!(
|
assert!(
|
||||||
!app.model
|
!app.workbook.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.ordered_item_names()
|
.ordered_item_names()
|
||||||
@ -1049,6 +1051,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
let items: Vec<&str> = app
|
let items: Vec<&str> = app
|
||||||
|
.workbook
|
||||||
.model
|
.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -1073,7 +1076,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
// Should appear in effective_item_names (used by layout)
|
// Should appear in effective_item_names (used by layout)
|
||||||
let effective = app.model.effective_item_names("_Measure");
|
let effective = app.workbook.model.effective_item_names("_Measure");
|
||||||
assert!(
|
assert!(
|
||||||
effective.contains(&"Margin".to_string()),
|
effective.contains(&"Margin".to_string()),
|
||||||
"formula target 'Margin' should appear in effective _Measure items, got: {:?}",
|
"formula target 'Margin' should appear in effective _Measure items, got: {:?}",
|
||||||
@ -1081,7 +1084,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Should NOT be in the category's own items
|
// Should NOT be in the category's own items
|
||||||
assert!(
|
assert!(
|
||||||
!app.model
|
!app.workbook.model
|
||||||
.category("_Measure")
|
.category("_Measure")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.ordered_item_names()
|
.ordered_item_names()
|
||||||
@ -1109,13 +1112,13 @@ mod tests {
|
|||||||
target_category: "Type".to_string(),
|
target_category: "Type".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(!app.model.formulas().is_empty());
|
assert!(!app.workbook.model.formulas().is_empty());
|
||||||
RemoveFormula {
|
RemoveFormula {
|
||||||
target: "Clothing".to_string(),
|
target: "Clothing".to_string(),
|
||||||
target_category: "Type".to_string(),
|
target_category: "Type".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(app.model.formulas().is_empty());
|
assert!(app.workbook.model.formulas().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View effects ────────────────────────────────────────────────────
|
// ── View effects ────────────────────────────────────────────────────
|
||||||
@ -1123,11 +1126,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn switch_view_pushes_to_back_stack() {
|
fn switch_view_pushes_to_back_stack() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
app.model.create_view("View 2");
|
app.workbook.create_view("View 2");
|
||||||
assert!(app.view_back_stack.is_empty());
|
assert!(app.view_back_stack.is_empty());
|
||||||
|
|
||||||
SwitchView("View 2".to_string()).apply(&mut app);
|
SwitchView("View 2".to_string()).apply(&mut app);
|
||||||
assert_eq!(app.model.active_view.as_str(), "View 2");
|
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||||
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
||||||
// Forward stack should be cleared
|
// Forward stack should be cleared
|
||||||
assert!(app.view_forward_stack.is_empty());
|
assert!(app.view_forward_stack.is_empty());
|
||||||
@ -1143,19 +1146,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn view_back_and_forward() {
|
fn view_back_and_forward() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
app.model.create_view("View 2");
|
app.workbook.create_view("View 2");
|
||||||
SwitchView("View 2".to_string()).apply(&mut app);
|
SwitchView("View 2".to_string()).apply(&mut app);
|
||||||
assert_eq!(app.model.active_view.as_str(), "View 2");
|
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||||
|
|
||||||
// Go back
|
// Go back
|
||||||
ViewBack.apply(&mut app);
|
ViewBack.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view.as_str(), "Default");
|
assert_eq!(app.workbook.active_view.as_str(), "Default");
|
||||||
assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]);
|
assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]);
|
||||||
assert!(app.view_back_stack.is_empty());
|
assert!(app.view_back_stack.is_empty());
|
||||||
|
|
||||||
// Go forward
|
// Go forward
|
||||||
ViewForward.apply(&mut app);
|
ViewForward.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view.as_str(), "View 2");
|
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||||
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
|
||||||
assert!(app.view_forward_stack.is_empty());
|
assert!(app.view_forward_stack.is_empty());
|
||||||
}
|
}
|
||||||
@ -1163,19 +1166,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn view_back_with_empty_stack_is_noop() {
|
fn view_back_with_empty_stack_is_noop() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
let before = app.model.active_view.clone();
|
let before = app.workbook.active_view.clone();
|
||||||
ViewBack.apply(&mut app);
|
ViewBack.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view, before);
|
assert_eq!(app.workbook.active_view, before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_and_delete_view() {
|
fn create_and_delete_view() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
CreateView("View 2".to_string()).apply(&mut app);
|
CreateView("View 2".to_string()).apply(&mut app);
|
||||||
assert!(app.model.views.contains_key("View 2"));
|
assert!(app.workbook.views.contains_key("View 2"));
|
||||||
|
|
||||||
DeleteView("View 2".to_string()).apply(&mut app);
|
DeleteView("View 2".to_string()).apply(&mut app);
|
||||||
assert!(!app.model.views.contains_key("View 2"));
|
assert!(!app.workbook.views.contains_key("View 2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1186,21 +1189,21 @@ mod tests {
|
|||||||
axis: Axis::Page,
|
axis: Axis::Page,
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().axis_of("Type"), Axis::Page);
|
assert_eq!(app.workbook.active_view().axis_of("Type"), Axis::Page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transpose_axes_effect() {
|
fn transpose_axes_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
let row_before: Vec<String> = app
|
let row_before: Vec<String> = app
|
||||||
.model
|
.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.categories_on(Axis::Row)
|
.categories_on(Axis::Row)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.collect();
|
.collect();
|
||||||
let col_before: Vec<String> = app
|
let col_before: Vec<String> = app
|
||||||
.model
|
.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.categories_on(Axis::Column)
|
.categories_on(Axis::Column)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1208,14 +1211,14 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
TransposeAxes.apply(&mut app);
|
TransposeAxes.apply(&mut app);
|
||||||
let row_after: Vec<String> = app
|
let row_after: Vec<String> = app
|
||||||
.model
|
.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.categories_on(Axis::Row)
|
.categories_on(Axis::Row)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.collect();
|
.collect();
|
||||||
let col_after: Vec<String> = app
|
let col_after: Vec<String> = app
|
||||||
.model
|
.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.categories_on(Axis::Column)
|
.categories_on(Axis::Column)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1231,7 +1234,7 @@ mod tests {
|
|||||||
fn set_selected_effect() {
|
fn set_selected_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
SetSelected(3, 5).apply(&mut app);
|
SetSelected(3, 5).apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().selected, (3, 5));
|
assert_eq!(app.workbook.active_view().selected, (3, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1239,8 +1242,8 @@ mod tests {
|
|||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
SetRowOffset(10).apply(&mut app);
|
SetRowOffset(10).apply(&mut app);
|
||||||
SetColOffset(5).apply(&mut app);
|
SetColOffset(5).apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().row_offset, 10);
|
assert_eq!(app.workbook.active_view().row_offset, 10);
|
||||||
assert_eq!(app.model.active_view().col_offset, 5);
|
assert_eq!(app.workbook.active_view().col_offset, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── App state effects ───────────────────────────────────────────────
|
// ── App state effects ───────────────────────────────────────────────
|
||||||
@ -1417,7 +1420,7 @@ mod tests {
|
|||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]);
|
]);
|
||||||
// Set original cell
|
// Set original cell
|
||||||
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0));
|
||||||
|
|
||||||
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
||||||
StartDrill(records).apply(&mut app);
|
StartDrill(records).apply(&mut app);
|
||||||
@ -1433,7 +1436,7 @@ mod tests {
|
|||||||
ApplyAndClearDrill.apply(&mut app);
|
ApplyAndClearDrill.apply(&mut app);
|
||||||
assert!(app.drill_state.is_none());
|
assert!(app.drill_state.is_none());
|
||||||
assert!(app.dirty);
|
assert!(app.dirty);
|
||||||
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(99.0)));
|
assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(99.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1443,7 +1446,7 @@ mod tests {
|
|||||||
("Type".into(), "Food".into()),
|
("Type".into(), "Food".into()),
|
||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]);
|
]);
|
||||||
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0));
|
||||||
|
|
||||||
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
||||||
StartDrill(records).apply(&mut app);
|
StartDrill(records).apply(&mut app);
|
||||||
@ -1459,15 +1462,16 @@ mod tests {
|
|||||||
ApplyAndClearDrill.apply(&mut app);
|
ApplyAndClearDrill.apply(&mut app);
|
||||||
assert!(app.dirty);
|
assert!(app.dirty);
|
||||||
// Old cell should be gone
|
// Old cell should be gone
|
||||||
assert_eq!(app.model.get_cell(&key), None);
|
assert_eq!(app.workbook.model.get_cell(&key), None);
|
||||||
// New cell should exist
|
// New cell should exist
|
||||||
let new_key = CellKey::new(vec![
|
let new_key = CellKey::new(vec![
|
||||||
("Type".into(), "Drink".into()),
|
("Type".into(), "Drink".into()),
|
||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]);
|
]);
|
||||||
assert_eq!(app.model.get_cell(&new_key), Some(&CellValue::Number(42.0)));
|
assert_eq!(app.workbook.model.get_cell(&new_key), Some(&CellValue::Number(42.0)));
|
||||||
// "Drink" should have been added as an item
|
// "Drink" should have been added as an item
|
||||||
let items: Vec<&str> = app
|
let items: Vec<&str> = app
|
||||||
|
.workbook
|
||||||
.model
|
.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -1484,7 +1488,7 @@ mod tests {
|
|||||||
("Type".into(), "Food".into()),
|
("Type".into(), "Food".into()),
|
||||||
("Month".into(), "Jan".into()),
|
("Month".into(), "Jan".into()),
|
||||||
]);
|
]);
|
||||||
app.model.set_cell(key.clone(), CellValue::Number(42.0));
|
app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0));
|
||||||
|
|
||||||
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
let records = vec![(key.clone(), CellValue::Number(42.0))];
|
||||||
StartDrill(records).apply(&mut app);
|
StartDrill(records).apply(&mut app);
|
||||||
@ -1498,7 +1502,7 @@ mod tests {
|
|||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
|
|
||||||
ApplyAndClearDrill.apply(&mut app);
|
ApplyAndClearDrill.apply(&mut app);
|
||||||
assert_eq!(app.model.get_cell(&key), None);
|
assert_eq!(app.workbook.model.get_cell(&key), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle effects ──────────────────────────────────────────────────
|
// ── Toggle effects ──────────────────────────────────────────────────
|
||||||
@ -1506,11 +1510,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn toggle_prune_empty_effect() {
|
fn toggle_prune_empty_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
let before = app.model.active_view().prune_empty;
|
let before = app.workbook.active_view().prune_empty;
|
||||||
TogglePruneEmpty.apply(&mut app);
|
TogglePruneEmpty.apply(&mut app);
|
||||||
assert_ne!(app.model.active_view().prune_empty, before);
|
assert_ne!(app.workbook.active_view().prune_empty, before);
|
||||||
TogglePruneEmpty.apply(&mut app);
|
TogglePruneEmpty.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().prune_empty, before);
|
assert_eq!(app.workbook.active_view().prune_empty, before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1532,6 +1536,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
let items: Vec<&str> = app
|
let items: Vec<&str> = app
|
||||||
|
.workbook
|
||||||
.model
|
.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -1541,7 +1546,7 @@ mod tests {
|
|||||||
assert!(!items.contains(&"Food"));
|
assert!(!items.contains(&"Food"));
|
||||||
|
|
||||||
RemoveCategory("Month".to_string()).apply(&mut app);
|
RemoveCategory("Month".to_string()).apply(&mut app);
|
||||||
assert!(app.model.category("Month").is_none());
|
assert!(app.workbook.model.category("Month").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Number format ───────────────────────────────────────────────────
|
// ── Number format ───────────────────────────────────────────────────
|
||||||
@ -1550,7 +1555,7 @@ mod tests {
|
|||||||
fn set_number_format_effect() {
|
fn set_number_format_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
SetNumberFormat(",.2f".to_string()).apply(&mut app);
|
SetNumberFormat(",.2f".to_string()).apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().number_format, ",.2f");
|
assert_eq!(app.workbook.active_view().number_format, ",.2f");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page selection ──────────────────────────────────────────────────
|
// ── Page selection ──────────────────────────────────────────────────
|
||||||
@ -1563,7 +1568,7 @@ mod tests {
|
|||||||
item: "Food".to_string(),
|
item: "Food".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert_eq!(app.model.active_view().page_selection("Type"), Some("Food"));
|
assert_eq!(app.workbook.active_view().page_selection("Type"), Some("Food"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hide/show items ─────────────────────────────────────────────────
|
// ── Hide/show items ─────────────────────────────────────────────────
|
||||||
@ -1576,14 +1581,14 @@ mod tests {
|
|||||||
item: "Food".to_string(),
|
item: "Food".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(app.model.active_view().is_hidden("Type", "Food"));
|
assert!(app.workbook.active_view().is_hidden("Type", "Food"));
|
||||||
|
|
||||||
ShowItem {
|
ShowItem {
|
||||||
category: "Type".to_string(),
|
category: "Type".to_string(),
|
||||||
item: "Food".to_string(),
|
item: "Food".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(!app.model.active_view().is_hidden("Type", "Food"));
|
assert!(!app.workbook.active_view().is_hidden("Type", "Food"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle group ────────────────────────────────────────────────────
|
// ── Toggle group ────────────────────────────────────────────────────
|
||||||
@ -1597,7 +1602,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(
|
assert!(
|
||||||
app.model
|
app.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.is_group_collapsed("Type", "MyGroup")
|
.is_group_collapsed("Type", "MyGroup")
|
||||||
);
|
);
|
||||||
@ -1607,7 +1612,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(
|
assert!(
|
||||||
!app.model
|
!app.workbook
|
||||||
.active_view()
|
.active_view()
|
||||||
.is_group_collapsed("Type", "MyGroup")
|
.is_group_collapsed("Type", "MyGroup")
|
||||||
);
|
);
|
||||||
@ -1618,9 +1623,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cycle_axis_effect() {
|
fn cycle_axis_effect() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
let before = app.model.active_view().axis_of("Type");
|
let before = app.workbook.active_view().axis_of("Type");
|
||||||
CycleAxis("Type".to_string()).apply(&mut app);
|
CycleAxis("Type".to_string()).apply(&mut app);
|
||||||
let after = app.model.active_view().axis_of("Type");
|
let after = app.workbook.active_view().axis_of("Type");
|
||||||
assert_ne!(before, after);
|
assert_ne!(before, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
135
src/ui/grid.rs
135
src/ui/grid.rs
@ -8,7 +8,7 @@ use unicode_width::UnicodeWidthStr;
|
|||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
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.
|
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||||
const MIN_COL_WIDTH: u16 = 5;
|
const MIN_COL_WIDTH: u16 = 5;
|
||||||
@ -22,6 +22,8 @@ const GROUP_COLLAPSED: &str = "▶";
|
|||||||
|
|
||||||
pub struct GridWidget<'a> {
|
pub struct GridWidget<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
|
pub view: &'a View,
|
||||||
|
pub view_name: &'a str,
|
||||||
pub layout: &'a GridLayout,
|
pub layout: &'a GridLayout,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
pub search_query: &'a str,
|
pub search_query: &'a str,
|
||||||
@ -30,8 +32,11 @@ pub struct GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> GridWidget<'a> {
|
impl<'a> GridWidget<'a> {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
model: &'a Model,
|
model: &'a Model,
|
||||||
|
view: &'a View,
|
||||||
|
view_name: &'a str,
|
||||||
layout: &'a GridLayout,
|
layout: &'a GridLayout,
|
||||||
mode: &'a AppMode,
|
mode: &'a AppMode,
|
||||||
search_query: &'a str,
|
search_query: &'a str,
|
||||||
@ -40,6 +45,8 @@ impl<'a> GridWidget<'a> {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
model,
|
model,
|
||||||
|
view,
|
||||||
|
view_name,
|
||||||
layout,
|
layout,
|
||||||
mode,
|
mode,
|
||||||
search_query,
|
search_query,
|
||||||
@ -49,7 +56,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let view = self.model.active_view();
|
let view = self.view;
|
||||||
let layout = self.layout;
|
let layout = self.layout;
|
||||||
let (sel_row, sel_col) = view.selected;
|
let (sel_row, sel_col) = view.selected;
|
||||||
let row_offset = view.row_offset;
|
let row_offset = view.row_offset;
|
||||||
@ -494,10 +501,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
|
|
||||||
impl<'a> Widget for GridWidget<'a> {
|
impl<'a> Widget for GridWidget<'a> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
let view_name = self.model.active_view.clone();
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(format!(" View: {} ", view_name));
|
.title(format!(" View: {} ", self.view_name));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
@ -674,27 +680,33 @@ mod tests {
|
|||||||
|
|
||||||
use super::GridWidget;
|
use super::GridWidget;
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::GridLayout;
|
use crate::view::GridLayout;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Render a GridWidget into a fresh buffer of the given size.
|
/// Render a GridWidget into a fresh buffer of the given size.
|
||||||
fn render(model: &mut Model, width: u16, height: u16) -> Buffer {
|
fn render(wb: &mut Workbook, width: u16, height: u16) -> Buffer {
|
||||||
let none_cats: Vec<String> = model
|
let none_cats = wb.active_view().none_cats();
|
||||||
.active_view()
|
wb.model.recompute_formulas(&none_cats);
|
||||||
.categories_on(crate::view::Axis::None)
|
|
||||||
.into_iter()
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
model.recompute_formulas(&none_cats);
|
|
||||||
let area = Rect::new(0, 0, width, height);
|
let area = Rect::new(0, 0, width, height);
|
||||||
let mut buf = Buffer::empty(area);
|
let mut buf = Buffer::empty(area);
|
||||||
let bufs = std::collections::HashMap::new();
|
let bufs = std::collections::HashMap::new();
|
||||||
let layout = GridLayout::new(model, model.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
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
|
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.
|
/// Every cell has a value so rows/cols survive pruning.
|
||||||
fn two_cat_model() -> Model {
|
fn two_cat_model() -> Workbook {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Workbook::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
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("Food");
|
||||||
c.add_item("Clothing");
|
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("Jan");
|
||||||
c.add_item("Feb");
|
c.add_item("Feb");
|
||||||
}
|
}
|
||||||
// Fill every cell so nothing is pruned as empty.
|
// Fill every cell so nothing is pruned as empty.
|
||||||
for t in ["Food", "Clothing"] {
|
for t in ["Food", "Clothing"] {
|
||||||
for mo in ["Jan", "Feb"] {
|
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
|
m
|
||||||
@ -771,7 +784,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cell_value_appears_in_correct_position() {
|
fn cell_value_appears_in_correct_position() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
CellValue::Number(123.0),
|
CellValue::Number(123.0),
|
||||||
);
|
);
|
||||||
@ -782,15 +795,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn multiple_cell_values_all_appear() {
|
fn multiple_cell_values_all_appear() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
CellValue::Number(100.0),
|
CellValue::Number(100.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Feb")]),
|
coord(&[("Type", "Food"), ("Month", "Feb")]),
|
||||||
CellValue::Number(200.0),
|
CellValue::Number(200.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||||
CellValue::Number(50.0),
|
CellValue::Number(50.0),
|
||||||
);
|
);
|
||||||
@ -803,13 +816,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unset_cells_show_no_value() {
|
fn unset_cells_show_no_value() {
|
||||||
// Build a model without the two_cat_model helper (which fills every cell).
|
// 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("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
m.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
m.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
// Set one cell so the row/col isn't pruned
|
// Set one cell so the row/col isn't pruned
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
CellValue::Number(1.0),
|
CellValue::Number(1.0),
|
||||||
);
|
);
|
||||||
@ -830,11 +843,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn total_row_sums_column_correctly() {
|
fn total_row_sums_column_correctly() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
CellValue::Number(100.0),
|
CellValue::Number(100.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||||
CellValue::Number(50.0),
|
CellValue::Number(50.0),
|
||||||
);
|
);
|
||||||
@ -850,17 +863,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_filter_bar_shows_category_and_selection() {
|
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("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Payer").unwrap(); // → Page
|
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");
|
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");
|
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("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
@ -874,17 +887,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_filter_defaults_to_first_item() {
|
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("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
m.add_category("Payer").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");
|
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");
|
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("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
@ -900,21 +913,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formula_cell_renders_computed_value() {
|
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.add_category("Region").unwrap(); // → Column
|
||||||
m.category_mut("_Measure").unwrap().add_item("Revenue");
|
m.model.category_mut("_Measure").unwrap().add_item("Revenue");
|
||||||
m.category_mut("_Measure").unwrap().add_item("Cost");
|
m.model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||||
// Profit is a formula target — dynamically included in _Measure
|
// Profit is a formula target — dynamically included in _Measure
|
||||||
m.category_mut("Region").unwrap().add_item("East");
|
m.model.category_mut("Region").unwrap().add_item("East");
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("_Measure", "Revenue"), ("Region", "East")]),
|
coord(&[("_Measure", "Revenue"), ("Region", "East")]),
|
||||||
CellValue::Number(1000.0),
|
CellValue::Number(1000.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("_Measure", "Cost"), ("Region", "East")]),
|
coord(&[("_Measure", "Cost"), ("Region", "East")]),
|
||||||
CellValue::Number(600.0),
|
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()
|
m.active_view_mut()
|
||||||
.set_axis("_Index", crate::view::Axis::None);
|
.set_axis("_Index", crate::view::Axis::None);
|
||||||
m.active_view_mut()
|
m.active_view_mut()
|
||||||
@ -932,18 +945,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_row_categories_produce_cross_product_labels() {
|
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("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
|
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("Food");
|
||||||
c.add_item("Clothing");
|
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("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("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
@ -952,7 +965,7 @@ mod tests {
|
|||||||
// Populate cells so rows/cols survive pruning
|
// Populate cells so rows/cols survive pruning
|
||||||
for t in ["Food", "Clothing"] {
|
for t in ["Food", "Clothing"] {
|
||||||
for r in ["Alice", "Bob"] {
|
for r in ["Alice", "Bob"] {
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
|
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
|
||||||
CellValue::Number(1.0),
|
CellValue::Number(1.0),
|
||||||
);
|
);
|
||||||
@ -978,24 +991,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_row_categories_include_all_coords_in_cell_lookup() {
|
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("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
m.add_category("Recipient").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");
|
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");
|
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("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
m.active_view_mut()
|
m.active_view_mut()
|
||||||
.set_axis("Recipient", crate::view::Axis::Row);
|
.set_axis("Recipient", crate::view::Axis::Row);
|
||||||
// Set data at the full 3-coordinate key
|
// Set data at the full 3-coordinate key
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
||||||
CellValue::Number(77.0),
|
CellValue::Number(77.0),
|
||||||
);
|
);
|
||||||
@ -1005,17 +1018,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_column_categories_produce_cross_product_headers() {
|
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("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Year").unwrap(); // → Page by default; move to 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");
|
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");
|
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("2024");
|
||||||
c.add_item("2025");
|
c.add_item("2025");
|
||||||
}
|
}
|
||||||
@ -1023,7 +1036,7 @@ mod tests {
|
|||||||
.set_axis("Year", crate::view::Axis::Column);
|
.set_axis("Year", crate::view::Axis::Column);
|
||||||
// Populate cells so cols survive pruning
|
// Populate cells so cols survive pruning
|
||||||
for y in ["2024", "2025"] {
|
for y in ["2024", "2025"] {
|
||||||
m.set_cell(
|
m.model.set_cell(
|
||||||
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
|
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
|
||||||
CellValue::Number(1.0),
|
CellValue::Number(1.0),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,18 +8,25 @@ use unicode_width::UnicodeWidthStr;
|
|||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::Axis;
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
pub struct TileBar<'a> {
|
pub struct TileBar<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
|
pub view: &'a View,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
pub tile_cat_idx: usize,
|
pub tile_cat_idx: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TileBar<'a> {
|
impl<'a> TileBar<'a> {
|
||||||
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
|
pub fn new(
|
||||||
|
model: &'a Model,
|
||||||
|
view: &'a View,
|
||||||
|
mode: &'a AppMode,
|
||||||
|
tile_cat_idx: usize,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
model,
|
model,
|
||||||
|
view,
|
||||||
mode,
|
mode,
|
||||||
tile_cat_idx,
|
tile_cat_idx,
|
||||||
}
|
}
|
||||||
@ -44,7 +51,7 @@ impl<'a> Widget for TileBar<'a> {
|
|||||||
Style::default(),
|
Style::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let view = self.model.active_view();
|
let view = self.view;
|
||||||
|
|
||||||
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
||||||
Some(self.tile_cat_idx)
|
Some(self.tile_cat_idx)
|
||||||
|
|||||||
@ -4,31 +4,31 @@ use ratatui::{
|
|||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::panel::PanelContent;
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
pub struct ViewContent<'a> {
|
pub struct ViewContent<'a> {
|
||||||
view_names: Vec<String>,
|
view_names: Vec<String>,
|
||||||
active_view: String,
|
active_view: String,
|
||||||
model: &'a Model,
|
workbook: &'a Workbook,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ViewContent<'a> {
|
impl<'a> ViewContent<'a> {
|
||||||
pub fn new(model: &'a Model) -> Self {
|
pub fn new(workbook: &'a Workbook) -> Self {
|
||||||
let view_names: Vec<String> = model.views.keys().cloned().collect();
|
let view_names: Vec<String> = workbook.views.keys().cloned().collect();
|
||||||
let active_view = model.active_view.clone();
|
let active_view = workbook.active_view.clone();
|
||||||
Self {
|
Self {
|
||||||
view_names,
|
view_names,
|
||||||
active_view,
|
active_view,
|
||||||
model,
|
workbook,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
|
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
|
||||||
fn axis_summary(&self, view_name: &str) -> String {
|
fn axis_summary(&self, view_name: &str) -> String {
|
||||||
let Some(view) = self.model.views.get(view_name) else {
|
let Some(view) = self.workbook.views.get(view_name) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
|
|||||||
@ -568,73 +568,76 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{AxisEntry, GridLayout, synthetic_record_info};
|
use super::{AxisEntry, GridLayout, synthetic_record_info};
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
fn records_model() -> Model {
|
fn records_workbook() -> Workbook {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Region").unwrap();
|
wb.add_category("Region").unwrap();
|
||||||
m.add_category("_Measure").unwrap();
|
wb.add_category("_Measure").unwrap();
|
||||||
m.category_mut("Region").unwrap().add_item("North");
|
wb.model.category_mut("Region").unwrap().add_item("North");
|
||||||
m.category_mut("_Measure").unwrap().add_item("Revenue");
|
wb.model
|
||||||
m.category_mut("_Measure").unwrap().add_item("Cost");
|
.category_mut("_Measure")
|
||||||
m.set_cell(
|
.unwrap()
|
||||||
|
.add_item("Revenue");
|
||||||
|
wb.model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||||
|
wb.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("Region".into(), "North".into()),
|
("Region".into(), "North".into()),
|
||||||
("_Measure".into(), "Revenue".into()),
|
("_Measure".into(), "Revenue".into()),
|
||||||
]),
|
]),
|
||||||
CellValue::Number(100.0),
|
CellValue::Number(100.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
wb.model.set_cell(
|
||||||
CellKey::new(vec![
|
CellKey::new(vec![
|
||||||
("Region".into(), "North".into()),
|
("Region".into(), "North".into()),
|
||||||
("_Measure".into(), "Cost".into()),
|
("_Measure".into(), "Cost".into()),
|
||||||
]),
|
]),
|
||||||
CellValue::Number(50.0),
|
CellValue::Number(50.0),
|
||||||
);
|
);
|
||||||
m
|
wb
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Row").unwrap();
|
wb.add_category("Row").unwrap();
|
||||||
m.add_category("Col").unwrap();
|
wb.add_category("Col").unwrap();
|
||||||
m.category_mut("Row").unwrap().add_item("A");
|
wb.model.category_mut("Row").unwrap().add_item("A");
|
||||||
m.category_mut("Col").unwrap().add_item("X");
|
wb.model.category_mut("Col").unwrap().add_item("X");
|
||||||
m.category_mut("Col").unwrap().add_item("Y");
|
wb.model.category_mut("Col").unwrap().add_item("Y");
|
||||||
// Only X has data; Y is entirely empty
|
// Only X has data; Y is entirely empty
|
||||||
m.set_cell(
|
wb.model.set_cell(
|
||||||
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
|
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
|
||||||
CellValue::Number(1.0),
|
CellValue::Number(1.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut layout = GridLayout::new(&m, m.active_view());
|
let mut layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
||||||
layout.prune_empty(&m);
|
layout.prune_empty(&wb.model);
|
||||||
assert_eq!(layout.col_count(), 1); // only X after pruning
|
assert_eq!(layout.col_count(), 1); // only X after pruning
|
||||||
assert_eq!(layout.col_label(0), "X");
|
assert_eq!(layout.col_label(0), "X");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||||
let mut m = records_model();
|
let mut wb = records_workbook();
|
||||||
let v = m.active_view_mut();
|
let v = wb.active_view_mut();
|
||||||
v.set_axis("_Index", Axis::Row);
|
v.set_axis("_Index", Axis::Row);
|
||||||
v.set_axis("_Dim", Axis::Column);
|
v.set_axis("_Dim", Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert!(layout.is_records_mode());
|
assert!(layout.is_records_mode());
|
||||||
assert_eq!(layout.row_count(), 2); // 2 cells
|
assert_eq!(layout.row_count(), 2); // 2 cells
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
|
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
|
||||||
let mut m = records_model();
|
let mut wb = records_workbook();
|
||||||
let v = m.active_view_mut();
|
let v = wb.active_view_mut();
|
||||||
v.set_axis("_Index", Axis::Row);
|
v.set_axis("_Index", Axis::Row);
|
||||||
v.set_axis("_Dim", Axis::Column);
|
v.set_axis("_Dim", Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert!(layout.is_records_mode());
|
assert!(layout.is_records_mode());
|
||||||
let cols: Vec<String> = (0..layout.col_count())
|
let cols: Vec<String> = (0..layout.col_count())
|
||||||
.map(|i| layout.col_label(i))
|
.map(|i| layout.col_label(i))
|
||||||
@ -653,11 +656,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_mode_resolve_display_returns_values() {
|
fn records_mode_resolve_display_returns_values() {
|
||||||
let mut m = records_model();
|
let mut wb = records_workbook();
|
||||||
let v = m.active_view_mut();
|
let v = wb.active_view_mut();
|
||||||
v.set_axis("_Index", Axis::Row);
|
v.set_axis("_Index", Axis::Row);
|
||||||
v.set_axis("_Dim", Axis::Column);
|
v.set_axis("_Dim", Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
let cols: Vec<String> = (0..layout.col_count())
|
let cols: Vec<String> = (0..layout.col_count())
|
||||||
.map(|i| layout.col_label(i))
|
.map(|i| layout.col_label(i))
|
||||||
.collect();
|
.collect();
|
||||||
@ -707,31 +710,31 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn two_cat_model() -> Model {
|
fn two_cat_workbook() -> Workbook {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
for item in ["Food", "Clothing"] {
|
for item in ["Food", "Clothing"] {
|
||||||
m.category_mut("Type").unwrap().add_item(item);
|
wb.model.category_mut("Type").unwrap().add_item(item);
|
||||||
}
|
}
|
||||||
for item in ["Jan", "Feb"] {
|
for item in ["Jan", "Feb"] {
|
||||||
m.category_mut("Month").unwrap().add_item(item);
|
wb.model.category_mut("Month").unwrap().add_item(item);
|
||||||
}
|
}
|
||||||
m
|
wb
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn row_and_col_counts_match_item_counts() {
|
fn row_and_col_counts_match_item_counts() {
|
||||||
let m = two_cat_model();
|
let wb = two_cat_workbook();
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.row_count(), 2); // Food, Clothing
|
assert_eq!(layout.row_count(), 2); // Food, Clothing
|
||||||
assert_eq!(layout.col_count(), 2); // Jan, Feb
|
assert_eq!(layout.col_count(), 2); // Jan, Feb
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_key_encodes_correct_coordinates() {
|
fn cell_key_encodes_correct_coordinates() {
|
||||||
let m = two_cat_model();
|
let wb = two_cat_workbook();
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// row 0 = Food, col 1 = Feb
|
// row 0 = Food, col 1 = Feb
|
||||||
let key = layout.cell_key(0, 1).unwrap();
|
let key = layout.cell_key(0, 1).unwrap();
|
||||||
assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
|
assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
|
||||||
@ -739,88 +742,93 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_key_out_of_bounds_returns_none() {
|
fn cell_key_out_of_bounds_returns_none() {
|
||||||
let m = two_cat_model();
|
let wb = two_cat_workbook();
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert!(layout.cell_key(99, 0).is_none());
|
assert!(layout.cell_key(99, 0).is_none());
|
||||||
assert!(layout.cell_key(0, 99).is_none());
|
assert!(layout.cell_key(0, 99).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_key_includes_page_coords() {
|
fn cell_key_includes_page_coords() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Region").unwrap();
|
wb.add_category("Region").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Region").unwrap().add_item("East");
|
wb.model.category_mut("Region").unwrap().add_item("East");
|
||||||
m.category_mut("Region").unwrap().add_item("West");
|
wb.model.category_mut("Region").unwrap().add_item("West");
|
||||||
m.active_view_mut().set_page_selection("Region", "West");
|
wb.active_view_mut().set_page_selection("Region", "West");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
let key = layout.cell_key(0, 0).unwrap();
|
let key = layout.cell_key(0, 0).unwrap();
|
||||||
assert_eq!(key.get("Region"), Some("West"));
|
assert_eq!(key.get("Region"), Some("West"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_key_round_trips_through_model_evaluate() {
|
fn cell_key_round_trips_through_model_evaluate() {
|
||||||
let mut m = two_cat_model();
|
let mut wb = two_cat_workbook();
|
||||||
m.set_cell(
|
wb.model.set_cell(
|
||||||
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
|
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
|
||||||
CellValue::Number(42.0),
|
CellValue::Number(42.0),
|
||||||
);
|
);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// Clothing = row 1, Feb = col 1
|
// Clothing = row 1, Feb = col 1
|
||||||
let key = layout.cell_key(1, 1).unwrap();
|
let key = layout.cell_key(1, 1).unwrap();
|
||||||
assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0)));
|
assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(42.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn labels_join_with_slash_for_multi_cat_axis() {
|
fn labels_join_with_slash_for_multi_cat_axis() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Year").unwrap();
|
wb.add_category("Year").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Year").unwrap().add_item("2025");
|
wb.model.category_mut("Year").unwrap().add_item("2025");
|
||||||
m.active_view_mut()
|
wb.active_view_mut()
|
||||||
.set_axis("Year", crate::view::Axis::Column);
|
.set_axis("Year", crate::view::Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.col_label(0), "Jan/2025");
|
assert_eq!(layout.col_label(0), "Jan/2025");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn row_count_excludes_group_headers() {
|
fn row_count_excludes_group_headers() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Feb", "Q1");
|
.add_item_in_group("Feb", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
|
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn group_header_emitted_at_group_boundary() {
|
fn group_header_emitted_at_group_boundary() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
let headers: Vec<_> = layout
|
let headers: Vec<_> = layout
|
||||||
.row_items
|
.row_items
|
||||||
.iter()
|
.iter()
|
||||||
@ -837,21 +845,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collapsed_group_has_header_but_no_data_items() {
|
fn collapsed_group_has_header_but_no_data_items() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Feb", "Q1");
|
.add_item_in_group("Feb", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.active_view_mut().toggle_group_collapse("Month", "Q1");
|
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
|
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
|
||||||
assert_eq!(layout.row_count(), 1); // only Apr
|
assert_eq!(layout.row_count(), 1); // only Apr
|
||||||
let q1_header = layout
|
let q1_header = layout
|
||||||
@ -868,8 +879,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ungrouped_items_produce_no_headers() {
|
fn ungrouped_items_produce_no_headers() {
|
||||||
let m = two_cat_model();
|
let wb = two_cat_workbook();
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert!(
|
assert!(
|
||||||
!layout
|
!layout
|
||||||
.row_items
|
.row_items
|
||||||
@ -886,39 +897,43 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_key_correct_with_grouped_items() {
|
fn cell_key_correct_with_grouped_items() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.set_cell(
|
wb.model.set_cell(
|
||||||
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
||||||
CellValue::Number(99.0),
|
CellValue::Number(99.0),
|
||||||
);
|
);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// data row 0 = Jan, data row 1 = Apr
|
// data row 0 = Jan, data row 1 = Apr
|
||||||
let key = layout.cell_key(1, 0).unwrap();
|
let key = layout.cell_key(1, 0).unwrap();
|
||||||
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
|
assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(99.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_row_to_visual_skips_headers() {
|
fn data_row_to_visual_skips_headers() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||||
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
|
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
|
||||||
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
|
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
|
||||||
@ -927,17 +942,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_col_to_visual_skips_headers() {
|
fn data_col_to_visual_skips_headers() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap(); // Row
|
wb.add_category("Type").unwrap(); // Row
|
||||||
m.add_category("Month").unwrap(); // Column
|
wb.add_category("Month").unwrap(); // Column
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||||
assert_eq!(layout.data_col_to_visual(0), Some(1));
|
assert_eq!(layout.data_col_to_visual(0), Some(1));
|
||||||
assert_eq!(layout.data_col_to_visual(1), Some(3));
|
assert_eq!(layout.data_col_to_visual(1), Some(3));
|
||||||
@ -946,17 +963,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn row_group_for_finds_enclosing_group() {
|
fn row_group_for_finds_enclosing_group() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
layout.row_group_for(0),
|
layout.row_group_for(0),
|
||||||
Some(("Month".to_string(), "Q1".to_string()))
|
Some(("Month".to_string(), "Q1".to_string()))
|
||||||
@ -969,28 +988,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn row_group_for_returns_none_for_ungrouped() {
|
fn row_group_for_returns_none_for_ungrouped() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.row_group_for(0), None);
|
assert_eq!(layout.row_group_for(0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn col_group_for_finds_enclosing_group() {
|
fn col_group_for_finds_enclosing_group() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap(); // Row
|
wb.add_category("Type").unwrap(); // Row
|
||||||
m.add_category("Month").unwrap(); // Column
|
wb.add_category("Month").unwrap(); // Column
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Jan", "Q1");
|
.add_item_in_group("Jan", "Q1");
|
||||||
m.category_mut("Month")
|
wb.model
|
||||||
|
.category_mut("Month")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_item_in_group("Apr", "Q2");
|
.add_item_in_group("Apr", "Q2");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
layout.col_group_for(0),
|
layout.col_group_for(0),
|
||||||
Some(("Month".to_string(), "Q1".to_string()))
|
Some(("Month".to_string(), "Q1".to_string()))
|
||||||
@ -1003,12 +1024,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn col_group_for_returns_none_for_ungrouped() {
|
fn col_group_for_returns_none_for_ungrouped() {
|
||||||
let mut m = Model::new("T");
|
let mut wb = Workbook::new("T");
|
||||||
m.add_category("Type").unwrap();
|
wb.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
wb.add_category("Month").unwrap();
|
||||||
m.category_mut("Type").unwrap().add_item("Food");
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||||
assert_eq!(layout.col_group_for(0), None);
|
assert_eq!(layout.col_group_for(0), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -126,6 +126,16 @@ impl View {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owned-string variant of `categories_on(Axis::None)`. Used by callers
|
||||||
|
/// that need to pass the None-axis set to formula recomputation, which
|
||||||
|
/// takes `&[String]` so it can be stored without tying lifetimes to `View`.
|
||||||
|
pub fn none_cats(&self) -> Vec<String> {
|
||||||
|
self.categories_on(Axis::None)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
||||||
self.page_selections
|
self.page_selections
|
||||||
.insert(cat_name.to_string(), item.to_string());
|
.insert(cat_name.to_string(), item.to_string());
|
||||||
|
|||||||
259
src/workbook.rs
Normal file
259
src/workbook.rs
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
//! 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 with their conventional axes (`_Index`=Row, `_Dim`=Column).
|
||||||
|
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("_Index", Axis::Row);
|
||||||
|
view.set_axis("_Dim", Axis::Column);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
assert_eq!(v.axis_of("_Index"), Axis::Row);
|
||||||
|
assert_eq!(v.axis_of("_Dim"), Axis::Column);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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