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:
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::keymap::{Keymap, KeymapSet};
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::persistence;
|
||||
use crate::ui::grid::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||
};
|
||||
use crate::view::GridLayout;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
@ -152,7 +152,7 @@ impl AppMode {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub model: Model,
|
||||
pub workbook: Workbook,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pub mode: AppMode,
|
||||
pub status_msg: String,
|
||||
@ -199,22 +199,19 @@ pub struct 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
|
||||
// formula-derived values are available on the first frame.
|
||||
let none_cats: Vec<String> = model
|
||||
.active_view()
|
||||
.categories_on(crate::view::Axis::None)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
model.recompute_formulas(&none_cats);
|
||||
// formula-derived values are available on the first frame. The
|
||||
// cache is keyed by the active view's None-axis categories, so
|
||||
// the caller must gather them explicitly.
|
||||
let none_cats = workbook.active_view().none_cats();
|
||||
workbook.model.recompute_formulas(&none_cats);
|
||||
let layout = {
|
||||
let view = model.active_view();
|
||||
GridLayout::with_frozen_records(&model, view, None)
|
||||
let view = workbook.active_view();
|
||||
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||
};
|
||||
Self {
|
||||
model,
|
||||
workbook,
|
||||
file_path,
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
@ -245,29 +242,24 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the grid layout from current model, view, and drill state.
|
||||
/// Note: `with_frozen_records` already handles pruning internally.
|
||||
/// Rebuild the grid layout from current workbook, active view, and drill
|
||||
/// state. Note: `with_frozen_records` already handles pruning internally.
|
||||
pub fn rebuild_layout(&mut self) {
|
||||
// Gather none_cats before mutable borrow for formula recomputation
|
||||
let none_cats: Vec<String> = self
|
||||
.model
|
||||
.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 none_cats = self.workbook.active_view().none_cats();
|
||||
self.workbook.model.recompute_formulas(&none_cats);
|
||||
let view = self.workbook.active_view();
|
||||
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<'_> {
|
||||
let view = self.model.active_view();
|
||||
let view = self.workbook.active_view();
|
||||
let layout = &self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
CmdContext {
|
||||
model: &self.model,
|
||||
model: &self.workbook.model,
|
||||
workbook: &self.workbook,
|
||||
view,
|
||||
layout,
|
||||
registry: self.keymap_set.registry(),
|
||||
mode: &self.mode,
|
||||
@ -298,7 +290,8 @@ impl App {
|
||||
.or_else(|| layout.resolve_display(k))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.model
|
||||
self.workbook
|
||||
.model
|
||||
.get_cell(k)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
@ -310,7 +303,7 @@ impl App {
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
visible_cols: {
|
||||
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);
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
@ -335,7 +328,7 @@ impl App {
|
||||
/// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
|
||||
pub fn is_empty_model(&self) -> bool {
|
||||
use crate::model::category::CategoryKind;
|
||||
self.model.categories.values().all(|c| {
|
||||
self.workbook.model.categories.values().all(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
CategoryKind::VirtualIndex
|
||||
@ -379,7 +372,7 @@ impl App {
|
||||
&& let Some(path) = &self.file_path.clone()
|
||||
{
|
||||
let ap = persistence::autosave_path(path);
|
||||
let _ = persistence::save(&self.model, &ap);
|
||||
let _ = persistence::save(&self.workbook, &ap);
|
||||
self.last_autosave = Instant::now();
|
||||
}
|
||||
}
|
||||
@ -422,18 +415,17 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Model;
|
||||
|
||||
fn two_col_model() -> App {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap(); // → Row axis
|
||||
m.add_category("Col").unwrap(); // → Column axis
|
||||
m.category_mut("Row").unwrap().add_item("A");
|
||||
m.category_mut("Row").unwrap().add_item("B");
|
||||
m.category_mut("Row").unwrap().add_item("C");
|
||||
m.category_mut("Col").unwrap().add_item("X");
|
||||
m.category_mut("Col").unwrap().add_item("Y");
|
||||
App::new(m, None)
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Row").unwrap(); // → Row axis
|
||||
wb.add_category("Col").unwrap(); // → Column axis
|
||||
wb.model.category_mut("Row").unwrap().add_item("A");
|
||||
wb.model.category_mut("Row").unwrap().add_item("B");
|
||||
wb.model.category_mut("Row").unwrap().add_item("C");
|
||||
wb.model.category_mut("Col").unwrap().add_item("X");
|
||||
wb.model.category_mut("Col").unwrap().add_item("Y");
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
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 {
|
||||
use crate::command::cmd::navigation::CursorState;
|
||||
let view = app.model.active_view();
|
||||
let view = app.workbook.active_view();
|
||||
let cursor = CursorState {
|
||||
row: view.selected.0,
|
||||
col: view.selected.1,
|
||||
@ -462,29 +454,29 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_advance_moves_down_within_column() {
|
||||
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);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_advance_wraps_to_top_of_next_column() {
|
||||
let mut app = two_col_model();
|
||||
// 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);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.model.active_view().selected, (0, 1));
|
||||
assert_eq!(app.workbook.active_view().selected, (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_advance_stays_at_bottom_right() {
|
||||
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);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.model.active_view().selected, (2, 1));
|
||||
assert_eq!(app.workbook.active_view().selected, (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -535,22 +527,22 @@ mod tests {
|
||||
// each → column widths ~31 chars. With term_width=80, row header ~4,
|
||||
// data area ~76 → only ~2 columns actually fit. But the rough estimate
|
||||
// (80−30)/12 = 4 over-counts, so viewport_effects never scrolls.
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap();
|
||||
m.add_category("Col").unwrap();
|
||||
m.category_mut("Row").unwrap().add_item("R1");
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Row").unwrap();
|
||||
wb.add_category("Col").unwrap();
|
||||
wb.model.category_mut("Row").unwrap().add_item("R1");
|
||||
for i in 0..8 {
|
||||
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![
|
||||
("Row".to_string(), "R1".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;
|
||||
|
||||
// Press 'l' (right) 3 times to move cursor to column 3.
|
||||
@ -563,34 +555,34 @@ mod tests {
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
app.workbook.active_view().selected.1,
|
||||
3,
|
||||
"cursor should be at column 3"
|
||||
);
|
||||
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 \
|
||||
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]
|
||||
fn home_jumps_to_first_col() {
|
||||
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))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 0));
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_jumps_to_last_col() {
|
||||
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))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected, (1, 1));
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -598,38 +590,40 @@ mod tests {
|
||||
let mut app = two_col_model();
|
||||
// Add enough rows
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
app.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
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))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
|
||||
assert_eq!(app.workbook.active_view().selected.1, 0, "column preserved");
|
||||
assert!(
|
||||
app.model.active_view().selected.0 > 0,
|
||||
app.workbook.active_view().selected.0 > 0,
|
||||
"row should advance on PageDown"
|
||||
);
|
||||
// 3/4 of ~20 = 15
|
||||
assert_eq!(app.model.active_view().selected.0, 15);
|
||||
assert_eq!(app.workbook.active_view().selected.0, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_scrolls_backward() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
app.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
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))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 5);
|
||||
assert_eq!(app.workbook.active_view().selected.0, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -637,21 +631,22 @@ mod tests {
|
||||
let mut app = two_col_model();
|
||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||
for i in 0..10 {
|
||||
app.model
|
||||
app.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
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)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||||
.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");
|
||||
// With only ~5 visible rows and 13 rows, offset should 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!(
|
||||
offset > 0,
|
||||
"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() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.model
|
||||
app.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
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
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.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,
|
||||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.model.active_view().selected.0, 10);
|
||||
assert_eq!(app.workbook.active_view().selected.0, 10);
|
||||
assert!(
|
||||
app.model.active_view().row_offset > 0,
|
||||
app.workbook.active_view().row_offset > 0,
|
||||
"row_offset should scroll with small terminal, but is {}",
|
||||
app.model.active_view().row_offset
|
||||
app.workbook.active_view().row_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_in_edit_mode_commits_and_moves_right() {
|
||||
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
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
@ -706,7 +702,7 @@ mod tests {
|
||||
app.mode
|
||||
);
|
||||
assert_eq!(
|
||||
app.model.active_view().selected.1,
|
||||
app.workbook.active_view().selected.1,
|
||||
1,
|
||||
"should have moved to column 1"
|
||||
);
|
||||
@ -735,7 +731,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fresh_model_is_empty() {
|
||||
let app = App::new(Model::new("T"), None);
|
||||
let app = App::new(Workbook::new("T"), None);
|
||||
assert!(
|
||||
app.is_empty_model(),
|
||||
"a brand-new model with only virtual categories should be empty"
|
||||
@ -744,9 +740,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn model_with_user_category_is_not_empty() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Sales").unwrap();
|
||||
let app = App::new(m, None);
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Sales").unwrap();
|
||||
let app = App::new(wb, None);
|
||||
assert!(
|
||||
!app.is_empty_model(),
|
||||
"a model with a user-defined category should not be empty"
|
||||
@ -757,7 +753,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.help_page = 0;
|
||||
|
||||
@ -768,7 +764,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.help_page = 2;
|
||||
|
||||
@ -779,7 +775,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.help_page = 0;
|
||||
|
||||
@ -792,7 +788,7 @@ mod tests {
|
||||
fn help_page_clamps_at_max() {
|
||||
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.help_page = HELP_PAGE_COUNT - 1;
|
||||
|
||||
@ -809,7 +805,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
||||
@ -822,7 +818,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
@ -835,7 +831,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
@ -852,7 +848,7 @@ mod tests {
|
||||
#[test]
|
||||
fn add_item_to_nonexistent_category_sets_status() {
|
||||
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 {
|
||||
category: "Nonexistent".to_string(),
|
||||
item: "x".to_string(),
|
||||
@ -868,7 +864,7 @@ mod tests {
|
||||
#[test]
|
||||
fn add_formula_with_bad_syntax_sets_status() {
|
||||
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 {
|
||||
raw: "!!!invalid".to_string(),
|
||||
target_category: "X".to_string(),
|
||||
|
||||
@ -8,7 +8,7 @@ use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
|
||||
use crate::ui::panel::PanelContent;
|
||||
use crate::view::Axis;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
match axis {
|
||||
@ -20,14 +20,18 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
}
|
||||
|
||||
pub struct CategoryContent<'a> {
|
||||
model: &'a Model,
|
||||
view: &'a View,
|
||||
tree: Vec<CatTreeEntry>,
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
let y = inner.y + index as u16;
|
||||
let view = self.model.active_view();
|
||||
let view = self.view;
|
||||
|
||||
let base_style = if is_selected {
|
||||
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);
|
||||
impl Effect for AddCategory {
|
||||
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 {
|
||||
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);
|
||||
} else {
|
||||
app.status_msg = format!("Unknown category '{}'", self.category);
|
||||
@ -49,7 +49,7 @@ pub struct AddItemInGroup {
|
||||
}
|
||||
impl Effect for AddItemInGroup {
|
||||
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);
|
||||
} else {
|
||||
app.status_msg = format!("Unknown category '{}'", self.category);
|
||||
@ -61,7 +61,7 @@ impl Effect for AddItemInGroup {
|
||||
pub struct SetCell(pub CellKey, pub CellValue);
|
||||
impl Effect for SetCell {
|
||||
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);
|
||||
impl Effect for ClearCell {
|
||||
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
|
||||
// via Model::measure_item_names().
|
||||
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);
|
||||
}
|
||||
app.model.add_formula(formula);
|
||||
app.workbook.model.add_formula(formula);
|
||||
}
|
||||
Err(e) => {
|
||||
app.status_msg = format!("Formula error: {e}");
|
||||
@ -106,7 +106,8 @@ pub struct RemoveFormula {
|
||||
}
|
||||
impl Effect for RemoveFormula {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.model
|
||||
.remove_formula(&self.target, &self.target_category);
|
||||
}
|
||||
}
|
||||
@ -133,7 +134,7 @@ impl Effect for EnterEditAtCursor {
|
||||
pub struct TogglePruneEmpty;
|
||||
impl Effect for TogglePruneEmpty {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -155,7 +156,7 @@ pub struct RemoveItem {
|
||||
}
|
||||
impl Effect for RemoveItem {
|
||||
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);
|
||||
impl Effect for RemoveCategory {
|
||||
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);
|
||||
impl Effect for CreateView {
|
||||
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);
|
||||
impl Effect for DeleteView {
|
||||
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);
|
||||
impl Effect for SwitchView {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let current = app.model.active_view.clone();
|
||||
let current = app.workbook.active_view.clone();
|
||||
if current != self.0 {
|
||||
app.view_back_stack.push(current);
|
||||
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 {
|
||||
fn apply(&self, app: &mut App) {
|
||||
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);
|
||||
let _ = app.model.switch_view(&prev);
|
||||
let _ = app.workbook.switch_view(&prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -217,9 +218,9 @@ pub struct ViewForward;
|
||||
impl Effect for ViewForward {
|
||||
fn apply(&self, app: &mut App) {
|
||||
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);
|
||||
let _ = app.model.switch_view(&next);
|
||||
let _ = app.workbook.switch_view(&next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -231,7 +232,7 @@ pub struct SetAxis {
|
||||
}
|
||||
impl Effect for SetAxis {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view_mut()
|
||||
.set_axis(&self.category, self.axis);
|
||||
}
|
||||
@ -244,7 +245,7 @@ pub struct SetPageSelection {
|
||||
}
|
||||
impl Effect for SetPageSelection {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view_mut()
|
||||
.set_page_selection(&self.category, &self.item);
|
||||
}
|
||||
@ -257,7 +258,7 @@ pub struct ToggleGroup {
|
||||
}
|
||||
impl Effect for ToggleGroup {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse(&self.category, &self.group);
|
||||
}
|
||||
@ -270,7 +271,7 @@ pub struct HideItem {
|
||||
}
|
||||
impl Effect for HideItem {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view_mut()
|
||||
.hide_item(&self.category, &self.item);
|
||||
}
|
||||
@ -283,7 +284,7 @@ pub struct ShowItem {
|
||||
}
|
||||
impl Effect for ShowItem {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view_mut()
|
||||
.show_item(&self.category, &self.item);
|
||||
}
|
||||
@ -293,7 +294,7 @@ impl Effect for ShowItem {
|
||||
pub struct TransposeAxes;
|
||||
impl Effect for TransposeAxes {
|
||||
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);
|
||||
impl Effect for CycleAxis {
|
||||
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);
|
||||
impl Effect for SetNumberFormat {
|
||||
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);
|
||||
impl Effect for SetSelected {
|
||||
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);
|
||||
impl Effect for SetRowOffset {
|
||||
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);
|
||||
impl Effect for SetColOffset {
|
||||
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" {
|
||||
// Update the cell's value
|
||||
let value = if new_value.is_empty() {
|
||||
app.model.clear_cell(orig_key);
|
||||
app.workbook.model.clear_cell(orig_key);
|
||||
continue;
|
||||
} else if let Ok(n) = new_value.parse::<f64>() {
|
||||
CellValue::Number(n)
|
||||
} else {
|
||||
CellValue::Text(new_value.clone())
|
||||
};
|
||||
app.model.set_cell(orig_key.clone(), value);
|
||||
app.workbook.model.set_cell(orig_key.clone(), value);
|
||||
} else {
|
||||
// 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(),
|
||||
None => continue,
|
||||
};
|
||||
app.model.clear_cell(orig_key);
|
||||
app.workbook.model.clear_cell(orig_key);
|
||||
// Build new key by replacing the coord
|
||||
let new_coords: Vec<(String, String)> = orig_key
|
||||
.0
|
||||
@ -478,10 +479,10 @@ impl Effect for ApplyAndClearDrill {
|
||||
.collect();
|
||||
let new_key = CellKey::new(new_coords);
|
||||
// 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());
|
||||
}
|
||||
app.model.set_cell(new_key, value);
|
||||
app.workbook.model.set_cell(new_key, value);
|
||||
}
|
||||
}
|
||||
app.dirty = true;
|
||||
@ -513,7 +514,7 @@ pub struct Save;
|
||||
impl Effect for Save {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(ref path) = app.file_path {
|
||||
match crate::persistence::save(&app.model, path) {
|
||||
match crate::persistence::save(&app.workbook,path) {
|
||||
Ok(()) => {
|
||||
app.dirty = false;
|
||||
app.status_msg = format!("Saved to {}", path.display());
|
||||
@ -532,7 +533,7 @@ impl Effect for Save {
|
||||
pub struct SaveAs(pub PathBuf);
|
||||
impl Effect for SaveAs {
|
||||
fn apply(&self, app: &mut App) {
|
||||
match crate::persistence::save(&app.model, &self.0) {
|
||||
match crate::persistence::save(&app.workbook,&self.0) {
|
||||
Ok(()) => {
|
||||
app.file_path = Some(self.0.clone());
|
||||
app.dirty = false;
|
||||
@ -648,9 +649,9 @@ impl Effect for WizardKey {
|
||||
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
|
||||
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
|
||||
crossterm::event::KeyCode::Enter => match wizard.build_model() {
|
||||
Ok(mut model) => {
|
||||
model.normalize_view_state();
|
||||
app.model = model;
|
||||
Ok(mut workbook) => {
|
||||
workbook.normalize_view_state();
|
||||
app.workbook = workbook;
|
||||
app.formula_cursor = 0;
|
||||
app.dirty = true;
|
||||
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);
|
||||
impl Effect for ExportCsv {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let view_name = app.model.active_view.clone();
|
||||
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
|
||||
let view_name = app.workbook.active_view.clone();
|
||||
match crate::persistence::export_csv(&app.workbook, &view_name, &self.0) {
|
||||
Ok(()) => {
|
||||
app.status_msg = format!("Exported to {}", self.0.display());
|
||||
}
|
||||
@ -723,7 +724,7 @@ impl Effect for LoadModel {
|
||||
match crate::persistence::load(&self.0) {
|
||||
Ok(mut loaded) => {
|
||||
loaded.normalize_view_state();
|
||||
app.model = loaded;
|
||||
app.workbook = loaded;
|
||||
app.status_msg = format!("Loaded from {}", self.0.display());
|
||||
}
|
||||
Err(e) => {
|
||||
@ -833,8 +834,8 @@ impl Effect for ImportJsonHeadless {
|
||||
};
|
||||
|
||||
match pipeline.build_model() {
|
||||
Ok(new_model) => {
|
||||
app.model = new_model;
|
||||
Ok(new_workbook) => {
|
||||
app.workbook = new_workbook;
|
||||
app.status_msg = "Imported successfully".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
@ -952,18 +953,18 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Model;
|
||||
use crate::workbook::Workbook;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
fn test_app() -> App {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Type").unwrap().add_item("Clothing");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.category_mut("Month").unwrap().add_item("Feb");
|
||||
App::new(m, None)
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
// ── Model mutation effects ──────────────────────────────────────────
|
||||
@ -972,7 +973,7 @@ mod tests {
|
||||
fn add_category_effect() {
|
||||
let mut app = test_app();
|
||||
AddCategory("Region".to_string()).apply(&mut app);
|
||||
assert!(app.model.category("Region").is_some());
|
||||
assert!(app.workbook.model.category("Region").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -984,6 +985,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
let items: Vec<&str> = app
|
||||
.workbook
|
||||
.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
@ -1012,10 +1014,10 @@ mod tests {
|
||||
("Month".into(), "Jan".into()),
|
||||
]);
|
||||
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);
|
||||
assert_eq!(app.model.get_cell(&key), None);
|
||||
assert_eq!(app.workbook.model.get_cell(&key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1026,7 +1028,7 @@ mod tests {
|
||||
target_category: "Type".to_string(),
|
||||
}
|
||||
.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
|
||||
@ -1037,7 +1039,7 @@ mod tests {
|
||||
let mut app = test_app();
|
||||
// "Margin" does not exist as an item in "Type" before adding the formula
|
||||
assert!(
|
||||
!app.model
|
||||
!app.workbook.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
.ordered_item_names()
|
||||
@ -1049,6 +1051,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
let items: Vec<&str> = app
|
||||
.workbook
|
||||
.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
@ -1073,7 +1076,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
// 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!(
|
||||
effective.contains(&"Margin".to_string()),
|
||||
"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
|
||||
assert!(
|
||||
!app.model
|
||||
!app.workbook.model
|
||||
.category("_Measure")
|
||||
.unwrap()
|
||||
.ordered_item_names()
|
||||
@ -1109,13 +1112,13 @@ mod tests {
|
||||
target_category: "Type".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(!app.model.formulas().is_empty());
|
||||
assert!(!app.workbook.model.formulas().is_empty());
|
||||
RemoveFormula {
|
||||
target: "Clothing".to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(app.model.formulas().is_empty());
|
||||
assert!(app.workbook.model.formulas().is_empty());
|
||||
}
|
||||
|
||||
// ── View effects ────────────────────────────────────────────────────
|
||||
@ -1123,11 +1126,11 @@ mod tests {
|
||||
#[test]
|
||||
fn switch_view_pushes_to_back_stack() {
|
||||
let mut app = test_app();
|
||||
app.model.create_view("View 2");
|
||||
app.workbook.create_view("View 2");
|
||||
assert!(app.view_back_stack.is_empty());
|
||||
|
||||
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()]);
|
||||
// Forward stack should be cleared
|
||||
assert!(app.view_forward_stack.is_empty());
|
||||
@ -1143,19 +1146,19 @@ mod tests {
|
||||
#[test]
|
||||
fn view_back_and_forward() {
|
||||
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);
|
||||
assert_eq!(app.model.active_view.as_str(), "View 2");
|
||||
assert_eq!(app.workbook.active_view.as_str(), "View 2");
|
||||
|
||||
// Go back
|
||||
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!(app.view_back_stack.is_empty());
|
||||
|
||||
// Go forward
|
||||
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!(app.view_forward_stack.is_empty());
|
||||
}
|
||||
@ -1163,19 +1166,19 @@ mod tests {
|
||||
#[test]
|
||||
fn view_back_with_empty_stack_is_noop() {
|
||||
let mut app = test_app();
|
||||
let before = app.model.active_view.clone();
|
||||
let before = app.workbook.active_view.clone();
|
||||
ViewBack.apply(&mut app);
|
||||
assert_eq!(app.model.active_view, before);
|
||||
assert_eq!(app.workbook.active_view, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_delete_view() {
|
||||
let mut app = test_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);
|
||||
assert!(!app.model.views.contains_key("View 2"));
|
||||
assert!(!app.workbook.views.contains_key("View 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1186,21 +1189,21 @@ mod tests {
|
||||
axis: Axis::Page,
|
||||
}
|
||||
.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]
|
||||
fn transpose_axes_effect() {
|
||||
let mut app = test_app();
|
||||
let row_before: Vec<String> = app
|
||||
.model
|
||||
.workbook
|
||||
.active_view()
|
||||
.categories_on(Axis::Row)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let col_before: Vec<String> = app
|
||||
.model
|
||||
.workbook
|
||||
.active_view()
|
||||
.categories_on(Axis::Column)
|
||||
.into_iter()
|
||||
@ -1208,14 +1211,14 @@ mod tests {
|
||||
.collect();
|
||||
TransposeAxes.apply(&mut app);
|
||||
let row_after: Vec<String> = app
|
||||
.model
|
||||
.workbook
|
||||
.active_view()
|
||||
.categories_on(Axis::Row)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let col_after: Vec<String> = app
|
||||
.model
|
||||
.workbook
|
||||
.active_view()
|
||||
.categories_on(Axis::Column)
|
||||
.into_iter()
|
||||
@ -1231,7 +1234,7 @@ mod tests {
|
||||
fn set_selected_effect() {
|
||||
let mut app = test_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]
|
||||
@ -1239,8 +1242,8 @@ mod tests {
|
||||
let mut app = test_app();
|
||||
SetRowOffset(10).apply(&mut app);
|
||||
SetColOffset(5).apply(&mut app);
|
||||
assert_eq!(app.model.active_view().row_offset, 10);
|
||||
assert_eq!(app.model.active_view().col_offset, 5);
|
||||
assert_eq!(app.workbook.active_view().row_offset, 10);
|
||||
assert_eq!(app.workbook.active_view().col_offset, 5);
|
||||
}
|
||||
|
||||
// ── App state effects ───────────────────────────────────────────────
|
||||
@ -1417,7 +1420,7 @@ mod tests {
|
||||
("Month".into(), "Jan".into()),
|
||||
]);
|
||||
// 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))];
|
||||
StartDrill(records).apply(&mut app);
|
||||
@ -1433,7 +1436,7 @@ mod tests {
|
||||
ApplyAndClearDrill.apply(&mut app);
|
||||
assert!(app.drill_state.is_none());
|
||||
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]
|
||||
@ -1443,7 +1446,7 @@ mod tests {
|
||||
("Type".into(), "Food".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))];
|
||||
StartDrill(records).apply(&mut app);
|
||||
@ -1459,15 +1462,16 @@ mod tests {
|
||||
ApplyAndClearDrill.apply(&mut app);
|
||||
assert!(app.dirty);
|
||||
// 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
|
||||
let new_key = CellKey::new(vec![
|
||||
("Type".into(), "Drink".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
|
||||
let items: Vec<&str> = app
|
||||
.workbook
|
||||
.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
@ -1484,7 +1488,7 @@ mod tests {
|
||||
("Type".into(), "Food".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))];
|
||||
StartDrill(records).apply(&mut app);
|
||||
@ -1498,7 +1502,7 @@ mod tests {
|
||||
.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 ──────────────────────────────────────────────────
|
||||
@ -1506,11 +1510,11 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_prune_empty_effect() {
|
||||
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);
|
||||
assert_ne!(app.model.active_view().prune_empty, before);
|
||||
assert_ne!(app.workbook.active_view().prune_empty, before);
|
||||
TogglePruneEmpty.apply(&mut app);
|
||||
assert_eq!(app.model.active_view().prune_empty, before);
|
||||
assert_eq!(app.workbook.active_view().prune_empty, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1532,6 +1536,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
let items: Vec<&str> = app
|
||||
.workbook
|
||||
.model
|
||||
.category("Type")
|
||||
.unwrap()
|
||||
@ -1541,7 +1546,7 @@ mod tests {
|
||||
assert!(!items.contains(&"Food"));
|
||||
|
||||
RemoveCategory("Month".to_string()).apply(&mut app);
|
||||
assert!(app.model.category("Month").is_none());
|
||||
assert!(app.workbook.model.category("Month").is_none());
|
||||
}
|
||||
|
||||
// ── Number format ───────────────────────────────────────────────────
|
||||
@ -1550,7 +1555,7 @@ mod tests {
|
||||
fn set_number_format_effect() {
|
||||
let mut app = test_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 ──────────────────────────────────────────────────
|
||||
@ -1563,7 +1568,7 @@ mod tests {
|
||||
item: "Food".to_string(),
|
||||
}
|
||||
.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 ─────────────────────────────────────────────────
|
||||
@ -1576,14 +1581,14 @@ mod tests {
|
||||
item: "Food".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(app.model.active_view().is_hidden("Type", "Food"));
|
||||
assert!(app.workbook.active_view().is_hidden("Type", "Food"));
|
||||
|
||||
ShowItem {
|
||||
category: "Type".to_string(),
|
||||
item: "Food".to_string(),
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(!app.model.active_view().is_hidden("Type", "Food"));
|
||||
assert!(!app.workbook.active_view().is_hidden("Type", "Food"));
|
||||
}
|
||||
|
||||
// ── Toggle group ────────────────────────────────────────────────────
|
||||
@ -1597,7 +1602,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(
|
||||
app.model
|
||||
app.workbook
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
);
|
||||
@ -1607,7 +1612,7 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(
|
||||
!app.model
|
||||
!app.workbook
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
);
|
||||
@ -1618,9 +1623,9 @@ mod tests {
|
||||
#[test]
|
||||
fn cycle_axis_effect() {
|
||||
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);
|
||||
let after = app.model.active_view().axis_of("Type");
|
||||
let after = app.workbook.active_view().axis_of("Type");
|
||||
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::ui::app::AppMode;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
use crate::view::{AxisEntry, GridLayout, View};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
const MIN_COL_WIDTH: u16 = 5;
|
||||
@ -22,6 +22,8 @@ const GROUP_COLLAPSED: &str = "▶";
|
||||
|
||||
pub struct GridWidget<'a> {
|
||||
pub model: &'a Model,
|
||||
pub view: &'a View,
|
||||
pub view_name: &'a str,
|
||||
pub layout: &'a GridLayout,
|
||||
pub mode: &'a AppMode,
|
||||
pub search_query: &'a str,
|
||||
@ -30,8 +32,11 @@ pub struct GridWidget<'a> {
|
||||
}
|
||||
|
||||
impl<'a> GridWidget<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
model: &'a Model,
|
||||
view: &'a View,
|
||||
view_name: &'a str,
|
||||
layout: &'a GridLayout,
|
||||
mode: &'a AppMode,
|
||||
search_query: &'a str,
|
||||
@ -40,6 +45,8 @@ impl<'a> GridWidget<'a> {
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
view,
|
||||
view_name,
|
||||
layout,
|
||||
mode,
|
||||
search_query,
|
||||
@ -49,7 +56,7 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
let view = self.model.active_view();
|
||||
let view = self.view;
|
||||
let layout = self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
@ -494,10 +501,9 @@ impl<'a> GridWidget<'a> {
|
||||
|
||||
impl<'a> Widget for GridWidget<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let view_name = self.model.active_view.clone();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" View: {} ", view_name));
|
||||
.title(format!(" View: {} ", self.view_name));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@ -674,27 +680,33 @@ mod tests {
|
||||
|
||||
use super::GridWidget;
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::GridLayout;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render a GridWidget into a fresh buffer of the given size.
|
||||
fn render(model: &mut Model, width: u16, height: u16) -> Buffer {
|
||||
let none_cats: Vec<String> = model
|
||||
.active_view()
|
||||
.categories_on(crate::view::Axis::None)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
model.recompute_formulas(&none_cats);
|
||||
fn render(wb: &mut Workbook, width: u16, height: u16) -> Buffer {
|
||||
let none_cats = wb.active_view().none_cats();
|
||||
wb.model.recompute_formulas(&none_cats);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let bufs = std::collections::HashMap::new();
|
||||
let layout = GridLayout::new(model, model.active_view());
|
||||
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let view_name = wb.active_view.clone();
|
||||
GridWidget::new(
|
||||
&wb.model,
|
||||
wb.active_view(),
|
||||
&view_name,
|
||||
&layout,
|
||||
&AppMode::Normal,
|
||||
"",
|
||||
&bufs,
|
||||
None,
|
||||
)
|
||||
.render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
@ -723,24 +735,25 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal model: Type on Row, Month on Column.
|
||||
/// Minimal workbook: Type on Row, Month on Column.
|
||||
/// Every cell has a value so rows/cols survive pruning.
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("Test");
|
||||
fn two_cat_model() -> Workbook {
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
m.add_category("Month").unwrap(); // → Column
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
c.add_item("Clothing");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
c.add_item("Feb");
|
||||
}
|
||||
// Fill every cell so nothing is pruned as empty.
|
||||
for t in ["Food", "Clothing"] {
|
||||
for mo in ["Jan", "Feb"] {
|
||||
m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
|
||||
m.model
|
||||
.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
|
||||
}
|
||||
}
|
||||
m
|
||||
@ -771,7 +784,7 @@ mod tests {
|
||||
#[test]
|
||||
fn cell_value_appears_in_correct_position() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||
CellValue::Number(123.0),
|
||||
);
|
||||
@ -782,15 +795,15 @@ mod tests {
|
||||
#[test]
|
||||
fn multiple_cell_values_all_appear() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Feb")]),
|
||||
CellValue::Number(200.0),
|
||||
);
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
@ -803,13 +816,13 @@ mod tests {
|
||||
#[test]
|
||||
fn unset_cells_show_no_value() {
|
||||
// Build a model without the two_cat_model helper (which fills every cell).
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.model.category_mut("Type").unwrap().add_item("Food");
|
||||
m.model.category_mut("Month").unwrap().add_item("Jan");
|
||||
// Set one cell so the row/col isn't pruned
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
@ -830,11 +843,11 @@ mod tests {
|
||||
#[test]
|
||||
fn total_row_sums_column_correctly() {
|
||||
let mut m = two_cat_model();
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
@ -850,17 +863,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn page_filter_bar_shows_category_and_selection() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
m.add_category("Month").unwrap(); // → Column
|
||||
m.add_category("Payer").unwrap(); // → Page
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Payer") {
|
||||
if let Some(c) = m.model.category_mut("Payer") {
|
||||
c.add_item("Alice");
|
||||
c.add_item("Bob");
|
||||
}
|
||||
@ -874,17 +887,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn page_filter_defaults_to_first_item() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Payer").unwrap();
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Payer") {
|
||||
if let Some(c) = m.model.category_mut("Payer") {
|
||||
c.add_item("Alice");
|
||||
c.add_item("Bob");
|
||||
}
|
||||
@ -900,21 +913,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn formula_cell_renders_computed_value() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Region").unwrap(); // → Column
|
||||
m.category_mut("_Measure").unwrap().add_item("Revenue");
|
||||
m.category_mut("_Measure").unwrap().add_item("Cost");
|
||||
m.model.category_mut("_Measure").unwrap().add_item("Revenue");
|
||||
m.model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||
// Profit is a formula target — dynamically included in _Measure
|
||||
m.category_mut("Region").unwrap().add_item("East");
|
||||
m.set_cell(
|
||||
m.model.category_mut("Region").unwrap().add_item("East");
|
||||
m.model.set_cell(
|
||||
coord(&[("_Measure", "Revenue"), ("Region", "East")]),
|
||||
CellValue::Number(1000.0),
|
||||
);
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("_Measure", "Cost"), ("Region", "East")]),
|
||||
CellValue::Number(600.0),
|
||||
);
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||
m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||
m.active_view_mut()
|
||||
.set_axis("_Index", crate::view::Axis::None);
|
||||
m.active_view_mut()
|
||||
@ -932,18 +945,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_row_categories_produce_cross_product_labels() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
m.add_category("Month").unwrap(); // → Column
|
||||
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
c.add_item("Clothing");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Recipient") {
|
||||
if let Some(c) = m.model.category_mut("Recipient") {
|
||||
c.add_item("Alice");
|
||||
c.add_item("Bob");
|
||||
}
|
||||
@ -952,7 +965,7 @@ mod tests {
|
||||
// Populate cells so rows/cols survive pruning
|
||||
for t in ["Food", "Clothing"] {
|
||||
for r in ["Alice", "Bob"] {
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
@ -978,24 +991,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_row_categories_include_all_coords_in_cell_lookup() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.add_category("Recipient").unwrap();
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Recipient") {
|
||||
if let Some(c) = m.model.category_mut("Recipient") {
|
||||
c.add_item("Alice");
|
||||
c.add_item("Bob");
|
||||
}
|
||||
m.active_view_mut()
|
||||
.set_axis("Recipient", crate::view::Axis::Row);
|
||||
// Set data at the full 3-coordinate key
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
||||
CellValue::Number(77.0),
|
||||
);
|
||||
@ -1005,17 +1018,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_column_categories_produce_cross_product_headers() {
|
||||
let mut m = Model::new("Test");
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
m.add_category("Month").unwrap(); // → Column
|
||||
m.add_category("Year").unwrap(); // → Page by default; move to Column
|
||||
if let Some(c) = m.category_mut("Type") {
|
||||
if let Some(c) = m.model.category_mut("Type") {
|
||||
c.add_item("Food");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Month") {
|
||||
if let Some(c) = m.model.category_mut("Month") {
|
||||
c.add_item("Jan");
|
||||
}
|
||||
if let Some(c) = m.category_mut("Year") {
|
||||
if let Some(c) = m.model.category_mut("Year") {
|
||||
c.add_item("2024");
|
||||
c.add_item("2025");
|
||||
}
|
||||
@ -1023,7 +1036,7 @@ mod tests {
|
||||
.set_axis("Year", crate::view::Axis::Column);
|
||||
// Populate cells so cols survive pruning
|
||||
for y in ["2024", "2025"] {
|
||||
m.set_cell(
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
|
||||
@ -8,18 +8,25 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::Axis;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
pub struct TileBar<'a> {
|
||||
pub model: &'a Model,
|
||||
pub view: &'a View,
|
||||
pub mode: &'a AppMode,
|
||||
pub tile_cat_idx: usize,
|
||||
}
|
||||
|
||||
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 {
|
||||
model,
|
||||
view,
|
||||
mode,
|
||||
tile_cat_idx,
|
||||
}
|
||||
@ -44,7 +51,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
Style::default(),
|
||||
);
|
||||
|
||||
let view = self.model.active_view();
|
||||
let view = self.view;
|
||||
|
||||
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
||||
Some(self.tile_cat_idx)
|
||||
|
||||
@ -4,31 +4,31 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::panel::PanelContent;
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
pub struct ViewContent<'a> {
|
||||
view_names: Vec<String>,
|
||||
active_view: String,
|
||||
model: &'a Model,
|
||||
workbook: &'a Workbook,
|
||||
}
|
||||
|
||||
impl<'a> ViewContent<'a> {
|
||||
pub fn new(model: &'a Model) -> Self {
|
||||
let view_names: Vec<String> = model.views.keys().cloned().collect();
|
||||
let active_view = model.active_view.clone();
|
||||
pub fn new(workbook: &'a Workbook) -> Self {
|
||||
let view_names: Vec<String> = workbook.views.keys().cloned().collect();
|
||||
let active_view = workbook.active_view.clone();
|
||||
Self {
|
||||
view_names,
|
||||
active_view,
|
||||
model,
|
||||
workbook,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
|
||||
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();
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user