1use std::borrow::Cow;
16use std::collections::HashMap;
17use std::ops::RangeInclusive;
18
19use improv_core_model::{
20 parser, BinaryOp, CanvasRect, CategoryId, Expr, Filter, FuncId, ItemId, MatrixPlacement,
21 Measure, MeasureId, MeasureKind, Model, Name, ParseError, UnaryOp, Value, ValueType, View,
22 ViewId,
23};
24use improv_engine::session::{Engine, Snapshot};
25use improv_engine::{encode_coord, CellValue, CoordKey};
26use improv_nl_formula::{describe_formula, parse_nl_formula, NlContext};
27use improv_store::Store;
28
29use crate::csv_wizard::{self, ExportForm, ImportForm};
30use crate::diagnostics;
31use crate::menu::{self, Command};
32
33#[derive(Debug, Clone, Default)]
47pub(crate) struct Matrix {
48 uid: u64,
52 measure: Option<MeasureId>,
54 axis_order: Vec<CategoryId>,
59 n_rows: usize,
64 n_cols: usize,
65 page_idx: Vec<usize>,
68 axis_for: Option<MeasureId>,
71 filters: Vec<Filter>,
75 cursor_row: usize,
78 cursor_col: usize,
79 anchor: Option<(usize, usize)>,
82 editing: Option<(MeasureId, CoordKey)>,
84 edit_buf: String,
85 rect: CanvasRect,
88 gutters: Option<GutterRects>,
92 rendered_axes: Option<(Vec<CategoryId>, Vec<CategoryId>)>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum FormulaEditor {
106 Row,
108 NewLine,
110}
111
112impl FormulaEditor {
113 fn id(self) -> egui::Id {
114 egui::Id::new(match self {
115 FormulaEditor::Row => "formula_edit",
116 FormulaEditor::NewLine => "formula_new",
117 })
118 }
119}
120
121fn caret_byte(ctx: &egui::Context, id: egui::Id, text: &str) -> usize {
124 egui::TextEdit::load_state(ctx, id)
125 .and_then(|s| s.cursor.char_range())
126 .map_or(text.len(), |r| {
127 text.char_indices()
128 .nth(r.primary.index)
129 .map_or(text.len(), |(b, _)| b)
130 })
131}
132
133fn set_caret(ctx: &egui::Context, id: egui::Id, text: &str, at: usize) {
135 let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
136 let ci = text[..at.min(text.len())].chars().count();
137 let c = egui::text::CCursor::new(ci);
138 state
139 .cursor
140 .set_char_range(Some(egui::text::CCursorRange::one(c)));
141 state.store(ctx, id);
142}
143
144pub struct ImprovApp {
146 db: String,
148 model: Model,
149 engine: Option<Engine>,
151 snapshot: Snapshot,
152 matrices: Vec<Matrix>,
156 focus: usize,
160 current_view: Option<ViewId>,
163 status: String,
164
165 formula_buf: String,
168 formula_for: Option<MeasureId>,
171 formula_error: Option<crate::diagnostics::Diagnostic>,
176 locale: improv_core_model::Locale,
180 new_formula_line: String,
183 new_formula_error: Option<crate::diagnostics::Diagnostic>,
186 view_name: String,
188 browser_sel: Option<ViewId>,
191
192 show_chart: bool,
194 chart_line: bool,
195
196 show_csv_wizard: bool,
199 import_form: ImportForm,
200 show_inspector: bool,
202 fn_help: crate::function_help::Browser,
204 formula_target: FormulaEditor,
206 show_browser: bool,
208 quit_armed: bool,
211 dirty: bool,
216 save_token: improv_store::SaveToken,
219 last_title: String,
221 status_shown: String,
224 status_at: f64,
225 menu: menu::MenuState,
227 export_form: ExportForm,
228
229 undo_stack: Vec<Model>,
233 redo_stack: Vec<Model>,
234
235 formula_row_rects: Vec<(MeasureId, egui::Rect)>,
241}
242
243#[derive(Clone, Copy, Debug, PartialEq)]
253pub(crate) struct GutterRects {
254 top: egui::Rect,
256 left: egui::Rect,
258 table: egui::Rect,
260 well: egui::Rect,
262 hbar: egui::Rect,
265}
266
267const EDGE_EPS: f32 = 0.5;
271
272fn gutters_frame_table(g: &GutterRects) -> bool {
283 let adjoins = |a: f32, b: f32| (a - b).abs() <= EDGE_EPS;
284 adjoins(g.left.max.x, g.table.min.x)
285 && adjoins(g.top.max.y, g.table.min.y)
286 && g.left.min.y <= g.table.min.y + EDGE_EPS
288 && g.left.max.y + EDGE_EPS >= g.table.min.y
289 && g.top.min.x <= g.table.min.x + EDGE_EPS
291 && g.top.max.x + EDGE_EPS >= g.table.min.x
292 && g.well.min.y + EDGE_EPS >= g.left.max.y
297 && adjoins(g.well.min.x, g.left.min.x)
298}
299
300fn next_matrix_uid() -> u64 {
302 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
303 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
304}
305
306fn matrix_window_id(uid: u64) -> egui::Id {
309 crate::nxwin::NxWindow::new(("matrix", uid), "").id()
310}
311
312fn anchored_rect(ctx: &egui::Context, win: egui::Id, home: egui::Rect) -> egui::Rect {
317 let last = win.with("home");
318 let prev_home = ctx.data_mut(|d| {
319 let prev = d.get_temp::<egui::Rect>(last);
320 d.insert_temp(last, home);
321 prev
322 });
323 crate::nxwin::stored_rect(ctx, win)
324 .filter(|&at| Some(at) != prev_home)
325 .unwrap_or(home)
326}
327
328fn gutters_have_room(g: &GutterRects) -> bool {
337 g.well.min.y >= g.top.max.y && g.left.height() > 0.0
338}
339
340type GridScroll =
343 egui::scroll_area::ScrollAreaOutput<(egui::scroll_area::ScrollAreaOutput<()>, egui::Rect)>;
344
345const TILE_H: f32 = 17.0;
347
348const CASCADE: f32 = 28.0;
352
353const STATUS_TTL: f64 = 5.0;
355
356const MENU_CLEAR: f32 = crate::menu::MENU_W + 12.0;
359
360const MATRIX_MIN_W: f32 = 240.0;
364const MATRIX_MIN_H: f32 = 180.0;
365
366const GRID_MIN_H: f32 = 120.0;
369
370const UNDO_DEPTH: usize = 50;
381
382pub const INITIAL_WINDOW: [f32; 2] = [1280.0, 800.0];
385pub const MIN_WINDOW: [f32; 2] = [960.0, 600.0];
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Axis {
392 Rows,
393 Columns,
394 Pages,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum FilterReq {
401 Toggle(CategoryId, ItemId),
403 Clear(CategoryId),
405}
406
407pub(crate) type ChartAxes = (Vec<CategoryId>, Vec<CategoryId>, Vec<(CategoryId, ItemId)>);
410
411impl Matrix {
412 fn new(model: &Model, measure: Option<MeasureId>) -> Matrix {
415 let axis_order = natural_axis_order(model, measure);
416 let mut m = Matrix {
417 uid: next_matrix_uid(),
418 measure,
419 n_rows: 1.min(axis_order.len()),
420 n_cols: 1.min(axis_order.len().saturating_sub(1)),
421 axis_order,
422 axis_for: measure,
423 rect: CanvasRect {
424 x: MENU_CLEAR,
425 ..CanvasRect::default()
426 },
427 ..Matrix::default()
428 };
429 let lines = |cats: &[CategoryId]| product_len(&m.axis_item_lists(model, cats)) as f32;
431 let (rows, cols) = (lines(&m.row_cats(model)), lines(&m.col_cats(model)));
432 m.rect.w = (120.0 + 80.0 * cols).clamp(360.0, 900.0);
433 m.rect.h = (200.0 + 20.0 * rows + 110.0).clamp(300.0, 700.0);
434 m
435 }
436
437 fn from_placement(model: &Model, p: &MatrixPlacement) -> Option<Matrix> {
445 if !model.measures.contains_key(&p.measure) {
446 return None;
447 }
448 let mut m = Matrix {
449 uid: next_matrix_uid(),
450 measure: Some(p.measure),
451 axis_order: if p.axis_order.is_empty() {
452 natural_axis_order(model, Some(p.measure))
453 } else {
454 p.axis_order.clone()
455 },
456 axis_for: Some(p.measure),
457 filters: p.filters.clone(),
458 rect: p.rect,
459 ..Matrix::default()
460 };
461 let len = m.axis_order.len();
463 m.n_rows = p.n_rows.min(len);
464 m.n_cols = p.n_cols.min(len.saturating_sub(m.n_rows));
465 let page_cats = m.page_cats(model);
467 m.page_idx = vec![0; page_cats.len()];
468 for (pi, cat) in page_cats.iter().enumerate() {
469 if let Some((_, it)) = p.page_items.iter().find(|(c, _)| c == cat) {
470 if let Some(idx) = m
471 .sorted_items(model, *cat)
472 .iter()
473 .position(|(id, _)| id == it)
474 {
475 if let Some(slot) = m.page_idx.get_mut(pi) {
476 *slot = idx;
477 }
478 }
479 }
480 }
481 m.clamp_cursor(model);
482 Some(m)
483 }
484
485 fn to_placement(&self, model: &Model) -> Option<MatrixPlacement> {
489 Some(MatrixPlacement {
490 measure: self.measure?,
491 axis_order: self.axis_order.clone(),
492 n_rows: self.n_rows,
493 n_cols: self.n_cols,
494 page_items: self.pinned_pages(model),
495 filters: self.filters.clone(),
496 rect: self.rect,
497 })
498 }
499
500 fn select(&mut self, measure: Option<MeasureId>) {
503 self.measure = measure;
504 self.editing = None;
505 }
506
507 fn sync_axis_state(&mut self, model: &Model) {
510 if self.axis_for != self.measure {
511 self.axis_for = self.measure;
512 self.axis_order = natural_axis_order(model, self.measure);
513 self.n_rows = 1.min(self.axis_order.len());
514 self.n_cols = 1.min(self.axis_order.len().saturating_sub(self.n_rows));
515 self.page_idx = vec![0; self.page_cats(model).len()];
516 self.filters.clear();
517 self.cursor_row = 0;
518 self.cursor_col = 0;
519 self.anchor = None;
520 } else if self.page_idx.len() != self.page_cats(model).len() {
521 let n = self.page_cats(model).len();
523 self.page_idx.resize(n, 0);
524 }
525 self.clamp_cursor(model);
526 }
527
528 fn measure_dims(&self, model: &Model) -> Vec<CategoryId> {
543 natural_axis_order(model, self.measure)
544 }
545
546 fn live_axis_slice(&self, model: &Model, skip: usize, take: usize) -> Vec<CategoryId> {
550 let dims = self.measure_dims(model);
551 self.axis_order
552 .iter()
553 .skip(skip)
554 .take(take)
555 .filter(|c| dims.contains(c))
556 .copied()
557 .collect()
558 }
559
560 fn row_cats(&self, model: &Model) -> Vec<CategoryId> {
561 self.live_axis_slice(model, 0, self.n_rows)
562 }
563 fn col_cats(&self, model: &Model) -> Vec<CategoryId> {
564 self.live_axis_slice(model, self.n_rows, self.n_cols)
565 }
566 fn page_cats(&self, model: &Model) -> Vec<CategoryId> {
567 self.live_axis_slice(model, self.n_rows + self.n_cols, usize::MAX)
568 }
569
570 fn pinned_pages(&self, model: &Model) -> Vec<(CategoryId, ItemId)> {
575 let mut pinned = Vec::new();
576 for (pi, c) in self.page_cats(model).iter().enumerate() {
577 let its = self.sorted_items(model, *c);
578 if its.is_empty() {
579 continue;
580 }
581 let sel = self
582 .page_idx
583 .get(pi)
584 .copied()
585 .unwrap_or(0)
586 .min(its.len() - 1);
587 pinned.push((*c, its[sel].0));
588 }
589 pinned
590 }
591
592 fn pinned_pages_opt(&self, model: &Model) -> Option<Vec<(CategoryId, ItemId)>> {
597 let pinned = self.pinned_pages(model);
598 (pinned.len() == self.page_cats(model).len()).then_some(pinned)
599 }
600
601 fn axis_tuples(&self, model: &Model, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
607 let mut out: Vec<Vec<(ItemId, String)>> = vec![Vec::new()];
608 for c in cats {
609 let items = self.sorted_items(model, *c);
610 if items.is_empty() {
611 return Vec::new();
612 }
613 let mut next = Vec::with_capacity(out.len() * items.len());
614 for prefix in &out {
615 for it in &items {
616 let mut t = prefix.clone();
617 t.push(it.clone());
618 next.push(t);
619 }
620 }
621 out = next;
622 }
623 out
624 }
625
626 fn axis_item_lists(&self, model: &Model, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
631 cats.iter().map(|c| self.sorted_items(model, *c)).collect()
632 }
633
634 fn sorted_items(&self, model: &Model, c: CategoryId) -> Vec<(ItemId, String)> {
638 let keep = |id: ItemId| match self.filters.iter().find(|f| f.category == c) {
639 Some(f) => f.items.contains(&id),
640 None => true,
641 };
642 let mut v: Vec<(ItemId, String)> = model
643 .categories
644 .get(&c)
645 .map(|cat| {
646 cat.items
647 .iter()
648 .filter(|id| keep(**id))
649 .filter_map(|id| model.items.get(id).map(|it| (*id, it.name.0.clone())))
650 .collect()
651 })
652 .unwrap_or_default();
653 v.sort_by_key(|(id, _)| id.0);
654 v
655 }
656
657 fn set_axis(&mut self, model: &Model, category: CategoryId, axis: Axis) {
663 if !self.axis_order.contains(&category) {
664 return;
665 }
666 let (mut rows, mut cols, mut pages) = (
667 self.row_cats(model),
668 self.col_cats(model),
669 self.page_cats(model),
670 );
671 for v in [&mut rows, &mut cols, &mut pages] {
672 v.retain(|c| *c != category);
673 }
674 match axis {
675 Axis::Rows => rows.push(category),
676 Axis::Columns => cols.push(category),
677 Axis::Pages => pages.push(category),
678 }
679 self.rebuild_axis_order(model, rows, cols, pages);
680 self.clamp_cursor(model);
681 }
682
683 fn rebuild_axis_order(
686 &mut self,
687 model: &Model,
688 rows: Vec<CategoryId>,
689 cols: Vec<CategoryId>,
690 pages: Vec<CategoryId>,
691 ) {
692 self.n_rows = rows.len();
693 self.n_cols = cols.len();
694 self.axis_order = rows;
695 self.axis_order.extend(cols);
696 self.axis_order.extend(pages);
697 self.page_idx = vec![0; self.page_cats(model).len()];
698 }
699
700 fn pivot_rotate(&mut self, model: &Model) {
705 let rows = self.row_cats(model);
706 let cols = self.col_cats(model);
707 if rows.is_empty() && cols.is_empty() {
708 return;
709 }
710 let pages = self.page_cats(model);
711 self.rebuild_axis_order(model, cols, rows, pages);
713 self.clamp_cursor(model);
714 }
715
716 fn set_page(&mut self, model: &Model, dim_index: usize, item_index: usize) {
720 let pages = self.page_cats(model);
721 let Some(cat) = pages.get(dim_index).copied() else {
722 return;
723 };
724 let count = self.sorted_items(model, cat).len();
725 if count == 0 {
726 return;
727 }
728 if self.page_idx.len() != pages.len() {
729 self.page_idx.resize(pages.len(), 0);
730 }
731 if let Some(slot) = self.page_idx.get_mut(dim_index) {
732 *slot = item_index.min(count - 1);
733 }
734 }
735
736 fn page_first(&mut self, model: &Model, delta: isize) {
738 let Some(cat) = self.page_cats(model).first().copied() else {
739 return;
740 };
741 let count = self.sorted_items(model, cat).len();
742 if count == 0 {
743 return;
744 }
745 let cur = self.page_idx.first().copied().unwrap_or(0).min(count - 1);
746 let next = (cur as isize + delta).rem_euclid(count as isize) as usize;
747 self.set_page(model, 0, next);
748 }
749
750 fn toggle_filter_item(&mut self, model: &Model, category: CategoryId, item: ItemId) {
754 let all: Vec<ItemId> = model
755 .categories
756 .get(&category)
757 .map(|c| c.items.clone())
758 .unwrap_or_default();
759 match self.filters.iter().position(|f| f.category == category) {
760 None => {
761 let items: Vec<ItemId> = all.into_iter().filter(|i| *i != item).collect();
762 self.filters.push(Filter { category, items });
763 }
764 Some(i) => {
765 let f = &mut self.filters[i];
766 if let Some(p) = f.items.iter().position(|x| *x == item) {
767 f.items.remove(p);
768 } else {
769 f.items.push(item);
770 }
771 if f.items.len() == all.len() && all.iter().all(|x| f.items.contains(x)) {
772 self.filters.remove(i);
773 }
774 }
775 }
776 self.clamp_cursor(model);
777 }
778
779 fn clear_filter(&mut self, model: &Model, category: CategoryId) {
781 self.filters.retain(|f| f.category != category);
782 self.clamp_cursor(model);
783 }
784
785 fn grid_dims(&self, model: &Model) -> (usize, usize) {
792 if self.pinned_pages_opt(model).is_none() {
793 return (0, 0);
794 }
795 let rows = product_len(&self.axis_item_lists(model, &self.row_cats(model)));
796 let cols = product_len(&self.axis_item_lists(model, &self.col_cats(model)));
797 (rows, cols)
798 }
799
800 fn move_cursor(&mut self, model: &Model, drow: isize, dcol: isize) {
803 let (rows, cols) = self.grid_dims(model);
804 let max_row = rows.saturating_sub(1) as isize;
805 let max_col = cols.saturating_sub(1) as isize;
806 self.cursor_row = (self.cursor_row as isize + drow).clamp(0, max_row) as usize;
807 self.cursor_col = (self.cursor_col as isize + dcol).clamp(0, max_col) as usize;
808 }
809
810 fn step(&mut self, model: &Model, drow: isize, dcol: isize, extend: bool) {
813 self.anchor = extend.then(|| self.anchor.unwrap_or((self.cursor_row, self.cursor_col)));
814 self.move_cursor(model, drow, dcol);
815 }
816
817 fn selection(&self) -> (RangeInclusive<usize>, RangeInclusive<usize>) {
819 let (r, c) = (self.cursor_row, self.cursor_col);
820 let (ar, ac) = self.anchor.unwrap_or((r, c));
821 (ar.min(r)..=ar.max(r), ac.min(c)..=ac.max(c))
822 }
823
824 fn selection_keys(&self, model: &Model) -> Vec<CoordKey> {
827 let Some(pinned) = self.pinned_pages_opt(model) else {
828 return Vec::new();
829 };
830 let row_cats = self.row_cats(model);
831 let col_cats = self.col_cats(model);
832 let row_lists = self.axis_item_lists(model, &row_cats);
833 let col_lists = self.axis_item_lists(model, &col_cats);
834 let (n_rows, n_cols) = (product_len(&row_lists), product_len(&col_lists));
835 let line = |lists: &[Vec<(ItemId, String)>], i: usize| {
836 if lists.is_empty() {
837 Vec::new()
838 } else {
839 nth_tuple(lists, i)
840 }
841 };
842 let (rows, cols) = self.selection();
843 let cols: Vec<_> = cols
844 .filter(|c| *c < n_cols)
845 .map(|c| line(&col_lists, c))
846 .collect();
847 rows.filter(|r| *r < n_rows)
850 .flat_map(|r| {
851 let rt = line(&row_lists, r);
852 cols.iter()
853 .map(|ct| cell_key_multi(&row_cats, &rt, &col_cats, ct, &pinned))
854 .collect::<Vec<_>>()
855 })
856 .take(10_000)
857 .collect()
858 }
859
860 fn clamp_cursor(&mut self, model: &Model) {
863 let (rows, cols) = self.grid_dims(model);
864 self.cursor_row = self.cursor_row.min(rows.saturating_sub(1));
865 self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
866 if let Some((r, c)) = &mut self.anchor {
867 *r = (*r).min(rows.saturating_sub(1));
868 *c = (*c).min(cols.saturating_sub(1));
869 }
870 }
871
872 fn cursor_key(&self, model: &Model) -> Option<CoordKey> {
878 let pinned = self.pinned_pages_opt(model)?;
879 let row_cats = self.row_cats(model);
880 let col_cats = self.col_cats(model);
881 let row_lists = self.axis_item_lists(model, &row_cats);
882 let col_lists = self.axis_item_lists(model, &col_cats);
883 let (n_rows, n_cols) = (product_len(&row_lists), product_len(&col_lists));
884 if n_rows == 0 || n_cols == 0 {
885 return None;
886 }
887 let row_tuple = if row_lists.is_empty() {
890 Vec::new()
891 } else {
892 nth_tuple(&row_lists, self.cursor_row.min(n_rows - 1))
893 };
894 let col_tuple = if col_lists.is_empty() {
895 Vec::new()
896 } else {
897 nth_tuple(&col_lists, self.cursor_col.min(n_cols - 1))
898 };
899 Some(cell_key_multi(
900 &row_cats, &row_tuple, &col_cats, &col_tuple, &pinned,
901 ))
902 }
903
904 #[cfg(test)]
907 fn resolved_axes(
908 &self,
909 model: &Model,
910 ) -> (
911 Option<CategoryId>,
912 Option<CategoryId>,
913 Vec<(CategoryId, ItemId)>,
914 ) {
915 let row_cat = self.axis_order.first().copied();
916 let col_cat = self.axis_order.get(self.n_rows).copied();
917 (row_cat, col_cat, self.pinned_pages(model))
918 }
919}
920
921impl ImprovApp {
922 pub fn load(db: &str) -> Result<ImprovApp, String> {
925 let (model, save_token) = if db.is_empty() {
926 (Model::new(), improv_store::SaveToken::default())
927 } else {
928 let mut store = Store::open(db).map_err(|e| e.to_string())?;
929 let model = store.load_model().map_err(|e| e.to_string())?;
930 (model, store.save_token())
931 };
932
933 let (engine, snapshot) = build_engine(&model);
934 let selected = pick_default_measure(&model);
935 let matrices = vec![Matrix::new(&model, selected)];
936
937 Ok(ImprovApp {
938 db: db.to_string(),
939 save_token,
940 model,
941 engine,
942 snapshot,
943 matrices,
944 focus: 0,
945 current_view: None,
946 status: String::new(),
947 formula_buf: String::new(),
948 formula_for: None,
949 formula_error: None,
950 locale: if cfg!(test) {
951 improv_core_model::Locale::en()
952 } else {
953 improv_core_model::Locale::from_env()
954 },
955 view_name: String::new(),
956 browser_sel: None,
957 show_chart: false,
958 chart_line: false,
959 show_csv_wizard: false,
960 import_form: ImportForm::default(),
961 show_inspector: false,
962 fn_help: Default::default(),
963 formula_target: FormulaEditor::NewLine,
964 show_browser: true,
965 quit_armed: false,
966 dirty: false,
967 last_title: String::new(),
968 status_shown: String::new(),
969 status_at: 0.0,
970 menu: menu::MenuState::default(),
971 export_form: ExportForm::default(),
972 undo_stack: Vec::new(),
973 redo_stack: Vec::new(),
974 formula_row_rects: Vec::new(),
975 new_formula_line: String::new(),
976 new_formula_error: None,
977 })
978 }
979
980 pub(crate) fn focused(&self) -> &Matrix {
989 &self.matrices[self.focus.min(self.matrices.len() - 1)]
990 }
991
992 pub(crate) fn focused_mut(&mut self) -> &mut Matrix {
994 let i = self.focus.min(self.matrices.len() - 1);
995 &mut self.matrices[i]
996 }
997
998 pub fn set_focus(&mut self, i: usize) {
1001 self.focus = i.min(self.matrices.len() - 1);
1002 }
1003
1004 pub fn focus_index(&self) -> usize {
1006 self.focus.min(self.matrices.len() - 1)
1007 }
1008
1009 pub fn matrix_count(&self) -> usize {
1011 self.matrices.len()
1012 }
1013
1014 pub fn add_matrix(&mut self, measure: Option<MeasureId>) -> usize {
1021 let mut m = Matrix::new(&self.model, measure);
1022 let last = self.matrices.last().map(|m| m.rect).unwrap_or_default();
1023 m.rect = CanvasRect {
1024 x: last.x + CASCADE,
1025 y: last.y + CASCADE,
1026 w: last.w,
1027 h: last.h,
1028 };
1029 self.matrices.push(m);
1030 self.focus = self.matrices.len() - 1;
1031 self.focus
1032 }
1033
1034 pub fn remove_matrix(&mut self, i: usize) -> bool {
1038 if self.matrices.len() <= 1 || i >= self.matrices.len() {
1039 return false;
1040 }
1041 self.matrices.remove(i);
1042 self.focus = self.focus.min(self.matrices.len() - 1);
1043 true
1044 }
1045
1046 pub(crate) fn selected(&self) -> Option<MeasureId> {
1049 self.focused().measure
1050 }
1051 pub(crate) fn chart_axes_pub(&self) -> Option<ChartAxes> {
1063 let m = self.focused();
1064 Some((
1065 m.row_cats(&self.model),
1066 m.col_cats(&self.model),
1067 m.pinned_pages_opt(&self.model)?,
1068 ))
1069 }
1070 pub(crate) fn axis_tuples_pub(&self, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
1073 self.focused().axis_tuples(&self.model, cats)
1074 }
1075 pub(crate) fn cell_key_multi_pub(
1077 &self,
1078 row_cats: &[CategoryId],
1079 row_tuple: &[(ItemId, String)],
1080 col_cats: &[CategoryId],
1081 col_tuple: &[(ItemId, String)],
1082 pinned: &[(CategoryId, ItemId)],
1083 ) -> CoordKey {
1084 cell_key_multi(row_cats, row_tuple, col_cats, col_tuple, pinned)
1085 }
1086 pub(crate) fn values_for_pub(&self, measure: MeasureId) -> HashMap<CoordKey, f64> {
1087 self.values_for(measure)
1088 }
1089 pub(crate) fn category_name_pub(&self, c: CategoryId) -> Option<String> {
1090 self.model.categories.get(&c).map(|x| x.name.0.clone())
1091 }
1092
1093 fn values_for(&self, measure: MeasureId) -> HashMap<CoordKey, f64> {
1102 let is_derived = self.model.measures.get(&measure).map(|m| m.is_derived());
1103 match is_derived {
1104 Some(true) => self
1105 .snapshot
1106 .get(&measure)
1107 .map(|m| {
1108 m.iter()
1109 .filter_map(|(k, v)| v.as_num().map(|n| (k.clone(), n)))
1110 .collect()
1111 })
1112 .unwrap_or_default(),
1113 _ => self
1114 .model
1115 .inputs
1116 .iter()
1117 .filter(|((mid, _), _)| *mid == measure)
1118 .filter_map(|((_, coord), v)| match v {
1119 Value::Number(n) => Some((encode_coord(coord), *n)),
1120 _ => None,
1121 })
1122 .collect(),
1123 }
1124 }
1125
1126 fn cell_value(&self, measure: MeasureId, key: &CoordKey) -> Option<CellValue> {
1130 if self
1131 .model
1132 .measures
1133 .get(&measure)
1134 .is_some_and(|m| m.is_derived())
1135 {
1136 return self
1137 .snapshot
1138 .get(&measure)
1139 .and_then(|m| m.get(key))
1140 .cloned();
1141 }
1142 self.model
1143 .input(measure, &decode(key))
1144 .and_then(CellValue::from_model_value)
1145 }
1146
1147 fn cell_text(&self, measure: MeasureId, key: &CoordKey) -> Option<String> {
1152 self.cell_value(measure, key).map(|v| v.to_string())
1153 }
1154
1155 pub fn set_cell(
1167 &mut self,
1168 measure: MeasureId,
1169 coord: CoordKey,
1170 value: Value,
1171 ) -> Result<(), String> {
1172 self.write_cell(measure, coord, Some(value))
1173 }
1174
1175 pub fn clear_cell(&mut self, measure: MeasureId, coord: CoordKey) -> Result<(), String> {
1178 self.write_cell(measure, coord, None)
1179 }
1180
1181 fn write_cell(
1183 &mut self,
1184 measure: MeasureId,
1185 coord: CoordKey,
1186 value: Option<Value>,
1187 ) -> Result<(), String> {
1188 let m = self
1189 .model
1190 .measures
1191 .get(&measure)
1192 .ok_or_else(|| format!("no measure with id {}", measure.0))?;
1193 if m.is_derived() {
1194 return Err("derived cells are computed, not editable".into());
1195 }
1196 if let Some(v) = &value {
1199 if v.type_of() != Some(m.value_type) {
1200 return Err(format!(
1201 "measure '{}' is declared {:?}; refusing to store {v:?}",
1202 m.name.0, m.value_type
1203 ));
1204 }
1205 }
1206 let key = decode(&coord);
1213 let prior = self.model.input(measure, &key).cloned();
1214 let prior_snapshot = self.snapshot.clone();
1215 let prior_model = self.model.clone();
1218
1219 match &value {
1220 Some(v) => self.model.set_input(measure, key.clone(), v.clone()),
1221 None => {
1222 self.model.inputs.remove(&(measure, key.clone()));
1223 }
1224 }
1225 let outcome = (|| -> Result<(), String> {
1226 self.push_engine(measure, &coord, value.as_ref())?;
1227 self.save()
1228 })();
1229
1230 if let Err(e) = outcome {
1231 match &prior {
1234 Some(v) => self.model.set_input(measure, key, v.clone()),
1235 None => {
1236 self.model.inputs.remove(&(measure, key));
1237 }
1238 }
1239 if self.push_engine(measure, &coord, prior.as_ref()).is_err() {
1240 self.snapshot = prior_snapshot;
1241 }
1242 return Err(e);
1243 }
1244 self.record_history(prior_model);
1245 Ok(())
1246 }
1247
1248 fn push_engine(
1258 &mut self,
1259 measure: MeasureId,
1260 coord: &CoordKey,
1261 value: Option<&Value>,
1262 ) -> Result<(), String> {
1263 let Some(engine) = &mut self.engine else {
1264 return Ok(());
1265 };
1266 let ty = self.model.measures.get(&measure).map(|m| m.value_type);
1267 let cell = ty
1268 .zip(value)
1269 .and_then(|(t, v)| improv_engine::session::input_cell(t, v));
1270 let snapshot = engine
1271 .apply(vec![improv_engine::session::Edit {
1272 measure,
1273 coord: coord.clone(),
1274 value: cell,
1275 }])
1276 .map_err(|e| e.to_string())?;
1277 self.snapshot = snapshot;
1278 Ok(())
1279 }
1280
1281 pub fn commit_cell_text(
1293 &mut self,
1294 measure: MeasureId,
1295 coord: CoordKey,
1296 text: &str,
1297 ) -> Result<String, String> {
1298 let trimmed = text.trim();
1299 if trimmed.is_empty() {
1300 self.clear_cell(measure, coord)?;
1301 return Ok("cell cleared".into());
1302 }
1303 let declared = self
1304 .model
1305 .measures
1306 .get(&measure)
1307 .map(|m| m.value_type)
1308 .ok_or_else(|| format!("no measure with id {}", measure.0))?;
1309 let value = parse_typed(&self.model, declared, trimmed)?;
1310 self.set_cell(measure, coord, value)?;
1311 Ok("cell updated".into())
1312 }
1313
1314 fn with_focused<R>(&mut self, f: impl FnOnce(&mut Matrix, &Model) -> R) -> R {
1325 let i = self.focus.min(self.matrices.len() - 1);
1326 f(&mut self.matrices[i], &self.model)
1327 }
1328
1329 fn sync_axis_state(&mut self) {
1332 if self.matrices.is_empty() {
1334 self.matrices.push(Matrix::new(&self.model, None));
1335 }
1336 self.focus = self.focus.min(self.matrices.len() - 1);
1337 let model = &self.model;
1338 for m in &mut self.matrices {
1339 m.sync_axis_state(model);
1340 }
1341 }
1342
1343 pub fn pivot_rotate(&mut self) {
1346 self.with_focused(|m, model| m.pivot_rotate(model));
1347 }
1348
1349 #[cfg(test)]
1355 fn resolved_axes(
1356 &self,
1357 ) -> (
1358 Option<CategoryId>,
1359 Option<CategoryId>,
1360 Vec<(CategoryId, ItemId)>,
1361 ) {
1362 self.focused().resolved_axes(&self.model)
1363 }
1364 #[cfg(test)]
1365 fn row_cats(&self) -> Vec<CategoryId> {
1366 self.focused().row_cats(&self.model)
1367 }
1368 #[cfg(test)]
1369 fn col_cats(&self) -> Vec<CategoryId> {
1370 self.focused().col_cats(&self.model)
1371 }
1372 #[cfg(test)]
1373 fn page_cats(&self) -> Vec<CategoryId> {
1374 self.focused().page_cats(&self.model)
1375 }
1376 #[cfg(test)]
1377 fn axis_tuples(&self, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
1378 self.focused().axis_tuples(&self.model, cats)
1379 }
1380 #[cfg(test)]
1381 fn axis_item_lists(&self, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
1382 self.focused().axis_item_lists(&self.model, cats)
1383 }
1384 #[cfg(test)]
1385 fn sorted_items(&self, c: CategoryId) -> Vec<(ItemId, String)> {
1386 self.focused().sorted_items(&self.model, c)
1387 }
1388 #[cfg(test)]
1391 fn set_axis(&mut self, category: CategoryId, axis: Axis) {
1392 self.with_focused(|m, model| m.set_axis(model, category, axis));
1393 }
1394 #[cfg(test)]
1396 fn set_page(&mut self, dim_index: usize, item_index: usize) {
1397 self.with_focused(|m, model| m.set_page(model, dim_index, item_index));
1398 }
1399
1400 pub fn build_view(&self, id: ViewId, name: &str) -> Option<View> {
1411 let mut placements: Vec<MatrixPlacement> = self
1412 .matrices
1413 .iter()
1414 .filter_map(|m| m.to_placement(&self.model))
1415 .collect();
1416 if placements.is_empty() {
1417 return None;
1418 }
1419 let primary = placements.remove(0);
1420 Some(View::from_matrices(
1421 id,
1422 Name(name.to_string()),
1423 primary,
1424 placements,
1425 ))
1426 }
1427
1428 fn next_view_id(&self) -> ViewId {
1430 ViewId(
1431 self.model
1432 .views
1433 .keys()
1434 .map(|v| v.0)
1435 .max()
1436 .map(|m| m + 1)
1437 .unwrap_or(1),
1438 )
1439 }
1440
1441 pub fn save_view(&mut self, name: &str) -> Option<ViewId> {
1446 let name = name.trim();
1447 if name.is_empty() {
1448 self.status = "view name is required".into();
1449 return None;
1450 }
1451 let id = self.next_view_id();
1452 let view = self.build_view(id, name)?;
1453 let prior_model = self.model.clone();
1454 self.model.add_view(view);
1455 if let Err(e) = self.save() {
1456 self.model.views.remove(&id);
1457 self.status = e;
1458 return None;
1459 }
1460 self.record_history(prior_model);
1461 self.current_view = Some(id);
1462 self.status = format!("saved view '{name}'");
1463 Some(id)
1464 }
1465
1466 pub fn apply_view(&mut self, view: &View) {
1480 let matrices: Vec<Matrix> = view
1481 .matrices()
1482 .iter()
1483 .filter_map(|p| Matrix::from_placement(&self.model, p))
1484 .collect();
1485 if matrices.is_empty() {
1486 self.status = "view's measure no longer exists".into();
1487 return;
1488 }
1489 let dropped = view.matrices().len() - matrices.len();
1490 self.matrices = matrices;
1491 self.focus = 0;
1492 self.current_view = Some(view.id);
1493 self.status = if dropped == 0 {
1494 format!("view: {}", view.name.0)
1495 } else {
1496 format!(
1497 "view: {} ({dropped} matrix(es) skipped: measure gone)",
1498 view.name.0
1499 )
1500 };
1501 }
1502
1503 pub fn document_tabs(&self) -> Vec<(ViewId, String, bool)> {
1507 let mut ids: Vec<ViewId> = self.model.views.keys().copied().collect();
1508 ids.sort_by_key(|v| v.0);
1509 ids.into_iter()
1510 .map(|id| {
1511 (
1512 id,
1513 self.model.views[&id].name.0.clone(),
1514 self.current_view == Some(id),
1515 )
1516 })
1517 .collect()
1518 }
1519
1520 pub fn open_view(&mut self, id: ViewId) {
1523 if let Some(v) = self.model.views.get(&id).cloned() {
1524 self.apply_view(&v);
1525 }
1526 }
1527
1528 #[cfg(test)]
1533 fn toggle_filter_item(&mut self, category: CategoryId, item: ItemId) {
1534 self.with_focused(|m, model| m.toggle_filter_item(model, category, item));
1535 }
1536
1537 pub fn toggle_csv_wizard(&mut self) {
1543 self.show_csv_wizard = !self.show_csv_wizard;
1544 if self.show_csv_wizard && self.import_form.measure_id.trim().is_empty() {
1545 self.import_form.measure_id = self.next_measure_id().to_string();
1546 }
1547 }
1548
1549 pub fn run_csv_import(&mut self) {
1555 let spec = match csv_wizard::build_import_spec(&self.import_form) {
1556 Ok(s) => s,
1557 Err(e) => {
1558 self.status = format!("import error: {e}");
1559 return;
1560 }
1561 };
1562 let prior_model = self.model.clone();
1565 match improv_storage_csv::import_csv(&mut self.model, &spec) {
1566 Ok(n) => {
1567 self.rebuild_engine();
1568 self.focused_mut().select(Some(spec.measure_id));
1569 self.record_history(prior_model);
1572 self.status = match self.save() {
1575 Ok(()) => format!(
1576 "imported {n} cell(s) into measure {} '{}'",
1577 spec.measure_id.0, spec.measure_name
1578 ),
1579 Err(e) => e,
1580 };
1581 }
1582 Err(e) => self.status = format!("import error: {e}"),
1583 }
1584 }
1585
1586 pub fn run_csv_export(&mut self) {
1590 let (measure, path, delimiter) = match csv_wizard::build_export_args(&self.export_form) {
1591 Ok(a) => a,
1592 Err(e) => {
1593 self.status = format!("export error: {e}");
1594 return;
1595 }
1596 };
1597 match improv_storage_csv::export_measure_csv(&self.model, measure, &path, delimiter) {
1598 Ok(n) => {
1599 self.status = format!("exported {n} row(s) to {}", path.display());
1600 }
1601 Err(e) => self.status = format!("export error: {e}"),
1602 }
1603 }
1604
1605 fn rebuild_engine(&mut self) {
1608 let (engine, snapshot) = build_engine(&self.model);
1609 self.engine = engine;
1610 self.snapshot = snapshot;
1611 }
1612
1613 #[cfg(test)]
1617 fn grid_dims(&self) -> (usize, usize) {
1618 self.focused().grid_dims(&self.model)
1619 }
1620
1621 #[cfg(test)]
1624 pub fn move_cursor(&mut self, drow: isize, dcol: isize) {
1625 self.with_focused(|m, model| m.step(model, drow, dcol, false));
1626 }
1627
1628 pub fn cursor_key(&self) -> Option<CoordKey> {
1631 self.focused().cursor_key(&self.model)
1632 }
1633
1634 pub fn cursor_is_editable(&self) -> bool {
1639 self.selected()
1640 .and_then(|m| self.model.measures.get(&m))
1641 .is_some_and(|m| !m.is_derived())
1642 && self.cursor_key().is_some()
1643 }
1644
1645 fn begin_edit_cursor(&mut self) {
1649 let Some(measure) = self.selected() else {
1650 return;
1651 };
1652 let Some(key) = self.cursor_key() else {
1655 self.status = "no cell here: an axis category is filtered to nothing".into();
1656 return;
1657 };
1658 if !self.cursor_is_editable() {
1659 self.status = "derived cells are computed, not editable".into();
1660 return;
1661 }
1662 let seed = self.cell_text(measure, &key).unwrap_or_default();
1663 let m = self.focused_mut();
1664 m.editing = Some((measure, key));
1665 m.edit_buf = seed;
1666 }
1667
1668 pub fn formula_source(&self, measure: MeasureId) -> Option<String> {
1688 let Some(MeasureKind::Derived(f)) = self.model.measures.get(&measure).map(|m| &m.kind)
1689 else {
1690 return None;
1691 };
1692 let dsl = formula_dsl(&self.model, f).map(|d| self.locale.localize(&d));
1693 let cnl = describe_formula(&NlContext::new(&self.model), f);
1694 dsl.into_iter()
1695 .chain(std::iter::once(cnl))
1696 .find(|text| self.parse_formula_text(text).ok().as_ref() == Some(f))
1697 }
1698
1699 pub fn commit_formula(&mut self, measure: MeasureId, text: &str) -> Result<(), String> {
1716 let formula = match self.parse_formula_text(text) {
1717 Ok(f) => f,
1718 Err(e) => {
1719 self.formula_error = Some(diagnostics::parse_error(&self.model, text, &e));
1720 return Err(e.to_string());
1721 }
1722 };
1723 self.formula_error = None;
1727 let mut candidate = self.model.clone();
1731 let m = candidate
1732 .measures
1733 .get_mut(&measure)
1734 .ok_or_else(|| format!("no measure with id {}", measure.0))?;
1735 m.kind = MeasureKind::Derived(formula);
1736 self.publish(candidate).inspect_err(|e| {
1737 self.formula_error = formula_fault(&self.model, text, e);
1738 })
1739 }
1740
1741 #[cfg(test)]
1745 pub fn formula_enabled(&self, measure: MeasureId) -> bool {
1746 self.model
1747 .measures
1748 .get(&measure)
1749 .is_some_and(|m| !formula_disabled(m))
1750 }
1751
1752 pub fn set_formula_enabled(&mut self, measure: MeasureId, enabled: bool) -> Result<(), String> {
1766 let Some(m) = self.model.measures.get(&measure) else {
1767 return Ok(());
1768 };
1769 if !m.is_derived() || !formula_disabled(m) == enabled {
1773 return Ok(());
1774 }
1775 let mut candidate = self.model.clone();
1776 let m = candidate
1777 .measures
1778 .get_mut(&measure)
1779 .ok_or_else(|| format!("no measure with id {}", measure.0))?;
1780 let (_, user) = split_marker(m.description.as_deref());
1782 m.description = join_marker(!enabled, user);
1783 self.publish(candidate)
1784 }
1785
1786 pub fn formula_rows(&self) -> Vec<FormulaRow> {
1793 let mut ids: Vec<MeasureId> = self
1794 .model
1795 .measures
1796 .values()
1797 .filter(|m| m.is_derived())
1798 .map(|m| m.id)
1799 .collect();
1800 ids.sort_by_key(|m| m.0);
1801 ids.into_iter()
1802 .filter_map(|id| {
1803 let m = self.model.measures.get(&id)?;
1804 let MeasureKind::Derived(f) = &m.kind else {
1805 return None;
1806 };
1807 let text = self
1810 .formula_source(id)
1811 .unwrap_or_else(|| describe_formula(&NlContext::new(&self.model), f));
1812 Some(FormulaRow {
1813 measure: id,
1814 target: m.name.0.clone(),
1815 text,
1816 enabled: !formula_disabled(m),
1817 error: self.formula_row_error(id, f),
1818 })
1819 })
1820 .collect()
1821 }
1822
1823 fn formula_row_error(&self, id: MeasureId, f: &improv_core_model::Formula) -> Option<String> {
1838 let ctx = improv_engine::compiler::CompileContext::new(&self.model.measures);
1839 if let Err(e) = improv_engine::compiler::compile_formula(&ctx, id, f) {
1840 return Some(e.to_string());
1841 }
1842 self.formula_source(id)
1843 .is_none()
1844 .then(|| "no editable spelling (shown as description)".to_string())
1845 }
1846
1847 fn parse_formula_text(&self, text: &str) -> Result<improv_core_model::Formula, ParseError> {
1852 let canonical = self.locale.canonicalize(text);
1855 match parser::parse_expr(&self.model, &canonical) {
1856 Ok(f) => Ok(f),
1857 Err(dsl_err) => {
1858 parse_nl_formula(&NlContext::new(&self.model), text).map_err(|_| dsl_err)
1859 }
1860 }
1861 }
1862
1863 fn publish(&mut self, candidate: Model) -> Result<(), String> {
1875 let previous = self.swap_model(candidate)?;
1876 self.record_history(previous);
1877 Ok(())
1878 }
1879
1880 fn swap_model(&mut self, candidate: Model) -> Result<Model, String> {
1885 let (engine, snapshot) = try_build_engine(&candidate)?;
1886 let previous = std::mem::replace(&mut self.model, candidate);
1887 if let Err(e) = self.save() {
1888 self.model = previous;
1889 return Err(e);
1890 }
1891 self.engine = engine;
1892 self.snapshot = snapshot;
1893 Ok(previous)
1894 }
1895
1896 fn record_history(&mut self, prior: Model) {
1902 push_bounded(&mut self.undo_stack, prior);
1903 self.redo_stack.clear();
1904 }
1905
1906 pub fn can_undo(&self) -> bool {
1908 !self.undo_stack.is_empty()
1909 }
1910
1911 pub fn can_redo(&self) -> bool {
1913 !self.redo_stack.is_empty()
1914 }
1915
1916 pub fn undo(&mut self) -> Result<(), String> {
1924 let Some(prior) = self.undo_stack.pop() else {
1925 return Ok(());
1926 };
1927 let current = self.model.clone();
1928 match self.swap_model(prior) {
1929 Ok(_) => {
1930 push_bounded(&mut self.redo_stack, current);
1931 self.after_history_restore();
1932 Ok(())
1933 }
1934 Err(e) => {
1935 self.undo_stack.push(current);
1937 Err(e)
1938 }
1939 }
1940 }
1941
1942 pub fn redo(&mut self) -> Result<(), String> {
1944 let Some(next) = self.redo_stack.pop() else {
1945 return Ok(());
1946 };
1947 let current = self.model.clone();
1948 match self.swap_model(next) {
1949 Ok(_) => {
1950 push_bounded(&mut self.undo_stack, current);
1951 self.after_history_restore();
1952 Ok(())
1953 }
1954 Err(e) => {
1955 self.redo_stack.push(current);
1956 Err(e)
1957 }
1958 }
1959 }
1960
1961 fn undo_with_status(&mut self) {
1965 let had = self.can_undo();
1966 self.status = match self.undo() {
1967 Ok(()) if had => "undo".into(),
1968 Ok(()) => "nothing to undo".into(),
1969 Err(e) => format!("undo failed: {e}"),
1970 };
1971 }
1972
1973 fn redo_with_status(&mut self) {
1975 let had = self.can_redo();
1976 self.status = match self.redo() {
1977 Ok(()) if had => "redo".into(),
1978 Ok(()) => "nothing to redo".into(),
1979 Err(e) => format!("redo failed: {e}"),
1980 };
1981 }
1982
1983 fn after_history_restore(&mut self) {
1989 let fallback = pick_default_measure(&self.model);
1990 for m in &mut self.matrices {
1991 m.editing = None;
1992 if !m
1993 .measure
1994 .is_some_and(|id| self.model.measures.contains_key(&id))
1995 {
1996 m.measure = fallback;
1997 }
1998 }
1999 self.formula_for = None;
2001 self.formula_error = None;
2002 self.sync_axis_state();
2003 }
2004
2005 pub fn add_derived_measure(&mut self, name: &str, text: &str) -> Result<MeasureId, String> {
2014 self.new_formula_error = None;
2015 let name = name.trim();
2016 if name.is_empty() {
2017 return Err("measure name is required".into());
2018 }
2019 if self.model.measure_by_name(name).is_some() {
2020 return Err(format!("a measure named {name:?} already exists"));
2021 }
2022 let id = MeasureId(self.next_measure_id());
2023 let formula = {
2025 let provisional = self.model.with_provisional(id, name);
2026 let canonical = self.locale.canonicalize(text);
2027 parser::parse_expr(&provisional, &canonical)
2028 .or_else(|e| parse_nl_formula(&NlContext::new(&provisional), text).map_err(|_| e))
2029 .map_err(|e| {
2030 self.new_formula_error = Some(diagnostics::parse_error(&provisional, text, &e));
2031 e.to_string()
2032 })?
2033 };
2034 let (value_type, cats) =
2037 improv_engine::compiler::derived_shape(&self.model, id, name, &formula).map_err(
2038 |e| {
2039 let e = e.to_string();
2040 self.new_formula_error = formula_fault(&self.model, text, &e);
2041 e
2042 },
2043 )?;
2044 let mut candidate = self.model.clone();
2045 candidate.add_measure(Measure {
2046 id,
2047 name: Name(name.to_string()),
2048 value_type,
2049 categories: cats,
2050 kind: MeasureKind::Derived(formula),
2051 description: None,
2052 });
2053 self.publish(candidate).inspect_err(|e| {
2054 self.new_formula_error = formula_fault(&self.model, text, e);
2055 })?;
2056 Ok(id)
2057 }
2058
2059 fn next_measure_id(&self) -> u32 {
2061 self.model
2062 .measures
2063 .keys()
2064 .map(|m| m.0)
2065 .max()
2066 .map(|m| m + 1)
2067 .unwrap_or(1)
2068 }
2069
2070 #[must_use = "a failed save must be surfaced, not reported as success"]
2074 fn save(&mut self) -> Result<(), String> {
2075 if self.db.is_empty() {
2076 return Ok(());
2077 }
2078 let r = Store::open(&self.db)
2079 .and_then(|mut s| {
2080 s.set_save_token(&self.save_token);
2081 s.save_model(&self.model)?;
2082 self.save_token = s.save_token();
2083 Ok(())
2084 })
2085 .map_err(|e| format!("save failed: {e}"));
2086 self.dirty = r.is_err();
2087 r
2088 }
2089
2090 pub fn revert_to_saved(&mut self) -> Result<(), String> {
2097 if self.db.is_empty() {
2098 return Ok(());
2099 }
2100 let mut store = Store::open(&self.db).map_err(|e| format!("revert failed: {e}"))?;
2101 let model = store
2102 .load_model()
2103 .map_err(|e| format!("revert failed: {e}"))?;
2104 let (engine, snapshot) = try_build_engine(&model)?;
2105 self.model = model;
2106 self.engine = engine;
2107 self.snapshot = snapshot;
2108 self.save_token = store.save_token();
2109 self.undo_stack.clear();
2110 self.redo_stack.clear();
2111 self.dirty = false;
2112 self.after_history_restore();
2113 Ok(())
2114 }
2115
2116 fn inspector_data(&self, measure: MeasureId) -> Option<InspectorData> {
2118 let m = self.model.measures.get(&measure)?;
2119 let dimensions = m
2120 .categories
2121 .iter()
2122 .map(|c| {
2123 self.model
2124 .categories
2125 .get(c)
2126 .map(|cat| cat.name.0.clone())
2127 .unwrap_or_else(|| format!("category {}", c.0))
2128 })
2129 .collect();
2130 let (dependencies, formula_english) = match &m.kind {
2131 MeasureKind::Derived(f) => {
2132 let mut deps: Vec<String> = f
2133 .referenced_measures()
2134 .into_iter()
2135 .map(|id| {
2136 self.model
2137 .measures
2138 .get(&id)
2139 .map(|dm| dm.name.0.clone())
2140 .unwrap_or_else(|| format!("measure {}", id.0))
2141 })
2142 .collect();
2143 deps.dedup();
2144 (
2145 deps,
2146 Some(describe_formula(&NlContext::new(&self.model), f)),
2147 )
2148 }
2149 MeasureKind::Input => (Vec::new(), None),
2150 };
2151 let error_cells = self
2152 .snapshot
2153 .get(&measure)
2154 .map(|m| {
2155 m.values()
2156 .filter(|v| matches!(v, CellValue::Err(_)))
2157 .count()
2158 })
2159 .unwrap_or(0);
2160 Some(InspectorData {
2161 id: measure,
2162 name: m.name.0.clone(),
2163 is_derived: m.is_derived(),
2164 value_type: m.value_type,
2165 dimensions,
2166 dependencies,
2167 formula_english,
2168 error_cells,
2169 })
2170 }
2171}
2172
2173fn push_bounded(stack: &mut Vec<Model>, state: Model) {
2176 if stack.len() >= UNDO_DEPTH {
2177 stack.remove(0);
2178 }
2179 stack.push(state);
2180}
2181
2182fn grid_keys_enabled(editing: bool, other_focus: bool) -> bool {
2199 !editing && !other_focus
2200}
2201
2202fn white_selection(ui: &mut egui::Ui) {
2207 ui.visuals_mut().selection.bg_fill = crate::theme::WHITE;
2208}
2209
2210#[derive(Debug, PartialEq)]
2212struct InspectorData {
2213 id: MeasureId,
2214 name: String,
2215 is_derived: bool,
2216 value_type: ValueType,
2217 dimensions: Vec<String>,
2218 dependencies: Vec<String>,
2219 formula_english: Option<String>,
2220 error_cells: usize,
2221}
2222
2223#[derive(Debug, Clone, PartialEq)]
2231pub struct FormulaRow {
2232 pub measure: MeasureId,
2234 pub target: String,
2236 pub text: String,
2239 pub enabled: bool,
2242 pub error: Option<String>,
2245}
2246
2247fn consume_enter(ui: &egui::Ui) {
2250 ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
2251}
2252
2253fn formula_fault(model: &Model, text: &str, err: &str) -> Option<diagnostics::Diagnostic> {
2256 (!err.starts_with("save failed")).then(|| diagnostics::compile_error(model, text, err))
2257}
2258
2259fn assisted_formula_edit(
2268 ui: &mut egui::Ui,
2269 model: &Model,
2270 text: &mut String,
2271 id: egui::Id,
2272 hint: &str,
2273 span: Option<std::ops::Range<usize>>,
2274 from: usize,
2275) -> egui::Response {
2276 use crate::completion::{self, Popup};
2277 use egui::{Key, Modifiers};
2278 let ctx = ui.ctx().clone();
2279 let (pid, rid) = (id.with("completion"), id.with("completion_rect"));
2280 let mut popup: Popup = ctx.data_mut(|d| d.get_temp(pid)).unwrap_or_default();
2281 let offer = |text: &str, caret: usize| {
2282 if caret >= from {
2283 completion::offer(model, text, caret)
2284 } else {
2285 Vec::new()
2286 }
2287 };
2288 let word = |text: &str, caret: usize| {
2289 completion::prefix(text, caret).map_or(String::new(), |(_, w)| w.to_string())
2290 };
2291 let mut refocus = false;
2293 let mut accepted = false;
2294 if ctx.memory(|m| m.has_focus(id) || m.had_focus_last_frame(id)) {
2295 let caret = caret_byte(&ctx, id, text);
2296 let cands = offer(text, caret);
2297 if popup.sync(text, &word(text, caret), cands.len()) {
2298 let n = cands.len();
2299 let (up, down, tab, enter, esc) = ui.input_mut(|i| {
2300 let mut k = |key| i.consume_key(Modifiers::NONE, key);
2301 (
2302 k(Key::ArrowUp),
2303 k(Key::ArrowDown),
2304 k(Key::Tab),
2305 k(Key::Enter),
2306 k(Key::Escape),
2307 )
2308 });
2309 let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
2311 let clicked = ui
2312 .input(|i| i.pointer.press_origin().filter(|_| i.pointer.any_pressed()))
2313 .zip(ctx.data(|d| d.get_temp::<egui::Rect>(rid)))
2314 .filter(|(p, r)| r.contains(*p))
2315 .map(|(p, r)| (((p.y - r.top() - 2.0) / row_h).max(0.0) as usize).min(n - 1));
2316 if let Some(i) = clicked {
2317 popup.sel = i;
2318 }
2319 popup.step(isize::from(down) - isize::from(up), n);
2320 if tab || enter || clicked.is_some() {
2321 let (t, at) = completion::accept(text, caret, &cands[popup.sel]);
2322 *text = t;
2323 set_caret(&ctx, id, text, at);
2324 accepted = true;
2325 } else if esc {
2326 popup.dismiss(text);
2327 }
2328 refocus = tab || esc || clicked.is_some();
2329 }
2330 }
2331 let caret = caret_byte(&ctx, id, text);
2334 let listing = popup
2335 .clone()
2336 .sync(text, &word(text, caret), offer(text, caret).len());
2337 let out = formula_text_edit(ui, text, id, hint, span, listing);
2338 let mut resp = out.response;
2339 if refocus {
2340 resp.request_focus();
2341 }
2342 if accepted {
2343 resp.mark_changed();
2344 }
2345 ctx.data_mut(|d| d.remove::<egui::Rect>(rid));
2346 if resp.has_focus() {
2347 let caret = caret_byte(&ctx, id, text);
2348 let cands = offer(text, caret);
2349 let shown = popup.sync(text, &word(text, caret), cands.len());
2350 let sel = shown.then(|| &cands[popup.sel]);
2351 let font = egui::TextStyle::Body.resolve(ui.style());
2352 if let Some(g) = completion::ghost(text, caret, sel) {
2353 let at = out.galley_pos + egui::vec2(out.galley.rect.right(), 0.0);
2355 ui.painter().with_clip_rect(out.text_clip_rect).text(
2356 at,
2357 egui::Align2::LEFT_TOP,
2358 g,
2359 font,
2360 crate::theme::LIGHT_GRAY,
2361 );
2362 }
2363 let below = shown && completion_list(ui, &resp, &cands, popup.sel, rid);
2364 if shown {
2365 ui.memory_mut(|m| {
2366 m.set_focus_lock_filter(
2367 id,
2368 egui::EventFilter {
2369 tab: true,
2370 horizontal_arrows: true,
2371 vertical_arrows: true,
2372 escape: true,
2373 },
2374 )
2375 });
2376 }
2377 let signature = match sel.map(|c| (c.kind, c.name.clone())) {
2378 Some((completion::Kind::Function(d), name)) => Some((name, d, None)),
2379 _ => (caret >= from)
2380 .then(|| improv_core_model::functions::call_at(text, caret))
2381 .flatten()
2382 .map(|(d, a)| (d.name.to_string(), d, Some(a))),
2383 };
2384 if let Some((name, d, arg)) = signature {
2385 signature_tooltip(ui, &resp, &name, d, arg, below || !shown);
2387 }
2388 }
2389 ctx.data_mut(|d| d.insert_temp(pid, popup));
2390 resp
2391}
2392
2393fn completion_list(
2397 ui: &egui::Ui,
2398 resp: &egui::Response,
2399 cands: &[crate::completion::Candidate],
2400 sel: usize,
2401 rid: egui::Id,
2402) -> bool {
2403 use crate::theme::{BLACK, DARK_GRAY, LIGHT_GRAY, WHITE};
2404 let font = egui::TextStyle::Body.resolve(ui.style());
2405 let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
2406 let text_w =
2408 |s: &str| ui.fonts(|f| f.layout_no_wrap(s.to_owned(), font.clone(), BLACK).size().x);
2409 let widest = cands
2410 .iter()
2411 .map(|c| text_w(&c.hint()) + 12.0 + text_w(c.doc()))
2412 .fold(0.0, f32::max);
2413 let screen = ui.ctx().screen_rect();
2414 let size = egui::vec2(
2415 (widest + 12.0)
2416 .max(resp.rect.width())
2417 .min(screen.right() - resp.rect.left() - 2.0),
2418 cands.len() as f32 * row_h + 4.0,
2419 );
2420 let below = resp.rect.bottom() + 1.0 + size.y <= screen.bottom();
2421 let at = if below {
2422 resp.rect.left_bottom() + egui::vec2(0.0, 1.0)
2423 } else {
2424 resp.rect.left_top() - egui::vec2(0.0, size.y + 1.0)
2425 };
2426 let rect = egui::Rect::from_min_size(at, size);
2427 let painter = ui.ctx().layer_painter(egui::LayerId::new(
2428 egui::Order::Foreground,
2429 rid.with("list"),
2430 ));
2431 let p = painter.with_clip_rect(rect);
2432 p.rect_filled(rect, 0.0, WHITE);
2433 p.rect_stroke(rect.shrink(0.5), 0.0, egui::Stroke::new(1.0_f32, BLACK));
2434 for (i, c) in cands.iter().enumerate() {
2435 let row = egui::Rect::from_min_size(
2436 rect.min + egui::vec2(2.0, 2.0 + i as f32 * row_h),
2437 egui::vec2(size.x - 4.0, row_h),
2438 );
2439 if i == sel {
2440 p.rect_filled(row, 0.0, LIGHT_GRAY);
2441 }
2442 let hint = p.text(
2443 row.left_center() + egui::vec2(4.0, 0.0),
2444 egui::Align2::LEFT_CENTER,
2445 c.hint(),
2446 font.clone(),
2447 BLACK,
2448 );
2449 p.text(
2450 egui::pos2(hint.right() + 12.0, row.center().y),
2451 egui::Align2::LEFT_CENTER,
2452 c.doc(),
2453 font.clone(),
2454 DARK_GRAY,
2455 );
2456 }
2457 ui.ctx().data_mut(|d| d.insert_temp(rid, rect));
2458 below
2459}
2460
2461fn signature_tooltip(
2465 ui: &egui::Ui,
2466 resp: &egui::Response,
2467 name: &str,
2468 d: &improv_core_model::functions::FnDef,
2469 arg: Option<usize>,
2470 above: bool,
2471) {
2472 use crate::theme::{BLACK, BOLD, DARK_GRAY, WHITE};
2473 use egui::text::{LayoutJob, TextFormat};
2474 let font = egui::TextStyle::Body.resolve(ui.style());
2475 let bold = egui::FontId::new(font.size, egui::FontFamily::Name(BOLD.into()));
2476 let (before, current, after) = crate::completion::hint_parts(name, d, arg);
2477 let mut job = LayoutJob::default();
2478 job.append(&before, 0.0, TextFormat::simple(font.clone(), BLACK));
2479 job.append(
2480 ¤t,
2481 0.0,
2482 TextFormat {
2483 font_id: bold,
2484 color: BLACK,
2485 underline: egui::Stroke::new(1.0_f32, BLACK),
2486 ..Default::default()
2487 },
2488 );
2489 job.append(&after, 0.0, TextFormat::simple(font, BLACK));
2490 let (pivot, at) = if above {
2491 (
2492 egui::Align2::LEFT_BOTTOM,
2493 resp.rect.left_top() - egui::vec2(0.0, 2.0),
2494 )
2495 } else {
2496 (
2497 egui::Align2::LEFT_TOP,
2498 resp.rect.left_bottom() + egui::vec2(0.0, 2.0),
2499 )
2500 };
2501 egui::Area::new(resp.id.with("signature"))
2502 .order(egui::Order::Tooltip)
2503 .interactable(false)
2504 .pivot(pivot)
2505 .fixed_pos(at)
2506 .show(ui.ctx(), |ui| {
2507 egui::Frame::popup(ui.style()).fill(WHITE).show(ui, |ui| {
2509 ui.set_max_width(460.0);
2510 ui.label(job);
2511 ui.label(crate::completion::short_doc(d));
2512 ui.label(
2513 egui::RichText::new(crate::function_help::category_title(d.category))
2514 .color(DARK_GRAY),
2515 );
2516 });
2517 });
2518}
2519
2520fn formula_text_edit(
2524 ui: &mut egui::Ui,
2525 text: &mut String,
2526 id: egui::Id,
2527 hint: &str,
2528 span: Option<std::ops::Range<usize>>,
2529 keep_tab: bool,
2530) -> egui::text_edit::TextEditOutput {
2531 let font = egui::TextStyle::Body.resolve(ui.style());
2532 let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
2533 let mut job = crate::formula_highlight::highlight_formula(text, font.clone(), span.clone());
2534 job.wrap.max_width = wrap_width;
2535 ui.fonts(|f| f.layout_job(job))
2536 };
2537 egui::TextEdit::singleline(text)
2538 .id(id)
2539 .lock_focus(keep_tab)
2540 .hint_text(hint)
2541 .desired_width(f32::INFINITY)
2542 .layouter(&mut layouter)
2543 .show(ui)
2544}
2545
2546fn formula_error_lines(ui: &mut egui::Ui, d: &diagnostics::Diagnostic) -> egui::Response {
2549 ui.horizontal(|ui| {
2550 ui.add_space(44.0);
2551 ui.vertical(|ui| {
2552 ui.colored_label(
2553 crate::formula_highlight::ERROR_COLOR,
2554 format!("⚠ {}", d.message),
2555 );
2556 if let Some(h) = &d.hint {
2557 ui.label(egui::RichText::new(h).color(crate::theme::DARK_GRAY));
2558 }
2559 });
2560 })
2561 .response
2562}
2563
2564fn build_engine(model: &Model) -> (Option<Engine>, Snapshot) {
2567 match try_build_engine(model) {
2568 Ok(pair) => pair,
2569 Err(e) => {
2570 eprintln!("improv-gui: engine build failed: {e}");
2571 (None, Snapshot::default())
2572 }
2573 }
2574}
2575
2576const DISABLED_MARKER: &str = "[improv:formula-disabled]";
2613
2614const MARKER_SEP: char = ' ';
2623
2624fn split_marker(description: Option<&str>) -> (bool, Option<&str>) {
2631 let Some(d) = description else {
2632 return (false, None);
2633 };
2634 match d.strip_prefix(DISABLED_MARKER) {
2635 Some("") => (true, None),
2636 Some(rest) if rest.starts_with(MARKER_SEP) => (true, Some(&rest[MARKER_SEP.len_utf8()..])),
2637 _ => (false, Some(d)),
2638 }
2639}
2640
2641fn join_marker(disabled: bool, user: Option<&str>) -> Option<String> {
2644 match (disabled, user) {
2645 (false, user) => user.map(str::to_string),
2646 (true, None) => Some(DISABLED_MARKER.to_string()),
2647 (true, Some(user)) => Some(format!("{DISABLED_MARKER}{MARKER_SEP}{user}")),
2648 }
2649}
2650
2651fn formula_disabled(m: &Measure) -> bool {
2654 m.is_derived() && split_marker(m.description.as_deref()).0
2655}
2656
2657fn engine_model(model: &Model) -> Cow<'_, Model> {
2669 if !model.measures.values().any(formula_disabled) {
2670 return Cow::Borrowed(model);
2671 }
2672 let mut out = model.clone();
2673 for m in out.measures.values_mut() {
2674 if formula_disabled(m) {
2675 m.kind = MeasureKind::Input;
2676 }
2677 }
2678 Cow::Owned(out)
2679}
2680
2681fn try_build_engine(model: &Model) -> Result<(Option<Engine>, Snapshot), String> {
2689 let model = engine_model(model);
2690 let derived: Vec<MeasureId> = model
2691 .measures
2692 .values()
2693 .filter(|m| m.is_derived())
2694 .map(|m| m.id)
2695 .collect();
2696 if derived.is_empty() {
2697 return Ok((None, Snapshot::default()));
2698 }
2699 let (e, snap) = Engine::new(&model, &derived).map_err(|e| e.to_string())?;
2700 Ok((Some(e), snap))
2701}
2702
2703fn formula_dsl(model: &Model, formula: &improv_core_model::Formula) -> Option<String> {
2714 expr_dsl(model, &formula.expr)
2715}
2716
2717fn prec(e: &Expr) -> u8 {
2720 match e {
2721 Expr::BinaryOp(op, _, _) => match op {
2722 BinaryOp::Or => 1,
2723 BinaryOp::And => 2,
2724 BinaryOp::Eq
2725 | BinaryOp::Ne
2726 | BinaryOp::Lt
2727 | BinaryOp::Le
2728 | BinaryOp::Gt
2729 | BinaryOp::Ge => 4,
2730 BinaryOp::Concat => 5,
2731 BinaryOp::Add | BinaryOp::Sub => 6,
2732 BinaryOp::Mul | BinaryOp::Div => 7,
2733 BinaryOp::Pow => 9,
2734 },
2735 Expr::UnaryOp(UnaryOp::Not, _) => 3,
2738 Expr::UnaryOp(UnaryOp::Neg, _) => 8,
2739 _ => 10,
2740 }
2741}
2742
2743fn child_dsl(model: &Model, e: &Expr, min_prec: u8) -> Option<String> {
2745 let s = expr_dsl(model, e)?;
2746 Some(if prec(e) < min_prec {
2747 format!("({s})")
2748 } else {
2749 s
2750 })
2751}
2752
2753fn expr_dsl(model: &Model, e: &Expr) -> Option<String> {
2754 match e {
2755 Expr::Literal(v) => literal_dsl(v),
2756 Expr::Ref(id, _) if improv_core_model::functions::lambda_slot_of(*id).is_some() => {
2758 let (k, i) = improv_core_model::functions::lambda_slot_of(*id)?;
2759 Some(format!("{}{k}", if i == 0 { "acc" } else { "val" }))
2760 }
2761 Expr::Ref(id, spec) => {
2762 if !spec.over.is_empty() || !spec.except.is_empty() {
2765 return None;
2766 }
2767 ref_dsl(model, *id, &spec.by)
2768 }
2769 Expr::UnaryOp(op, inner) => {
2770 let s = child_dsl(model, inner, prec(e))?;
2773 Some(match op {
2774 UnaryOp::Neg => format!("-{s}"),
2775 UnaryOp::Not => format!("NOT {s}"),
2776 })
2777 }
2778 Expr::BinaryOp(op, l, r) => {
2779 let p = prec(e);
2780 let sym = match op {
2781 BinaryOp::Add => "+",
2782 BinaryOp::Sub => "-",
2783 BinaryOp::Mul => "*",
2784 BinaryOp::Div => "/",
2785 BinaryOp::And => "AND",
2786 BinaryOp::Or => "OR",
2787 BinaryOp::Eq => "==",
2788 BinaryOp::Ne => "<>",
2789 BinaryOp::Lt => "<",
2790 BinaryOp::Le => "<=",
2791 BinaryOp::Gt => ">",
2792 BinaryOp::Ge => ">=",
2793 BinaryOp::Pow => "^",
2794 BinaryOp::Concat => "&",
2795 };
2796 let (lmin, rmin) = match op {
2803 BinaryOp::Pow => (p + 1, 8),
2804 _ if p == 4 => (5, 5),
2805 _ => (p, p + 1),
2806 };
2807 Some(format!(
2808 "{} {sym} {}",
2809 child_dsl(model, l, lmin)?,
2810 child_dsl(model, r, rmin)?
2811 ))
2812 }
2813 Expr::Call(func, args) => call_dsl(model, *func, args),
2814 Expr::Category(c) => ident(category_name(model, *c)?),
2815 }
2816}
2817
2818fn call_dsl(model: &Model, func: FuncId, args: &[Expr]) -> Option<String> {
2819 if let Some(l) =
2822 improv_core_model::functions::named_lambda_of(func).and_then(|id| model.lambdas.get(&id))
2823 {
2824 let parts: Option<Vec<String>> = args.iter().map(|a| expr_dsl(model, a)).collect();
2825 return Some(format!("{}({})", ident(&l.name.0)?, parts?.join(", ")));
2826 }
2827 let name = func_name(func)?;
2828 if name == "SCAN" || name == "REDUCE" {
2831 let [init, Expr::Ref(x, spec), body] = args else {
2832 return None;
2833 };
2834 let [cat] = spec.over.as_slice() else {
2835 return None;
2836 };
2837 return Some(format!(
2838 "{name}({}, {} OVER {}, LAMBDA(acc0, val0, {}))",
2839 expr_dsl(model, init)?,
2840 ref_dsl(model, *x, &spec.by)?,
2841 ident(category_name(model, *cat)?)?,
2842 expr_dsl(model, body)?
2843 ));
2844 }
2845 if name == "FIXPOINT" {
2848 let (init, body, opts) = match args {
2849 [i, b, o @ ..] => (i, b, o),
2850 _ => return None,
2851 };
2852 let mut parts = vec![
2853 expr_dsl(model, init)?,
2854 format!("LAMBDA(acc0, {})", expr_dsl(model, body)?),
2855 ];
2856 for o in opts {
2857 parts.push(expr_dsl(model, o)?);
2858 }
2859 return Some(format!("FIXPOINT({})", parts.join(", ")));
2860 }
2861 if is_aggregation(func) {
2868 return aggregation_dsl(model, func, name, args);
2869 }
2870 let rendered: Option<Vec<String>> = args.iter().map(|a| expr_dsl(model, a)).collect();
2871 Some(format!("{name}({})", rendered?.join(", ")))
2872}
2873
2874fn aggregation_dsl(model: &Model, func: FuncId, name: &str, args: &[Expr]) -> Option<String> {
2879 let def = improv_core_model::functions::by_id(func)?;
2880 let mut pred = None;
2881 let mut window = None;
2882 let mut into = None;
2883 let mut rest = Vec::new();
2884 for a in args {
2885 match a {
2886 Expr::Call(id, k) if *id == improv_core_model::functions::INTO => into = Some(k),
2887 Expr::Call(id, p) if *id == improv_core_model::functions::WHERE => pred = p.first(),
2888 Expr::Call(id, b) if *id == improv_core_model::functions::WINDOW => window = Some(b),
2889 other => rest.push(other),
2890 }
2891 }
2892 let mut over: Option<&Vec<CategoryId>> = None;
2893 let mut parts = Vec::new();
2894 let over_at = match def.over {
2898 improv_core_model::functions::Over::Leading(_) => {
2899 (0..rest.len()).rev().find(|i| def.reduced(*i, rest[*i]))
2900 }
2901 _ => (0..rest.len()).find(|i| def.reduced(*i, rest[*i])),
2902 };
2903 for (i, a) in rest.iter().enumerate() {
2904 if !def.reduced(i, a) {
2905 parts.push(expr_dsl(model, a)?);
2906 continue;
2907 }
2908 let Expr::Ref(id, spec) = a else {
2909 return None;
2910 };
2911 if !spec.except.is_empty() || spec.over.is_empty() {
2912 return None;
2913 }
2914 let mut part = ref_dsl(model, *id, &spec.by)?;
2915 if over.is_some_and(|o| *o != spec.over) {
2916 return None;
2917 }
2918 if Some(i) == over_at {
2919 let cats: Option<Vec<String>> = spec
2920 .over
2921 .iter()
2922 .map(|c| ident(category_name(model, *c)?))
2923 .collect();
2924 part.push_str(" OVER ");
2925 part.push_str(&cats?.join(", "));
2926 if let Some(b) = window {
2927 if spec.over.len() != 1 || b.len() != 2 {
2928 return None;
2929 }
2930 part.push_str(&format!("[{}..{}]", bound_dsl(&b[0])?, bound_dsl(&b[1])?));
2931 }
2932 if let Some(p) = pred {
2933 part.push_str(" WHERE ");
2934 part.push_str(&expr_dsl(model, p)?);
2935 }
2936 }
2937 over = Some(&spec.over);
2938 parts.push(part);
2939 }
2940 over?;
2941 if let Some(ks) = into {
2942 let names: Option<Vec<String>> = ks.iter().map(|k| expr_dsl(model, k)).collect();
2943 let last = parts.last_mut()?;
2944 last.push_str(" INTO ");
2945 last.push_str(&names?.join(", "));
2946 }
2947 Some(format!("{name}({})", parts.join(", ")))
2948}
2949
2950fn bound_dsl(e: &Expr) -> Option<String> {
2952 match e {
2953 Expr::Literal(Value::Text(t)) if t == "FIRST" || t == "LAST" => Some(t.clone()),
2954 Expr::Literal(Value::Number(n)) if *n == 0.0 => Some("THIS".into()),
2955 Expr::Literal(Value::Number(n)) if n.fract() == 0.0 && *n > 0.0 => {
2956 Some(format!("THIS+{n}"))
2957 }
2958 Expr::Literal(Value::Number(n)) if n.fract() == 0.0 => Some(format!("THIS-{}", -n)),
2959 _ => None,
2960 }
2961}
2962
2963fn is_aggregation(func: FuncId) -> bool {
2965 improv_core_model::functions::is_aggregation(func)
2966}
2967
2968fn func_name(func: FuncId) -> Option<&'static str> {
2970 improv_core_model::functions::by_id(func).map(|d| d.name)
2971}
2972
2973fn ref_dsl(model: &Model, id: MeasureId, by: &[CategoryId]) -> Option<String> {
2974 let name = ident(model.measures.get(&id).map(|m| m.name.0.as_str())?)?;
2975 if by.is_empty() {
2976 return Some(name);
2977 }
2978 let cats: Option<Vec<String>> = by
2979 .iter()
2980 .map(|c| category_name(model, *c).and_then(ident))
2981 .collect();
2982 Some(format!("{name}[{}]", cats?.join(", ")))
2983}
2984
2985fn category_name(model: &Model, c: CategoryId) -> Option<&str> {
2986 model.categories.get(&c).map(|cat| cat.name.0.as_str())
2987}
2988
2989pub(crate) fn ident(name: &str) -> Option<String> {
3007 if is_bare_ident(name) {
3008 return Some(name.to_string());
3009 }
3010 if name.is_empty() {
3012 return None;
3013 }
3014 Some(format!("'{}'", name.replace('\'', "''")))
3015}
3016
3017fn is_bare_ident(name: &str) -> bool {
3020 let mut chars = name.chars();
3021 let head_ok = chars.next().is_some_and(|c| c.is_alphabetic() || c == '_');
3022 if !head_ok || !chars.all(|c| c.is_alphanumeric() || c == '_') {
3023 return false;
3024 }
3025 const KEYWORDS: &[&str] = &["NOT", "AND", "OR", "OVER", "TRUE", "FALSE"];
3030 !KEYWORDS.iter().any(|k| name.eq_ignore_ascii_case(k))
3031}
3032
3033fn literal_dsl(v: &Value) -> Option<String> {
3034 match v {
3035 Value::Number(n) => {
3037 let s = format!("{n}");
3038 (n.is_finite() && *n >= 0.0 && s.chars().all(|c| c.is_ascii_digit() || c == '.'))
3039 .then_some(s)
3040 }
3041 Value::Boolean(b) => Some(if *b { "TRUE".into() } else { "FALSE".into() }),
3042 Value::Text(t) => (!t.contains('"') && !t.contains('\\') && !t.contains(char::is_control))
3045 .then(|| format!("\"{t}\"")),
3046 Value::DateTime(dt) => Some(format!("#{}#", dt.to_rfc3339())),
3047 Value::Enum(_) | Value::Error(_) => None,
3048 }
3049}
3050
3051fn parse_typed(model: &Model, declared: ValueType, text: &str) -> Result<Value, String> {
3062 let literal = |src: String| match parser::parse_expr(model, &src) {
3063 Ok(f) => match f.expr {
3064 Expr::Literal(v) if v.type_of() == Some(declared) => Some(v),
3065 _ => None,
3066 },
3067 Err(_) => None,
3068 };
3069 match declared {
3070 ValueType::Number => text
3071 .parse::<f64>()
3072 .map(Value::Number)
3073 .map_err(|_| format!("not a number: {text:?}")),
3074 ValueType::Text => Ok(Value::Text(text.to_string())),
3077 ValueType::Boolean => literal(text.to_ascii_uppercase())
3078 .ok_or_else(|| format!("not a boolean (use true/false): {text:?}")),
3079 ValueType::DateTime => literal(format!("#{text}#"))
3080 .ok_or_else(|| format!("not a date (use YYYY-MM-DD or RFC3339): {text:?}")),
3081 ValueType::Enum => text
3082 .parse::<u32>()
3083 .map(Value::Enum)
3084 .map_err(|_| format!("not an enum index: {text:?}")),
3085 }
3086}
3087
3088fn decode(k: &CoordKey) -> improv_core_model::Coordinate {
3089 improv_core_model::Coordinate::from_pairs(k.iter().map(|(c, i)| (CategoryId(*c), ItemId(*i))))
3090}
3091
3092fn pick_default_measure(model: &Model) -> Option<MeasureId> {
3093 let mut ids: Vec<MeasureId> = model.measures.keys().copied().collect();
3094 ids.sort_by_key(|m| m.0);
3095 ids.iter()
3096 .find(|m| {
3097 model
3098 .measures
3099 .get(m)
3100 .map(|x| x.is_derived())
3101 .unwrap_or(false)
3102 })
3103 .copied()
3104 .or_else(|| ids.first().copied())
3105}
3106
3107fn natural_axis_order(model: &Model, measure: Option<MeasureId>) -> Vec<CategoryId> {
3109 measure
3110 .and_then(|m| model.measures.get(&m))
3111 .map(|m| m.categories.clone())
3112 .unwrap_or_default()
3113}
3114
3115impl eframe::App for ImprovApp {
3116 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
3117 self.sync_axis_state();
3118 self.sync_native_title(ctx);
3119 self.handle_command_keys(ctx);
3120 self.status_bar(ctx);
3125 self.chart_panel(ctx);
3126 self.csv_wizard_panel(ctx);
3127 let ws = self.canvas_panel(ctx);
3128 self.model_browser(ctx, ws);
3130 self.info_panel(ctx, ws);
3131 self.function_help(ctx, ws);
3132 self.menu_bar(ctx, ws);
3134 }
3135}
3136
3137impl ImprovApp {
3138 fn handle_command_keys(&mut self, ctx: &egui::Context) {
3146 let other_focus = ctx.memory(|m| m.focused()).is_some();
3147 if !grid_keys_enabled(self.focused().editing.is_some(), other_focus) {
3148 return;
3149 }
3150 let commands: Vec<Command> = ctx.input(|i| {
3153 i.events
3154 .iter()
3155 .filter_map(|e| match e {
3156 egui::Event::Key {
3157 key,
3158 pressed: true,
3159 repeat: false,
3160 modifiers,
3161 ..
3162 } if modifiers.command => menu::key_command(*key, modifiers.shift),
3163 _ => None,
3164 })
3165 .collect()
3166 });
3167 for c in commands {
3168 if self.command_enabled(c) {
3169 self.run_command(ctx, c);
3170 }
3171 }
3172 }
3173
3174 fn menu_bar(&mut self, ctx: &egui::Context, ws: egui::Rect) {
3177 let views: Vec<(String, bool)> = self
3178 .document_tabs()
3179 .into_iter()
3180 .map(|(_, name, current)| (name, current))
3181 .collect();
3182 let mut state = self.menu;
3183 let home = ws.min + egui::vec2(4.0, 4.0);
3184 let picked = menu::show(ctx, &mut state, home, &views, |c| self.command_enabled(c));
3185 self.menu = state;
3186 if let Some(c) = picked {
3187 self.run_command(ctx, c);
3188 }
3189 }
3190
3191 fn command_enabled(&self, c: Command) -> bool {
3193 match c {
3194 Command::Undo => self.can_undo(),
3195 Command::Redo => self.can_redo(),
3196 Command::RemoveMatrix => self.matrix_count() > 1,
3197 Command::SaveModel | Command::RevertToSaved => !self.db.is_empty(),
3199 _ => true,
3200 }
3201 }
3202
3203 fn run_command(&mut self, ctx: &egui::Context, c: Command) {
3205 if c != Command::Quit {
3206 self.quit_armed = false;
3207 }
3208 match c {
3209 Command::InfoPanel => self.show_inspector = !self.show_inspector,
3210 Command::ModelBrowser => self.show_browser = !self.show_browser,
3211 Command::SaveModel => {
3212 self.status = match self.save() {
3213 Ok(()) => "saved model".into(),
3214 Err(e) => e,
3215 };
3216 }
3217 Command::RevertToSaved => {
3218 self.status = match self.revert_to_saved() {
3219 Ok(()) => "reverted to the saved model".into(),
3220 Err(e) => e,
3221 };
3222 }
3223 Command::Undo => self.undo_with_status(),
3224 Command::Redo => self.redo_with_status(),
3225 Command::PivotRotate => self.pivot_rotate(),
3226 Command::AddMatrix => {
3227 let m = self
3228 .selected()
3229 .or_else(|| pick_default_measure(&self.model));
3230 self.add_matrix(m);
3231 }
3232 Command::RemoveMatrix => {
3233 let i = self.focus_index();
3234 self.remove_matrix(i);
3235 }
3236 Command::SaveView => {
3237 let name = if self.view_name.trim().is_empty() {
3239 format!("view {}", self.model.views.len() + 1)
3240 } else {
3241 self.view_name.clone()
3242 };
3243 if self.save_view(&name).is_some() {
3244 self.view_name.clear();
3245 self.status = format!("saved view '{name}'");
3246 }
3247 }
3248 Command::OpenView(i) => {
3249 if let Some(&(id, _, _)) = self.document_tabs().get(i) {
3250 self.open_view(id);
3251 }
3252 }
3253 Command::ToggleChart => self.show_chart = !self.show_chart,
3254 Command::ToggleCsv => self.toggle_csv_wizard(),
3255 Command::FunctionHelp => self.fn_help.toggle(),
3256 Command::Hide => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)),
3257 Command::Quit => {
3258 if self.quit_allowed() {
3259 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
3260 }
3261 }
3262 }
3263 }
3264
3265 fn quit_allowed(&mut self) -> bool {
3268 if self.db.is_empty() && self.can_undo() && !self.quit_armed {
3269 self.quit_armed = true;
3270 self.status = "unsaved in-memory model: Quit again to discard".into();
3271 return false;
3272 }
3273 true
3274 }
3275
3276 fn model_browser(&mut self, ctx: &egui::Context, ws: egui::Rect) {
3281 let mut open = self.show_browser;
3282 let rect = egui::Rect::from_min_size(
3283 egui::pos2(ws.left(), ws.bottom() - 300.0),
3284 egui::vec2(260.0, 300.0),
3285 );
3286 let win = crate::nxwin::NxWindow::new("browser", format!("{} - Model", self.file_name()));
3287 let rect = anchored_rect(ctx, win.id(), rect);
3288 let _ = win
3289 .closable(&mut open)
3290 .rect(rect)
3291 .resizable(true)
3292 .show(ctx, |ui| {
3293 crate::theme::next_scroll(
3294 ui,
3295 egui::ScrollArea::vertical().auto_shrink(false),
3296 |ui| {
3297 self.views_section(ui);
3298
3299 egui::CollapsingHeader::new("Categories")
3300 .default_open(false)
3301 .show(ui, |ui| {
3302 let mut cats: Vec<CategoryId> =
3303 self.model.categories.keys().copied().collect();
3304 cats.sort_by_key(|c| c.0);
3305 for cid in cats {
3306 let cat = &self.model.categories[&cid];
3307 egui::CollapsingHeader::new(&cat.name.0)
3308 .id_salt(("cat", cid.0))
3309 .show(ui, |ui| {
3310 let mut items = cat.items.clone();
3311 items.sort_by_key(|i| i.0);
3312 for iid in items {
3313 if let Some(it) = self.model.items.get(&iid) {
3314 ui.label(&it.name.0);
3315 }
3316 }
3317 });
3318 }
3319 });
3320
3321 egui::CollapsingHeader::new("Measures")
3322 .default_open(true)
3323 .show(ui, |ui| {
3324 let mut ids: Vec<MeasureId> =
3325 self.model.measures.keys().copied().collect();
3326 ids.sort_by_key(|m| m.0);
3327 ui.scope(|ui| {
3328 white_selection(ui);
3329 for id in ids {
3330 let m = &self.model.measures[&id];
3331 let kind = if m.is_derived() { "formula" } else { "input" };
3332 let name = m.name.0.clone();
3333 let sel = self.selected() == Some(id);
3334 ui.horizontal(|ui| {
3335 if ui.selectable_label(sel, name).clicked() {
3336 self.focused_mut().select(Some(id));
3337 }
3338 ui.with_layout(
3339 egui::Layout::right_to_left(egui::Align::Center),
3340 |ui| {
3341 ui.add_space(8.0);
3344 ui.label(
3345 egui::RichText::new(kind)
3346 .small()
3347 .color(crate::theme::DARK_GRAY),
3348 );
3349 },
3350 );
3351 });
3352 }
3353 });
3354 });
3355 },
3356 );
3357 });
3358 self.show_browser = open;
3359 }
3360
3361 fn view_description(&self, v: &View) -> String {
3365 let Some(m) = self.model.measures.get(&v.measure) else {
3366 return format!("{}: measure no longer exists", v.name.0);
3367 };
3368 let cats = if v.axis_order.is_empty() {
3369 &m.categories
3370 } else {
3371 &v.axis_order
3372 };
3373 let names: Vec<&str> = cats
3374 .iter()
3375 .map(|c| {
3376 self.model
3377 .categories
3378 .get(c)
3379 .map_or("?", |c| c.name.0.as_str())
3380 })
3381 .collect();
3382 let shown = (v.n_rows + v.n_cols).min(names.len());
3383 let mut s = m.name.0.clone();
3384 if shown > 0 {
3385 s += &format!(" by {}", names[..shown].join(" \u{d7} "));
3386 }
3387 if shown < names.len() {
3388 s += &format!("; pages: {}", names[shown..].join(", "));
3389 }
3390 s
3391 }
3392
3393 fn views_section(&mut self, ui: &mut egui::Ui) {
3404 use crate::theme::{BLACK, WHITE};
3405 let mut ids: Vec<ViewId> = self.model.views.keys().copied().collect();
3406 ids.sort_by_key(|v| v.0);
3407 if self
3408 .browser_sel
3409 .is_none_or(|s| !self.model.views.contains_key(&s))
3410 {
3411 self.browser_sel = self.current_view.or(ids.first().copied());
3412 }
3413 let box_frame = || {
3414 egui::Frame::none()
3415 .fill(WHITE)
3416 .stroke(egui::Stroke::new(1.0_f32, BLACK))
3417 .inner_margin(2.0)
3418 };
3419 let font = egui::TextStyle::Body.resolve(ui.style());
3420 let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
3421 let width = ui.available_width() - 8.0;
3423 let mut open: Option<View> = None;
3424 box_frame().show(ui, |ui| {
3425 ui.set_width(width - 4.0);
3426 ui.set_min_height(3.0 * row_h);
3427 ui.spacing_mut().item_spacing.y = 0.0;
3428 for &id in &ids {
3429 let (rect, resp) = ui.allocate_exact_size(
3430 egui::vec2(ui.available_width(), row_h),
3431 egui::Sense::click(),
3432 );
3433 let v = &self.model.views[&id];
3434 let p = ui.painter();
3435 if self.browser_sel == Some(id) {
3436 p.rect_filled(rect, 0.0, crate::theme::LIGHT_GRAY);
3437 }
3438 p.text(
3439 rect.left_center() + egui::vec2(4.0, 0.0),
3440 egui::Align2::LEFT_CENTER,
3441 format!("{} - Worksheet", v.name.0),
3442 font.clone(),
3443 BLACK,
3444 );
3445 let icon = egui::Rect::from_center_size(
3447 egui::pos2(rect.right() - 9.0, rect.center().y),
3448 egui::vec2(10.0, 8.0),
3449 );
3450 let ink = egui::Stroke::new(1.0_f32, BLACK);
3451 p.rect_stroke(icon, 0.0, ink);
3452 for y in [icon.top() + 8.0 / 3.0, icon.top() + 16.0 / 3.0] {
3453 p.hline(icon.x_range(), y, ink);
3454 }
3455 if resp.clicked() {
3456 self.browser_sel = Some(id);
3457 }
3458 if resp.double_clicked() {
3459 open = Some(v.clone());
3460 }
3461 }
3462 });
3463 if let Some(v) = open {
3464 self.apply_view(&v);
3465 }
3466 let desc = match self.browser_sel.and_then(|s| self.model.views.get(&s)) {
3467 Some(v) => self.view_description(v),
3468 None => "(no saved views)".to_owned(),
3469 };
3470 ui.add_space(4.0);
3471 box_frame().inner_margin(4.0).show(ui, |ui| {
3472 ui.set_width(width - 8.0);
3473 ui.set_min_height(2.0 * row_h);
3474 ui.label(egui::RichText::new(desc).color(BLACK));
3475 });
3476 ui.add_space(4.0);
3477 ui.horizontal(|ui| {
3478 ui.add(egui::TextEdit::singleline(&mut self.view_name).desired_width(120.0));
3480 if ui.button("Save view").clicked() {
3481 let name = self.view_name.clone();
3482 if self.save_view(&name).is_some() {
3483 self.view_name.clear();
3484 }
3485 }
3486 });
3487 }
3488
3489 fn info_panel(&mut self, ctx: &egui::Context, ws: egui::Rect) {
3493 let mut open = self.show_inspector;
3494 let rect = egui::Rect::from_min_size(
3495 egui::pos2(ws.right() - 280.0, ws.top()),
3496 egui::vec2(280.0, 240.0),
3497 );
3498 let win = crate::nxwin::NxWindow::new("info", "Info");
3499 let rect = anchored_rect(ctx, win.id(), rect);
3500 let _ = win
3501 .closable(&mut open)
3502 .rect(rect)
3503 .resizable(false)
3504 .show(ctx, |ui| {
3505 let data = self.selected().and_then(|m| self.inspector_data(m));
3506 match data {
3507 None => {
3508 ui.label("No measure selected.");
3509 }
3510 Some(d) => {
3511 ui.label(format!("id: {}", d.id.0));
3512 ui.label(format!("name: {}", d.name));
3513 ui.label(format!(
3514 "kind: {}",
3515 if d.is_derived { "derived" } else { "input" }
3516 ));
3517 ui.label(format!("value type: {:?}", d.value_type));
3518 ui.label(format!(
3519 "dimensions: {}",
3520 if d.dimensions.is_empty() {
3521 "(scalar)".to_string()
3522 } else {
3523 d.dimensions.join(" x ")
3524 }
3525 ));
3526 if d.is_derived {
3527 ui.separator();
3528 ui.label(format!("depends on: {}", d.dependencies.join(", ")));
3529 if let Some(eng) = &d.formula_english {
3530 ui.label(format!("formula: {eng}"));
3531 }
3532 if d.error_cells > 0 {
3533 ui.colored_label(
3534 crate::formula_highlight::ERROR_COLOR,
3535 format!("{} cell(s) have errors (#ERR)", d.error_cells),
3536 );
3537 }
3538 }
3539 }
3540 }
3541 });
3542 self.show_inspector = open;
3543 }
3544
3545 fn function_help(&mut self, ctx: &egui::Context, ws: egui::Rect) {
3551 use crate::function_help::{aliases, category_title};
3552 use crate::theme::{BLACK, BOLD, DARK_GRAY, LIGHT_GRAY, WHITE};
3553 if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F1)) {
3555 self.run_command(ctx, Command::FunctionHelp);
3556 }
3557 let mut open = self.fn_help.open;
3558 if !open {
3559 return;
3560 }
3561 let size = egui::vec2(420.0, 440.0);
3562 let home =
3563 egui::Rect::from_min_size(egui::pos2(ws.right() - size.x - 8.0, ws.top() + 32.0), size);
3564 let win = crate::nxwin::NxWindow::new("fnhelp", "Functions");
3565 let rect = anchored_rect(ctx, win.id(), home);
3566 let search_id = egui::Id::new("fnhelp_search");
3567 let mut insert = None;
3568 let _ = win
3569 .closable(&mut open)
3570 .rect(rect)
3571 .min_size(egui::vec2(300.0, 300.0))
3572 .show(ctx, |ui| {
3573 egui::Frame::none().inner_margin(4.0).show(ui, |ui| {
3574 let font = egui::TextStyle::Body.resolve(ui.style());
3575 let bold = egui::FontId::new(font.size, egui::FontFamily::Name(BOLD.into()));
3576 let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
3577 let width = ui.available_width();
3578 let search = ui
3579 .horizontal(|ui| {
3580 ui.label("Search:");
3581 ui.add(
3582 egui::TextEdit::singleline(&mut self.fn_help.query)
3583 .id(search_id)
3584 .hint_text("name or description")
3585 .desired_width(f32::INFINITY),
3586 )
3587 })
3588 .inner;
3589 if std::mem::take(&mut self.fn_help.focus_search) {
3590 search.request_focus();
3591 }
3592 let (up, down, enter) = ui.input(|i| {
3593 (
3594 i.key_pressed(egui::Key::ArrowUp),
3595 i.key_pressed(egui::Key::ArrowDown),
3596 i.key_pressed(egui::Key::Enter),
3597 )
3598 });
3599 let mut moved = false;
3600 if search.has_focus() && (up || down) {
3601 self.fn_help.step(if up { -1 } else { 1 });
3602 moved = true;
3603 }
3604 if search.lost_focus() && enter {
3605 consume_enter(ui);
3606 insert = self.fn_help.selected;
3607 }
3608 let groups = self.fn_help.groups().to_vec();
3609 let selected = self.fn_help.selected.map(|d| d.id);
3610 let n: usize = groups.iter().map(|(_, fs)| fs.len()).sum();
3611 ui.label(
3612 egui::RichText::new(format!(
3613 "{n} function{}",
3614 if n == 1 { "" } else { "s" }
3615 ))
3616 .color(DARK_GRAY),
3617 );
3618 let detail_h = 5.0 * row_h + 36.0;
3619 let list_h = (ui.available_height() - detail_h - 8.0).max(3.0 * row_h);
3620 let boxed = || {
3621 egui::Frame::none()
3622 .fill(WHITE)
3623 .stroke(egui::Stroke::new(1.0_f32, BLACK))
3624 .inner_margin(2.0)
3625 };
3626 boxed().show(ui, |ui| {
3627 ui.set_width(width - 4.0);
3628 crate::theme::next_scroll(
3629 ui,
3630 egui::ScrollArea::vertical()
3631 .id_salt("fnhelp_list")
3632 .max_height(list_h)
3633 .min_scrolled_height(list_h)
3634 .auto_shrink(false),
3635 |ui| {
3636 ui.spacing_mut().item_spacing.y = 0.0;
3637 for (cat, fs) in &groups {
3638 let (rect, _) = ui.allocate_exact_size(
3639 egui::vec2(ui.available_width(), row_h),
3640 egui::Sense::hover(),
3641 );
3642 ui.painter().text(
3643 rect.left_center() + egui::vec2(4.0, 0.0),
3644 egui::Align2::LEFT_CENTER,
3645 category_title(*cat),
3646 bold.clone(),
3647 BLACK,
3648 );
3649 for &d in fs {
3650 let (rect, resp) = ui.allocate_exact_size(
3651 egui::vec2(ui.available_width(), row_h),
3652 egui::Sense::click(),
3653 );
3654 let p = ui.painter();
3655 if selected == Some(d.id) {
3656 p.rect_filled(rect, 0.0, LIGHT_GRAY);
3657 if moved {
3658 resp.scroll_to_me(None);
3659 }
3660 }
3661 p.text(
3662 rect.left_center() + egui::vec2(16.0, 0.0),
3663 egui::Align2::LEFT_CENTER,
3664 d.hint(),
3665 font.clone(),
3666 BLACK,
3667 );
3668 if resp.clicked() {
3669 self.fn_help.selected = Some(d);
3670 }
3671 if resp.double_clicked() {
3672 insert = Some(d);
3673 }
3674 }
3675 }
3676 },
3677 );
3678 });
3679 ui.add_space(4.0);
3680 boxed().inner_margin(4.0).show(ui, |ui| {
3681 ui.set_width(width - 8.0);
3682 ui.set_min_height(detail_h - 36.0);
3683 match self.fn_help.selected {
3684 None => {
3685 ui.label("No function matches.");
3686 }
3687 Some(d) => {
3688 ui.label(egui::RichText::new(d.hint()).font(bold.clone()));
3689 let mut facts = category_title(d.category).to_string();
3690 if let Some(a) = aliases(d) {
3691 facts.push_str(" · ");
3692 facts.push_str(&a);
3693 }
3694 ui.label(egui::RichText::new(facts).color(DARK_GRAY));
3695 ui.label(d.doc);
3696 ui.label(
3697 egui::RichText::new(format!("Arguments: {}", d.signature()))
3698 .color(DARK_GRAY),
3699 );
3700 }
3701 }
3702 });
3703 ui.add_space(4.0);
3704 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
3705 let can = self.fn_help.selected.is_some();
3706 if ui.add_enabled(can, egui::Button::new("Insert")).clicked() {
3707 insert = self.fn_help.selected;
3708 }
3709 });
3710 });
3711 });
3712 self.fn_help.open = open;
3713 if let Some(d) = insert {
3714 self.insert_function(ctx, d.name);
3715 }
3716 }
3717
3718 fn insert_function(&mut self, ctx: &egui::Context, name: &str) {
3722 let target = match self.formula_target {
3723 FormulaEditor::Row if self.formula_for.is_some() => FormulaEditor::Row,
3724 _ => FormulaEditor::NewLine,
3725 };
3726 let id = target.id();
3727 let buf = match target {
3728 FormulaEditor::Row => {
3729 self.formula_error = None;
3730 &mut self.formula_buf
3731 }
3732 FormulaEditor::NewLine => {
3733 self.new_formula_error = None;
3734 &mut self.new_formula_line
3735 }
3736 };
3737 let caret = caret_byte(ctx, id, buf);
3738 let (text, at) = crate::function_help::insert_call(buf, caret, name);
3739 *buf = text;
3740 set_caret(ctx, id, buf, at);
3741 ctx.memory_mut(|m| m.request_focus(id));
3742 self.status = format!("inserted {name}(");
3743 }
3744
3745 fn formula_list(&mut self, ui: &mut egui::Ui, mi: usize) {
3764 let editing = mi == self.focus_index();
3766 ui.horizontal(|ui| {
3767 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
3768 if ui.button("Calc").clicked() {
3769 self.rebuild_engine();
3770 self.status = "recalculated".into();
3771 }
3772 });
3773 });
3774 ui.separator();
3775 let rows = self.formula_rows();
3776 let mut select: Option<MeasureId> = None;
3780 let mut toggle: Option<(MeasureId, bool)> = None;
3781 let mut rects: Vec<(MeasureId, egui::Rect)> = Vec::new();
3782 crate::theme::next_scroll(
3783 ui,
3784 egui::ScrollArea::vertical().auto_shrink([false, true]),
3785 |ui| {
3786 white_selection(ui);
3787 for (i, row) in rows.iter().enumerate() {
3788 let selected = self.selected() == Some(row.measure);
3789 ui.horizontal(|ui| {
3790 let mut on = row.enabled;
3791 if ui
3792 .add(egui::Checkbox::without_text(&mut on))
3793 .on_hover_text(
3794 "Enabled. Uncheck to stop this formula \
3795 computing (dependents recompute without it).",
3796 )
3797 .changed()
3798 {
3799 toggle = Some((row.measure, on));
3800 }
3801 ui.strong(format!("{}.", i + 1));
3803 let label = format!("{} = {}", row.target, row.text);
3806 let text = if row.enabled {
3807 egui::RichText::new(label)
3808 } else {
3809 egui::RichText::new(label).weak().strikethrough()
3810 };
3811 let resp = if selected && editing {
3812 self.formula_editor(ui, row.measure, &row.target, row.error.as_deref())
3813 } else {
3814 ui.selectable_label(selected, text)
3815 };
3816 let shown = resp.rect.intersect(ui.clip_rect());
3818 if shown.is_positive() {
3819 rects.push((row.measure, shown));
3820 }
3821 if resp.clicked() {
3822 select = Some(row.measure);
3823 }
3824 });
3825 let edit_err = if selected && editing {
3826 self.formula_error.clone()
3827 } else {
3828 None
3829 };
3830 let row_err = row
3831 .error
3832 .as_deref()
3833 .map(|e| diagnostics::compile_error(&self.model, &row.text, e));
3834 if let Some(d) = edit_err.or(row_err) {
3835 let _ = formula_error_lines(ui, &d);
3836 }
3837 }
3838 self.new_formula_line(ui, editing);
3839 },
3840 );
3841 self.formula_row_rects = rects;
3842 if editing && !rows.iter().any(|r| Some(r.measure) == self.selected()) {
3845 self.formula_for = None;
3846 }
3847 if let Some(id) = select {
3848 self.focused_mut().select(Some(id));
3849 }
3850 if let Some((id, on)) = toggle {
3851 match self.set_formula_enabled(id, on) {
3852 Ok(()) => {
3853 self.status = format!(
3854 "formula {} {}",
3855 self.model
3856 .measures
3857 .get(&id)
3858 .map(|m| m.name.0.clone())
3859 .unwrap_or_default(),
3860 if on { "enabled" } else { "disabled" }
3861 );
3862 }
3863 Err(e) => self.status = format!("toggle failed: {e}"),
3864 }
3865 }
3866 }
3867
3868 fn formula_editor(
3873 &mut self,
3874 ui: &mut egui::Ui,
3875 mid: MeasureId,
3876 target: &str,
3877 row_error: Option<&str>,
3878 ) -> egui::Response {
3879 let source = self.formula_source(mid);
3880 if self.formula_for != Some(mid) {
3883 self.formula_for = Some(mid);
3884 self.formula_buf = source.clone().unwrap_or_default();
3885 self.formula_error = None;
3886 }
3887 let Some(source) = source else {
3888 let english = self
3889 .model
3890 .measures
3891 .get(&mid)
3892 .and_then(|m| match &m.kind {
3893 MeasureKind::Derived(f) => {
3894 Some(describe_formula(&NlContext::new(&self.model), f))
3895 }
3896 MeasureKind::Input => None,
3897 })
3898 .unwrap_or_default();
3899 return ui.selectable_label(true, format!("{target} = {english}"));
3900 };
3901 ui.strong(format!("{target} ="));
3902 let standing = row_error
3904 .filter(|_| self.formula_buf == source)
3905 .and_then(|e| diagnostics::compile_error(&self.model, &source, e).span);
3906 let span = match &self.formula_error {
3907 Some(d) => d.span.clone(),
3908 None => standing,
3909 };
3910 let resp = assisted_formula_edit(
3911 ui,
3912 &self.model,
3913 &mut self.formula_buf,
3914 egui::Id::new("formula_edit"),
3915 "e.g. Price * Quantity",
3916 span,
3917 0,
3918 );
3919 if resp.has_focus() {
3920 self.formula_target = FormulaEditor::Row;
3921 }
3922 if resp.changed() {
3923 self.formula_error = None;
3925 }
3926 let (enter, esc) = ui.input(|i| {
3927 (
3928 i.key_pressed(egui::Key::Enter),
3929 i.key_pressed(egui::Key::Escape),
3930 )
3931 });
3932 if esc && (resp.has_focus() || resp.lost_focus()) {
3933 self.formula_buf = source;
3934 self.formula_error = None;
3935 resp.surrender_focus();
3936 } else if enter && resp.lost_focus() {
3937 consume_enter(ui);
3938 let text = self.formula_buf.clone();
3939 match self.commit_formula(mid, &text) {
3940 Ok(()) => self.status = "formula updated".into(),
3941 Err(e) => self.status = format!("formula error: {e}"),
3942 }
3943 }
3944 resp
3945 }
3946
3947 fn new_formula_line(&mut self, ui: &mut egui::Ui, editing: bool) {
3951 let id = if editing {
3952 egui::Id::new("formula_new")
3953 } else {
3954 ui.id().with("formula_new")
3955 };
3956 let span = self
3958 .new_formula_error
3959 .as_ref()
3960 .filter(|_| editing)
3961 .and_then(|d| d.span.clone());
3962 let resp = ui
3963 .horizontal(|ui| {
3964 ui.add_space(44.0);
3966 let from = self
3968 .new_formula_line
3969 .find('=')
3970 .map_or(usize::MAX, |i| i + 1);
3971 assisted_formula_edit(
3972 ui,
3973 &self.model,
3974 &mut self.new_formula_line,
3975 id,
3976 "Name = formula",
3977 span,
3978 from,
3979 )
3980 })
3981 .inner;
3982 if resp.has_focus() && editing {
3983 self.formula_target = FormulaEditor::NewLine;
3984 }
3985 if resp.changed() {
3986 self.new_formula_error = None;
3987 }
3988 let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
3989 if entered {
3990 consume_enter(ui);
3991 self.add_from_formula_line();
3992 }
3993 if let Some(d) = self.new_formula_error.as_ref().filter(|_| editing) {
3994 let shown = formula_error_lines(ui, d);
3995 if entered {
3996 shown.scroll_to_me(None);
3998 }
3999 }
4000 }
4001
4002 fn add_from_formula_line(&mut self) {
4005 let line = self.new_formula_line.clone();
4006 let Some((name, expr)) = line.split_once('=') else {
4007 self.status = "type Name = formula".into();
4008 return;
4009 };
4010 let at = line.len() - expr.trim_start().len();
4012 match self.add_derived_measure(name.trim(), expr.trim()) {
4013 Ok(id) => {
4014 self.focused_mut().select(Some(id));
4015 self.new_formula_line.clear();
4016 self.status = format!("added {}", name.trim());
4017 }
4018 Err(e) => {
4019 self.new_formula_error = Some(match self.new_formula_error.take() {
4020 Some(d) => d.shifted(at),
4021 None => diagnostics::Diagnostic::plain(e.clone()),
4023 });
4024 self.status = format!("formula error: {e}");
4025 }
4026 }
4027 }
4028
4029 fn chart_panel(&mut self, ctx: &egui::Context) {
4032 if !self.show_chart {
4033 return;
4034 }
4035 egui::SidePanel::right("chart")
4036 .resizable(true)
4037 .default_width(360.0)
4038 .show(ctx, |ui| {
4039 ui.horizontal(|ui| {
4040 ui.heading("Chart");
4041 ui.checkbox(&mut self.chart_line, "line");
4042 });
4043 ui.separator();
4044 let data = self.chart_series();
4045 crate::chart::render_chart(ui, &data, self.chart_line);
4046 });
4047 }
4048
4049 fn csv_wizard_panel(&mut self, ctx: &egui::Context) {
4055 if !self.show_csv_wizard {
4056 return;
4057 }
4058 let mut open = true;
4059 egui::Window::new("Import / Export CSV")
4060 .open(&mut open)
4061 .default_width(480.0)
4062 .show(ctx, |ui| {
4063 ui.columns(2, |cols| {
4064 self.csv_import_form(&mut cols[0]);
4065 self.csv_export_form(&mut cols[1]);
4066 });
4067 });
4068 if !open {
4069 self.show_csv_wizard = false;
4070 }
4071 }
4072
4073 fn csv_import_form(&mut self, ui: &mut egui::Ui) {
4077 ui.heading("Import");
4078 ui.horizontal(|ui| {
4079 ui.label("file");
4080 ui.text_edit_singleline(&mut self.import_form.path);
4081 });
4082 ui.horizontal(|ui| {
4083 ui.checkbox(&mut self.import_form.tsv, "TSV");
4084 ui.checkbox(&mut self.import_form.has_header, "has header row");
4085 });
4086 ui.horizontal(|ui| {
4087 ui.label("measure id");
4088 ui.add(
4089 egui::TextEdit::singleline(&mut self.import_form.measure_id).desired_width(60.0),
4090 );
4091 ui.label("name");
4092 ui.text_edit_singleline(&mut self.import_form.measure_name);
4093 });
4094 ui.horizontal(|ui| {
4095 ui.label("value column");
4096 ui.text_edit_singleline(&mut self.import_form.value_column);
4097 });
4098 ui.separator();
4099 ui.label("dimensions (column, category id, category name):");
4100 let mut remove: Option<usize> = None;
4101 for (i, row) in self.import_form.dimensions.iter_mut().enumerate() {
4102 ui.horizontal(|ui| {
4103 ui.text_edit_singleline(&mut row.column);
4104 ui.add(egui::TextEdit::singleline(&mut row.category_id).desired_width(40.0));
4105 ui.text_edit_singleline(&mut row.category_name);
4106 if ui.small_button("-").clicked() {
4107 remove = Some(i);
4108 }
4109 });
4110 }
4111 if let Some(i) = remove {
4112 self.import_form.dimensions.remove(i);
4113 }
4114 if ui.small_button("+ dimension").clicked() {
4115 self.import_form
4116 .dimensions
4117 .push(csv_wizard::DimRow::default());
4118 }
4119 ui.separator();
4120 if ui.button("Import").clicked() {
4121 self.run_csv_import();
4122 }
4123 }
4124
4125 fn csv_export_form(&mut self, ui: &mut egui::Ui) {
4129 ui.heading("Export");
4130 let mut ids: Vec<MeasureId> = self.model.measures.keys().copied().collect();
4131 ids.sort_by_key(|m| m.0);
4132 let selected_label = self
4133 .export_form
4134 .measure_id
4135 .and_then(|m| self.model.measures.get(&m))
4136 .map(|m| m.name.0.clone())
4137 .unwrap_or_else(|| "(choose a measure)".to_string());
4138 egui::ComboBox::from_label("measure")
4139 .selected_text(selected_label)
4140 .show_ui(ui, |ui| {
4141 for id in &ids {
4142 let name = self.model.measures[id].name.0.clone();
4143 ui.selectable_value(&mut self.export_form.measure_id, Some(*id), name);
4144 }
4145 });
4146 ui.horizontal(|ui| {
4147 ui.label("file");
4148 ui.text_edit_singleline(&mut self.export_form.path);
4149 });
4150 ui.checkbox(&mut self.export_form.tsv, "TSV");
4151 ui.separator();
4152 if ui.button("Export").clicked() {
4153 self.run_csv_export();
4154 }
4155 }
4156
4157 fn status_bar(&mut self, ctx: &egui::Context) {
4161 let now = ctx.input(|i| i.time);
4162 if self.status != self.status_shown {
4163 self.status_shown.clone_from(&self.status);
4164 self.status_at = now;
4165 }
4166 if !self.status.is_empty() {
4167 let age = now - self.status_at;
4168 if age >= STATUS_TTL {
4169 self.status.clear();
4170 self.status_shown.clear();
4171 } else {
4172 ctx.request_repaint_after(std::time::Duration::from_secs_f64(STATUS_TTL - age));
4173 }
4174 }
4175 egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
4176 ui.horizontal(|ui| {
4177 ui.label(&self.status);
4178 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
4179 ui.label(self.selection_readout())
4180 .on_hover_text("aggregate of the focused matrix's selection");
4181 });
4182 });
4183 });
4184 }
4185
4186 pub fn selection_readout(&self) -> String {
4196 match self.selection_sum() {
4197 Some(v) => format!("Sum {v}"),
4198 None => "Sum \u{2014}".to_string(),
4199 }
4200 }
4201
4202 pub fn selection_sum(&self) -> Option<f64> {
4206 let measure = self.selected()?;
4207 let nums: Vec<f64> = self
4208 .focused()
4209 .selection_keys(&self.model)
4210 .iter()
4211 .filter_map(|k| self.cell_value(measure, k).and_then(|v| v.as_num()))
4212 .collect();
4213 (!nums.is_empty()).then(|| nums.iter().sum())
4214 }
4215
4216 fn canvas_panel(&mut self, ctx: &egui::Context) -> egui::Rect {
4226 let ws = egui::CentralPanel::default()
4227 .frame(egui::Frame::none().fill(crate::theme::DARK_GRAY))
4228 .show(ctx, |ui| {
4229 if self.model.measures.is_empty() {
4230 ui.colored_label(
4231 crate::theme::WHITE,
4232 "No measures. Open a model store with `improv-gui <db>`.",
4233 );
4234 }
4235 ui.max_rect()
4236 })
4237 .inner;
4238 if self.model.measures.is_empty() {
4239 return ws;
4240 }
4241 crate::nxwin::make_key(ctx, matrix_window_id(self.focused().uid));
4245 let mut closed = Vec::new();
4246 for mi in 0..self.matrices.len() {
4247 if !self.matrix_window(ctx, ws, mi) {
4248 closed.push(mi);
4249 }
4250 }
4251 for mi in closed.into_iter().rev() {
4252 self.remove_matrix(mi);
4253 }
4254 ws
4255 }
4256
4257 fn matrix_window(&mut self, ctx: &egui::Context, ws: egui::Rect, mi: usize) -> bool {
4261 let Matrix { uid, rect: r, .. } = self.matrices[mi];
4262 let rect = egui::Rect::from_min_size(ws.min + egui::vec2(r.x, r.y), egui::vec2(r.w, r.h));
4263 let id = matrix_window_id(uid);
4264 let mut open = true;
4265 let closable = self.matrix_count() > 1;
4266 let mut win = crate::nxwin::NxWindow::new(("matrix", uid), self.window_title(mi))
4270 .rect(rect)
4271 .min_size(egui::vec2(MATRIX_MIN_W, MATRIX_MIN_H))
4272 .bounds(ws)
4273 .resizable(true);
4274 if closable {
4275 win = win.closable(&mut open);
4276 }
4277 let _ = win.show(ctx, |ui| {
4278 let outer_clip = ui.clip_rect();
4279 egui::TopBottomPanel::bottom(ui.id().with("formula_pane"))
4280 .resizable(true)
4281 .default_height(110.0)
4282 .max_height((ui.available_height() - GRID_MIN_H).max(0.0))
4284 .frame(egui::Frame::none().fill(crate::theme::WHITE))
4285 .show_separator_line(false)
4286 .show_inside(ui, |ui| {
4287 ui.shrink_clip_rect(outer_clip);
4288 self.formula_list(ui, mi);
4289 });
4290 self.gutter_frame(ui, mi);
4291 });
4292 if let Some(after) = crate::nxwin::stored_rect(ctx, id).filter(|a| *a != rect) {
4293 self.matrices[mi].rect = CanvasRect {
4294 x: after.min.x - ws.min.x,
4295 y: after.min.y - ws.min.y,
4296 w: after.width(),
4297 h: after.height(),
4298 };
4299 }
4300 if crate::nxwin::is_key(ctx, id) && self.focus_index() != mi {
4301 self.set_focus(mi);
4302 }
4303 open
4304 }
4305
4306 pub fn window_title(&self, mi: usize) -> String {
4309 format!(
4310 "{} - Worksheet - {}",
4311 self.matrix_title(mi),
4312 self.file_name()
4313 )
4314 }
4315
4316 fn sync_native_title(&mut self, ctx: &egui::Context) {
4319 if self.db.is_empty() {
4320 self.dirty = self.can_undo();
4321 }
4322 let t = self.native_title();
4323 if t != self.last_title {
4324 ctx.send_viewport_cmd(egui::ViewportCommand::Title(t.clone()));
4325 self.last_title = t;
4326 }
4327 }
4328
4329 fn native_title(&self) -> String {
4331 format!(
4332 "Improv — {}{}",
4333 self.file_name(),
4334 if self.dirty { " •" } else { "" }
4335 )
4336 }
4337
4338 fn file_name(&self) -> &str {
4340 if improv_store::is_postgres(&self.db) {
4341 return self
4343 .db
4344 .rsplit_once("model=")
4345 .map_or("Untitled", |(_, m)| m.split('&').next().unwrap_or(m));
4346 }
4347 std::path::Path::new(&self.db)
4348 .file_stem()
4349 .and_then(|s| s.to_str())
4350 .unwrap_or("Untitled")
4351 }
4352
4353 pub fn matrix_title(&self, mi: usize) -> String {
4357 let Some(m) = self.matrices.get(mi) else {
4358 return String::new();
4359 };
4360 let name = m
4361 .measure
4362 .and_then(|id| self.model.measures.get(&id))
4363 .map(|x| x.name.0.clone())
4364 .unwrap_or_else(|| "(no measure)".to_string());
4365 let slice: Vec<String> = m
4366 .pinned_pages(&self.model)
4367 .iter()
4368 .filter_map(|(_, it)| self.model.items.get(it).map(|x| x.name.0.clone()))
4369 .collect();
4370 if slice.is_empty() {
4371 name
4372 } else {
4373 format!("{name}: {}", slice.join(", "))
4374 }
4375 }
4376
4377 fn gutter_frame(&mut self, ui: &mut egui::Ui, mi: usize) {
4414 let Some(m) = self.matrices.get(mi) else {
4415 return;
4416 };
4417 let Some(measure) = m.measure else {
4418 ui.weak("(no measure)");
4419 return;
4420 };
4421 let uid = m.uid;
4422 let mut moves: Vec<(CategoryId, Axis)> = Vec::new();
4423 let mut freqs: Vec<FilterReq> = Vec::new();
4424 let col_stack = m.col_cats(&self.model);
4425 let row_stack = m.row_cats(&self.model);
4426 let pages: Vec<(CategoryId, Vec<String>, usize)> = m
4428 .page_cats(&self.model)
4429 .into_iter()
4430 .enumerate()
4431 .map(|(i, c)| {
4432 let items: Vec<String> = m
4433 .sorted_items(&self.model, c)
4434 .into_iter()
4435 .map(|(_, n)| n)
4436 .collect();
4437 let cur = m
4438 .page_idx
4439 .get(i)
4440 .copied()
4441 .unwrap_or(0)
4442 .min(items.len().saturating_sub(1));
4443 (c, items, cur)
4444 })
4445 .collect();
4446 let mut page_pick: Option<(usize, usize)> = None;
4447 let outer_clip = ui.clip_rect();
4453 let mut hbar = egui::Rect::NOTHING;
4454
4455 let well = egui::TopBottomPanel::bottom(ui.id().with("gutter_well"))
4458 .frame(egui::Frame::none())
4459 .show_separator_line(false)
4460 .show_inside(ui, |ui| {
4461 ui.shrink_clip_rect(outer_clip);
4462 Self::gutter_style().show(ui, |ui| {
4463 Self::gutter_drop_zone(ui, Axis::Rows, true, &mut moves, |ui, moves| {
4464 ui.horizontal(|ui| {
4465 for c in &row_stack {
4466 Self::tile(ui, &self.model, m, *c, Axis::Rows, moves, &mut freqs);
4467 }
4468 let h = ui.spacing().scroll.bar_width;
4472 hbar = ui
4473 .allocate_exact_size(
4474 egui::vec2(ui.available_width(), h),
4475 egui::Sense::hover(),
4476 )
4477 .0;
4478 });
4479 });
4480 });
4481 })
4482 .response
4483 .rect;
4484
4485 let top = egui::TopBottomPanel::top(ui.id().with("gutter_top"))
4488 .frame(egui::Frame::none())
4489 .show_separator_line(false)
4490 .show_inside(ui, |ui| {
4491 ui.shrink_clip_rect(outer_clip);
4492 Self::gutter_style().show(ui, |ui| {
4493 ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
4494 Self::gutter_drop_zone(ui, Axis::Pages, false, &mut moves, |ui, moves| {
4495 ui.horizontal_top(|ui| {
4496 for (i, (c, items, cur)) in pages.iter().enumerate() {
4497 ui.vertical(|ui| {
4498 Self::tile(
4499 ui,
4500 &self.model,
4501 m,
4502 *c,
4503 Axis::Pages,
4504 moves,
4505 &mut freqs,
4506 );
4507 if let Some(pick) =
4508 Self::page_field(ui, uid, *c, items, *cur)
4509 {
4510 page_pick = Some((i, pick));
4511 }
4512 });
4513 }
4514 });
4515 });
4516 Self::gutter_drop_zone(ui, Axis::Columns, true, &mut moves, |ui, moves| {
4517 ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
4518 for c in &col_stack {
4519 Self::tile(
4520 ui,
4521 &self.model,
4522 m,
4523 *c,
4524 Axis::Columns,
4525 moves,
4526 &mut freqs,
4527 );
4528 }
4529 });
4530 });
4531 });
4532 });
4533 })
4534 .response
4535 .rect;
4536
4537 let body = ui.available_rect_before_wrap();
4541 let left = egui::Rect::from_min_max(body.left_top(), body.left_bottom());
4542
4543 let field = ui.available_rect_before_wrap();
4546 ui.painter()
4547 .rect_filled(field, egui::Rounding::ZERO, crate::theme::WHITE);
4548 let mut hscroll = None;
4549 let table = egui::Frame::none()
4550 .show(ui, |ui| {
4551 hscroll = self.render_grid(ui, mi, measure);
4552 })
4553 .response
4554 .rect;
4555 if let Some(h) = hscroll.filter(|_| hbar.is_positive()) {
4556 crate::theme::hscroller_chrome(ui, &h, hbar, 20.0);
4557 }
4558
4559 let rects = GutterRects {
4560 top,
4561 left,
4562 table,
4563 well,
4564 hbar,
4565 };
4566 debug_assert!(
4567 !gutters_have_room(&rects) || gutters_frame_table(&rects),
4568 "matrix {mi}'s gutters must frame its table: {rects:?}"
4569 );
4570 if let Some(m) = self.matrices.get_mut(mi) {
4571 m.gutters = Some(rects);
4572 }
4573
4574 if let Some((dim, idx)) = page_pick {
4575 let model = &self.model;
4576 if let Some(m) = self.matrices.get_mut(mi) {
4577 m.set_page(model, dim, idx);
4578 }
4579 }
4580
4581 let model = &self.model;
4584 if let Some(m) = self.matrices.get_mut(mi) {
4585 for (c, axis) in moves {
4586 m.set_axis(model, c, axis);
4587 }
4588 for r in freqs {
4589 match r {
4590 FilterReq::Toggle(c, i) => m.toggle_filter_item(model, c, i),
4591 FilterReq::Clear(c) => m.clear_filter(model, c),
4592 }
4593 }
4594 }
4595 }
4596
4597 fn gutter_style() -> egui::Frame {
4599 egui::Frame::default()
4600 .fill(crate::theme::LIGHT_GRAY)
4601 .inner_margin(egui::Margin::same(2.0))
4602 .stroke(egui::Stroke::new(1.0_f32, crate::theme::DARK_GRAY))
4603 }
4604
4605 fn gutter_drop_zone(
4616 ui: &mut egui::Ui,
4617 axis: Axis,
4618 fill: bool,
4619 moves: &mut Vec<(CategoryId, Axis)>,
4620 contents: impl FnOnce(&mut egui::Ui, &mut Vec<(CategoryId, Axis)>),
4621 ) {
4622 let dragging = egui::DragAndDrop::has_payload_of_type::<CategoryId>(ui.ctx());
4623 let body = |ui: &mut egui::Ui, moves: &mut Vec<(CategoryId, Axis)>| {
4624 if fill {
4625 ui.set_min_width(ui.available_width());
4626 } else if dragging {
4627 ui.set_min_width(4.0 * TILE_H);
4628 }
4629 ui.set_min_height(TILE_H);
4630 contents(ui, moves);
4631 };
4632 if !dragging {
4633 ui.scope(|ui| body(ui, moves));
4634 return;
4635 }
4636 let (_, dropped) =
4637 ui.dnd_drop_zone::<CategoryId, ()>(egui::Frame::default(), |ui| body(ui, moves));
4638 if let Some(c) = dropped {
4639 moves.push((*c, axis));
4640 }
4641 }
4642
4643 fn page_field(
4646 ui: &mut egui::Ui,
4647 uid: u64,
4648 c: CategoryId,
4649 items: &[String],
4650 cur: usize,
4651 ) -> Option<usize> {
4652 let mut pick = None;
4653 ui.scope(|ui| {
4654 let w = &mut ui.visuals_mut().widgets;
4655 for v in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
4656 v.weak_bg_fill = crate::theme::WHITE;
4657 v.bg_fill = crate::theme::WHITE;
4658 }
4659 egui::ComboBox::from_id_salt(("page", uid, c.0))
4660 .selected_text(items.get(cur).cloned().unwrap_or_default())
4661 .show_ui(ui, |ui| {
4662 for (k, name) in items.iter().enumerate() {
4663 if ui.selectable_label(k == cur, name).clicked() {
4664 pick = Some(k);
4665 }
4666 }
4667 });
4668 });
4669 pick
4670 }
4671
4672 fn tile(
4680 ui: &mut egui::Ui,
4681 model: &Model,
4682 m: &Matrix,
4683 c: CategoryId,
4684 from: Axis,
4685 moves: &mut Vec<(CategoryId, Axis)>,
4686 freqs: &mut Vec<FilterReq>,
4687 ) {
4688 let uid = m.uid;
4689 let filter = m.filters.iter().find(|f| f.category == c);
4690 let cat = model.categories.get(&c);
4691 let mut name = cat.map_or_else(|| format!("category {}", c.0), |x| x.name.0.clone());
4692 if filter.is_some() {
4693 name.push('*');
4694 }
4695 let mut items: Vec<(ItemId, String)> = cat
4697 .map(|cat| {
4698 cat.items
4699 .iter()
4700 .filter_map(|id| model.items.get(id).map(|it| (*id, it.name.0.clone())))
4701 .collect()
4702 })
4703 .unwrap_or_default();
4704 items.sort_by_key(|(id, _)| id.0);
4705 let resp = ui
4708 .dnd_drag_source(egui::Id::new(("tile", uid, c.0)), c, |ui| {
4709 egui::Frame::default()
4710 .fill(crate::theme::DARK_GRAY)
4711 .stroke(egui::Stroke::new(1.0_f32, crate::theme::BLACK))
4712 .inner_margin(egui::Margin::symmetric(6.0, 1.0))
4713 .show(ui, |ui| {
4714 ui.set_height(TILE_H - 2.0);
4716 ui.add(
4717 egui::Label::new(egui::RichText::new(&name).color(crate::theme::WHITE))
4718 .selectable(false)
4719 .truncate(),
4720 );
4721 });
4722 })
4723 .response;
4724 resp.context_menu(|ui| {
4725 for to in [Axis::Rows, Axis::Columns, Axis::Pages] {
4726 if to != from && ui.button(format!("Move to {to:?}")).clicked() {
4727 moves.push((c, to));
4728 ui.close_menu();
4729 }
4730 }
4731 ui.separator();
4732 for (id, item) in &items {
4733 let mut shown = filter.is_none_or(|f| f.items.contains(id));
4734 if ui.checkbox(&mut shown, item).changed() {
4735 freqs.push(FilterReq::Toggle(c, *id));
4736 }
4737 }
4738 if ui.button("Show all").clicked() {
4739 freqs.push(FilterReq::Clear(c));
4740 ui.close_menu();
4741 }
4742 });
4743 }
4744
4745 fn handle_grid_keys(&mut self, ui: &egui::Ui, mi: usize) {
4760 if mi != self.focus_index() {
4761 return;
4762 }
4763 let other_focus = ui.ctx().memory(|m| m.focused()).is_some();
4769 if !grid_keys_enabled(self.focused().editing.is_some(), other_focus) {
4770 return;
4771 }
4772 use egui::Key;
4773 let k = |key: Key| ui.input(|i| i.key_pressed(key));
4774 let modifiers = ui.input(|i| i.modifiers);
4775
4776 if modifiers.command {
4782 return;
4783 }
4784
4785 if k(Key::ArrowUp) || k(Key::K) {
4786 self.with_focused(|m, model| m.step(model, -1, 0, modifiers.shift));
4787 }
4788 if k(Key::ArrowDown) || k(Key::J) {
4789 self.with_focused(|m, model| m.step(model, 1, 0, modifiers.shift));
4790 }
4791 if k(Key::ArrowLeft) || k(Key::H) {
4792 self.with_focused(|m, model| m.step(model, 0, -1, modifiers.shift));
4793 }
4794 if k(Key::ArrowRight) || k(Key::L) {
4795 self.with_focused(|m, model| m.step(model, 0, 1, modifiers.shift));
4796 }
4797 if k(Key::Enter) || k(Key::F2) {
4798 self.begin_edit_cursor();
4799 }
4800 if k(Key::CloseBracket) || k(Key::PageDown) {
4802 self.page_first(1);
4803 }
4804 if k(Key::OpenBracket) || k(Key::PageUp) {
4805 self.page_first(-1);
4806 }
4807 if k(Key::N) {
4809 let shift = ui.input(|i| i.modifiers.shift);
4810 self.cycle_measure(if shift { -1 } else { 1 });
4811 }
4812 }
4813
4814 fn page_first(&mut self, delta: isize) {
4816 self.with_focused(|m, model| m.page_first(model, delta));
4817 }
4818
4819 fn cycle_measure(&mut self, delta: isize) {
4821 let mut ids: Vec<MeasureId> = self.model.measures.keys().copied().collect();
4822 if ids.is_empty() {
4823 return;
4824 }
4825 ids.sort_by_key(|m| m.0);
4826 let cur = self
4827 .selected()
4828 .and_then(|s| ids.iter().position(|m| *m == s))
4829 .unwrap_or(0);
4830 let next = (cur as isize + delta).rem_euclid(ids.len() as isize) as usize;
4831 self.focused_mut().select(Some(ids[next]));
4832 }
4833
4834 fn render_grid(
4843 &mut self,
4844 ui: &mut egui::Ui,
4845 mi: usize,
4846 measure: MeasureId,
4847 ) -> Option<GridScroll> {
4848 self.handle_grid_keys(ui, mi);
4849 let m = self.matrices.get(mi)?;
4850 let (sel_rows, sel_cols) = m.selection();
4851 let shift = ui.input(|i| i.modifiers.shift);
4852 let editing = m.editing.clone();
4853 let mut edit_buf = m.edit_buf.clone();
4854 let uid = m.uid;
4855 let is_derived = self
4856 .model
4857 .measures
4858 .get(&measure)
4859 .map(|m| m.is_derived())
4860 .unwrap_or(false);
4861 let is_number = self
4862 .model
4863 .measures
4864 .get(&measure)
4865 .is_some_and(|m| m.value_type == ValueType::Number);
4866
4867 let row_cats = m.row_cats(&self.model);
4874 let col_cats = m.col_cats(&self.model);
4875 if let Some(m) = self.matrices.get_mut(mi) {
4877 m.rendered_axes = Some((row_cats.clone(), col_cats.clone()));
4878 }
4879 let m = self.matrices.get(mi)?;
4880 let pinned = m.pinned_pages_opt(&self.model);
4883 let pages_ok = pinned.is_some();
4884 let pinned = pinned.unwrap_or_default();
4885 let row_lists = m.axis_item_lists(&self.model, &row_cats);
4886 let total_rows = if pages_ok { product_len(&row_lists) } else { 0 };
4892 let col_lines = if pages_ok {
4896 m.axis_tuples(&self.model, &col_cats)
4897 } else {
4898 Vec::new()
4899 };
4900 let n_row_stub = row_cats.len().max(1); let n_col_hdr = col_cats.len().max(1); let row_tuple_at = |lists: &[Vec<(ItemId, String)>], i: usize| -> Vec<(ItemId, String)> {
4906 if lists.is_empty() {
4907 Vec::new()
4908 } else {
4909 nth_tuple(lists, i)
4910 }
4911 };
4912
4913 let mut commit: Option<(CoordKey, String)> = None;
4916 let mut clicked_derived = false;
4917 let mut cancel = false;
4918 let mut clicked_cell: Option<(usize, usize)> = None;
4920 let mut begin_edit: Option<(CoordKey, String)> = None;
4921 let mut drag_from: Option<(usize, usize)> = None;
4924 let mut dragging = false;
4925 let mut cell_rects: Vec<((usize, usize), egui::Rect)> = Vec::new();
4926 let formula_tip = if is_derived {
4927 self.formula_source(measure)
4928 } else {
4929 None
4930 };
4931
4932 const RULE: egui::Stroke = egui::Stroke {
4939 width: 1.0,
4940 color: crate::theme::BLACK,
4941 };
4942 fn header_cell(ui: &mut egui::Ui, text: &str, selected: bool) {
4943 let r = ui.available_rect_before_wrap();
4944 if selected {
4945 ui.painter().rect_filled(r, 0.0, crate::theme::LIGHT_GRAY);
4946 }
4947 ui.painter().vline(r.right() - 0.5, r.y_range(), RULE);
4948 if !text.is_empty() {
4949 ui.painter().hline(r.x_range(), r.top() + 0.5, RULE);
4950 }
4951 egui::Frame::none()
4952 .inner_margin(egui::Margin::symmetric(4.0, 1.0))
4953 .show(ui, |ui| {
4954 ui.add(egui::Label::new(text).truncate());
4955 });
4956 }
4957 fn band_bottom(ui: &egui::Ui) {
4959 let r = ui.max_rect();
4960 ui.painter().hline(r.x_range(), r.bottom() - 0.5, RULE);
4961 }
4962 let cat_name = |app: &ImprovApp, c: CategoryId| {
4963 app.model
4964 .categories
4965 .get(&c)
4966 .map(|x| x.name.0.clone())
4967 .unwrap_or_default()
4968 };
4969
4970 use egui_extras::{Column, TableBuilder};
4971 let spacing = ui.spacing().item_spacing;
4976 ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
4977 ui.visuals_mut().extreme_bg_color = crate::theme::TROUGH;
4979 let hscroll = egui::ScrollArea::horizontal()
4983 .id_salt(("hgrid", uid))
4984 .drag_to_scroll(false)
4985 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
4986 .show(ui, |ui| {
4987 let mut table = TableBuilder::new(ui)
4988 .id_salt(("grid", uid))
4989 .striped(false)
4990 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
4991 for _ in 0..n_row_stub {
4993 table = table.column(Column::auto().resizable(true));
4994 }
4995 for _ in &col_lines {
4996 table = table.column(Column::auto().resizable(true));
4997 }
4998
4999 let body = table
5000 .header(22.0 * n_col_hdr as f32, |mut header| {
5001 for (si, rc) in row_cats.iter().enumerate() {
5006 let _ = si;
5007 header.col(|ui| {
5008 header_cell(ui, &cat_name(self, *rc), false);
5009 band_bottom(ui);
5010 });
5011 }
5012 if row_cats.is_empty() {
5013 header.col(|ui| {
5014 header_cell(ui, "", false);
5015 band_bottom(ui);
5016 });
5017 }
5018 for (ci, line) in col_lines.iter().enumerate() {
5019 let selected = sel_cols.contains(&ci);
5020 header.col(|ui| {
5021 ui.vertical(|ui| {
5022 if line.is_empty() {
5023 header_cell(ui, "", selected);
5024 }
5025 for (it, name) in line {
5026 let _ = it;
5027 header_cell(ui, name, selected);
5028 }
5029 });
5030 band_bottom(ui);
5031 });
5032 }
5033 })
5034 .body(|body| {
5035 body.rows(20.0, total_rows, |mut row| {
5041 let ri = row.index();
5042 let row_line = row_tuple_at(&row_lists, ri);
5043 let prev_line = if ri > 0 {
5044 Some(row_tuple_at(&row_lists, ri - 1))
5045 } else {
5046 None
5047 };
5048 let row_selected = sel_rows.contains(&ri);
5050 for si in 0..n_row_stub {
5051 let cell = row_line.get(si);
5052 row.col(|ui| {
5054 let centered = egui::Layout::left_to_right(egui::Align::Center);
5055 ui.with_layout(centered, |ui| match cell {
5056 Some((_id, name)) => {
5057 let inner = si + 1 == n_row_stub;
5062 let changed = match &prev_line {
5063 None => true,
5064 Some(p) => (0..=si).any(|k| {
5065 row_line.get(k).map(|(id, _)| id)
5066 != p.get(k).map(|(id, _)| id)
5067 }),
5068 };
5069 if inner || changed {
5070 header_cell(ui, name, row_selected);
5071 } else {
5072 header_cell(ui, "", row_selected);
5073 }
5074 }
5075 None => header_cell(ui, "", row_selected),
5076 });
5077 });
5078 }
5079 for (ci, col_line) in col_lines.iter().enumerate() {
5080 let key = cell_key_multi(
5081 &row_cats, &row_line, &col_cats, col_line, &pinned,
5082 );
5083 let selected = sel_rows.contains(&ri) && sel_cols.contains(&ci);
5084 let editing_here =
5085 editing.as_ref() == Some(&(measure, key.clone()));
5086 row.col(|ui| {
5087 let r = ui.max_rect();
5090 let fill = if selected {
5091 crate::theme::LIGHT_GRAY
5092 } else {
5093 crate::theme::WHITE
5094 };
5095 ui.painter().rect_filled(r, 0.0, fill);
5096 ui.painter().vline(r.right() - 0.5, r.y_range(), RULE);
5097 ui.painter().hline(r.x_range(), r.bottom() - 0.5, RULE);
5098 cell_rects.push(((ri, ci), r));
5099 let cell_id = egui::Id::new(("cell", uid, ri, ci));
5100 let mut sense_cell = |ui: &mut egui::Ui| {
5101 let resp =
5102 ui.interact(r, cell_id, egui::Sense::click_and_drag());
5103 if resp.drag_started() {
5104 drag_from = Some((ri, ci));
5105 }
5106 dragging |= resp.dragged();
5107 resp
5108 };
5109 egui::Frame::none()
5110 .inner_margin(egui::Margin::symmetric(4.0, 0.0))
5111 .show(ui, |ui| {
5112 let value = self.cell_value(measure, &key);
5115 let numeric = matches!(value, Some(CellValue::Num(_)));
5116 let right = numeric || (editing_here && is_number);
5121 let align = if right && !ui.is_sizing_pass() {
5122 egui::Layout::right_to_left(egui::Align::Center)
5123 } else {
5124 egui::Layout::left_to_right(egui::Align::Center)
5125 };
5126 let text =
5127 value.map(|v| v.to_string()).unwrap_or_default();
5128 ui.with_layout(align, |ui| {
5129 if is_derived {
5130 ui.add(
5131 egui::Label::new(text).selectable(false),
5132 );
5133 let mut resp = sense_cell(ui);
5134 if let Some(src) = &formula_tip {
5135 resp = resp.on_hover_text(format!(
5136 "formula: {src}"
5137 ));
5138 }
5139 if resp.clicked() {
5140 clicked_cell = Some((ri, ci));
5141 clicked_derived = true;
5142 }
5143 } else if editing_here {
5144 crate::theme::content(ui);
5145 let resp = ui.add(
5146 egui::TextEdit::singleline(&mut edit_buf)
5147 .horizontal_align(if is_number {
5148 egui::Align::Max
5149 } else {
5150 egui::Align::Min
5151 })
5152 .desired_width(f32::INFINITY),
5153 );
5154 resp.request_focus();
5155 let enter = ui
5156 .input(|i| i.key_pressed(egui::Key::Enter));
5157 let esc = ui.input(|i| {
5158 i.key_pressed(egui::Key::Escape)
5159 });
5160 if esc {
5161 cancel = true;
5162 } else if resp.lost_focus() || enter {
5163 commit =
5164 Some((key.clone(), edit_buf.clone()));
5165 }
5166 } else {
5167 ui.add(
5173 egui::Label::new(text.clone())
5174 .selectable(false),
5175 );
5176 let resp = sense_cell(ui);
5177 if resp.clicked() || resp.double_clicked() {
5178 clicked_cell = Some((ri, ci));
5179 }
5180 if resp.double_clicked() {
5181 begin_edit = Some((key.clone(), text));
5182 }
5183 ui.painter().hline(
5185 r.left() + 2.0..=r.right() - 2.0,
5186 r.bottom() - 2.0,
5187 egui::Stroke::new(
5188 1.0_f32,
5189 crate::theme::LIGHT_GRAY,
5190 ),
5191 );
5192 }
5193 });
5194 });
5195 });
5196 }
5197 });
5198 });
5199
5200 let size = body.content_size.min(body.inner_rect.size())
5203 + egui::vec2(ui.spacing().scroll.bar_width, 0.0);
5204 let outer = egui::Rect::from_min_size(body.inner_rect.min, size);
5205 (body, outer)
5206 });
5207 let (body, outer) = &hscroll.inner;
5210 let outer = outer.with_max_x(outer.max.x.min(hscroll.inner_rect.max.x));
5211 crate::theme::scroller_chrome(ui, body, outer, 20.0);
5212 ui.spacing_mut().item_spacing = spacing;
5213 crate::theme::content(ui);
5214
5215 if let Some(m) = self.matrices.get_mut(mi) {
5219 m.edit_buf = edit_buf;
5220 if let Some((ri, ci)) = clicked_cell {
5221 m.anchor = shift.then(|| m.anchor.unwrap_or((m.cursor_row, m.cursor_col)));
5223 m.cursor_row = ri;
5224 m.cursor_col = ci;
5225 }
5226 if let Some(start) = drag_from {
5227 m.anchor = Some(start);
5228 (m.cursor_row, m.cursor_col) = start;
5229 }
5230 if dragging {
5231 let p = ui.ctx().pointer_latest_pos();
5232 if let Some((at, _)) = cell_rects
5233 .iter()
5234 .find(|(_, r)| p.is_some_and(|p| r.contains(p)))
5235 {
5236 (m.cursor_row, m.cursor_col) = *at;
5237 }
5238 }
5239 if let Some((key, text)) = begin_edit {
5240 m.editing = Some((measure, key));
5241 m.edit_buf = text;
5242 }
5243 if cancel || commit.is_some() {
5244 m.editing = None;
5245 }
5246 }
5247 if clicked_cell.is_some() {
5248 self.set_focus(mi);
5249 }
5250 if cancel {
5251 self.status = "edit cancelled".into();
5252 }
5253 if clicked_derived {
5254 self.status = "derived cells are computed, not editable".into();
5255 }
5256 if let Some((key, text)) = commit {
5257 self.status = match self.commit_cell_text(measure, key, &text) {
5258 Ok(msg) => msg,
5259 Err(e) => format!("edit error: {e}"),
5260 };
5261 }
5262 Some(hscroll)
5263 }
5264}
5265
5266fn cell_key_multi(
5271 row_cats: &[CategoryId],
5272 row_tuple: &[(ItemId, String)],
5273 col_cats: &[CategoryId],
5274 col_tuple: &[(ItemId, String)],
5275 pinned: &[(CategoryId, ItemId)],
5276) -> CoordKey {
5277 let mut pairs: Vec<(u32, u32)> = Vec::new();
5278 for (c, (it, _)) in row_cats.iter().zip(row_tuple.iter()) {
5279 pairs.push((c.0, it.0));
5280 }
5281 for (c, (it, _)) in col_cats.iter().zip(col_tuple.iter()) {
5282 pairs.push((c.0, it.0));
5283 }
5284 for (c, i) in pinned {
5285 pairs.push((c.0, i.0));
5286 }
5287 pairs.sort();
5288 pairs
5289}
5290
5291fn product_len(lists: &[Vec<(ItemId, String)>]) -> usize {
5296 lists.iter().map(|l| l.len()).product()
5297}
5298
5299fn nth_tuple(lists: &[Vec<(ItemId, String)>], mut i: usize) -> Vec<(ItemId, String)> {
5318 let mut out: Vec<(ItemId, String)> = vec![(ItemId(0), String::new()); lists.len()];
5319 for (d, list) in lists.iter().enumerate().rev() {
5320 let radix = list.len();
5321 assert!(radix > 0, "empty category has no lines");
5322 out[d] = list[i % radix].clone();
5323 i /= radix;
5324 }
5325 out
5326}
5327
5328#[cfg(test)]
5329#[cfg_attr(coverage_nightly, coverage(off))]
5330mod tests {
5331 #[test]
5336 fn literal_glyphs_are_in_default_fonts() {
5337 let ctx = egui::Context::default();
5338 crate::theme::install_fonts(&ctx);
5339 let _ = ctx.run(egui::RawInput::default(), |_| {});
5340 let prop = egui::FontId::proportional(14.0);
5341 ctx.fonts(|f| {
5342 assert!(f.has_glyphs(&prop, "⚠"), "⚠ is tofu");
5343 });
5344 }
5345
5346 use super::*;
5347 use improv_core_model::{
5348 BinaryOp, DimensionSpec, Expr, Formula, Measure, MeasureKind, Name, ValueType,
5349 };
5350
5351 fn revenue_model() -> Model {
5352 let mut m = Model::new();
5353 let (t, p) = (CategoryId(1), CategoryId(2));
5354 m.add_category(t, "Time");
5355 m.add_category(p, "Product");
5356 m.add_item(ItemId(10), t, "2025");
5357 m.add_item(ItemId(20), p, "WidgetA");
5358 m.add_measure(Measure {
5359 id: MeasureId(100),
5360 name: Name("Price".into()),
5361 value_type: ValueType::Number,
5362 categories: vec![p],
5363 kind: MeasureKind::Input,
5364 description: None,
5365 });
5366 m.add_measure(Measure {
5367 id: MeasureId(101),
5368 name: Name("Quantity".into()),
5369 value_type: ValueType::Number,
5370 categories: vec![t, p],
5371 kind: MeasureKind::Input,
5372 description: None,
5373 });
5374 m.add_measure(Measure {
5375 id: MeasureId(102),
5376 name: Name("Revenue".into()),
5377 value_type: ValueType::Number,
5378 categories: vec![t, p],
5379 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
5380 BinaryOp::Mul,
5381 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
5382 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
5383 ))),
5384 description: None,
5385 });
5386 let c = |pairs: &[(CategoryId, ItemId)]| {
5387 improv_core_model::Coordinate::from_pairs(pairs.iter().copied())
5388 };
5389 m.set_input(MeasureId(100), c(&[(p, ItemId(20))]), Value::Number(10.0));
5390 m.set_input(
5391 MeasureId(101),
5392 c(&[(t, ItemId(10)), (p, ItemId(20))]),
5393 Value::Number(7.0),
5394 );
5395 m
5396 }
5397
5398 fn inputs_only_model() -> Model {
5401 let mut m = revenue_model();
5402 m.measures.remove(&MeasureId(102));
5403 m
5404 }
5405
5406 #[test]
5407 fn loads_and_computes_derived() {
5408 let app = build_app(revenue_model());
5409 assert_eq!(app.selected(), Some(MeasureId(102)));
5411 let vals = app.values_for(MeasureId(102));
5412 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5413 key.sort();
5414 assert_eq!(vals.get(&key), Some(&70.0)); }
5416
5417 #[test]
5418 fn editing_recomputes_derived() {
5419 let mut app = build_app(revenue_model());
5420 let mut qkey = vec![(1u32, 10u32), (2u32, 20u32)];
5422 qkey.sort();
5423 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(9.0))
5424 .unwrap();
5425 let rev = app.values_for(MeasureId(102));
5426 assert_eq!(rev.get(&qkey), Some(&90.0));
5427 }
5428
5429 #[test]
5430 fn editing_derived_is_rejected() {
5431 let mut app = build_app(revenue_model());
5432 let key = vec![(1u32, 10u32), (2u32, 20u32)];
5433 assert!(app
5434 .set_cell(MeasureId(102), key, Value::Number(1.0))
5435 .is_err());
5436 }
5437
5438 #[test]
5439 fn commit_formula_creates_and_recomputes() {
5440 let mut app = build_app(inputs_only_model());
5443 let rev = app
5445 .add_derived_measure("Revenue", "Price * Quantity")
5446 .expect("add derived");
5447 let vals = app.values_for(rev);
5448 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5449 key.sort();
5450 assert_eq!(vals.get(&key), Some(&70.0)); let m = &app.model.measures[&rev];
5453 assert!(m.is_derived());
5454 assert_eq!(m.categories, vec![CategoryId(1), CategoryId(2)]);
5455 }
5456
5457 #[test]
5458 fn commit_formula_updates_existing_derived() {
5459 let mut app = build_app(revenue_model());
5460 app.commit_formula(MeasureId(102), "Price + Quantity")
5462 .expect("commit");
5463 let vals = app.values_for(MeasureId(102));
5464 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5465 key.sort();
5466 assert_eq!(vals.get(&key), Some(&17.0));
5467 }
5468
5469 #[test]
5470 fn bad_formula_leaves_model_unchanged() {
5471 let mut app = build_app(revenue_model());
5472 let before = app.model.clone();
5473 let err = app.commit_formula(MeasureId(102), "Price * Widgets");
5474 assert!(err.is_err());
5475 assert_eq!(app.model, before, "model unchanged on parse error");
5476 let vals = app.values_for(MeasureId(102));
5478 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5479 key.sort();
5480 assert_eq!(vals.get(&key), Some(&70.0));
5481 }
5482
5483 #[test]
5484 fn commit_formula_error_sets_and_clears_error_pos() {
5485 let mut app = build_app(revenue_model());
5486 assert_eq!(app.formula_error, None);
5487
5488 let text = "Price * Widgets";
5490 let err = app.commit_formula(MeasureId(102), text).unwrap_err();
5491 let d = app.formula_error.clone().expect("located");
5492 assert_eq!(d.span, Some(8..15));
5493 assert_eq!(d.message, "unknown measure: Widgets");
5494 assert!(err.contains("Widgets") || err.contains("measure"));
5495
5496 app.commit_formula(MeasureId(102), "Quantity + LEN(Price)")
5498 .unwrap_err();
5499 let d = app.formula_error.clone().expect("located");
5500 assert_eq!(d.span, Some(11..14), "{d:?}");
5501 assert!(d.message.starts_with("type mismatch"), "{}", d.message);
5502
5503 app.commit_formula(MeasureId(102), "Price + Quantity")
5505 .expect("commit");
5506 assert_eq!(app.formula_error, None);
5507 }
5508
5509 #[test]
5510 fn bad_add_derived_leaves_model_unchanged() {
5511 let mut app = build_app(revenue_model());
5512 let before = app.model.clone();
5513 assert!(app
5514 .add_derived_measure("Junk", "does not parse [[")
5515 .is_err());
5516 assert!(app.add_derived_measure("Revenue", "Price").is_err()); assert_eq!(app.model, before);
5518 }
5519
5520 fn displayable_formulas() -> Vec<(&'static str, Formula)> {
5527 let (t, p) = (CategoryId(1), CategoryId(2));
5528 let price = || Expr::Ref(MeasureId(100), DimensionSpec::default());
5529 let qty = || Expr::Ref(MeasureId(101), DimensionSpec::default());
5530 let bin = |op, l: Expr, r: Expr| Expr::BinaryOp(op, Box::new(l), Box::new(r));
5531 vec![
5532 (
5533 "binary op",
5534 Formula::new(bin(BinaryOp::Mul, price(), qty())),
5535 ),
5536 (
5537 "nested binary ops needing parens",
5538 Formula::new(bin(
5539 BinaryOp::Mul,
5540 bin(BinaryOp::Add, price(), qty()),
5541 bin(BinaryOp::Sub, qty(), Expr::Literal(Value::Number(2.0))),
5542 )),
5543 ),
5544 (
5545 "right-nested same-precedence",
5546 Formula::new(bin(
5547 BinaryOp::Sub,
5548 price(),
5549 bin(BinaryOp::Sub, qty(), Expr::Literal(Value::Number(1.0))),
5550 )),
5551 ),
5552 (
5553 "aggregation with OVER",
5554 Formula::new(Expr::Call(
5555 FuncId(1), vec![Expr::Ref(
5557 MeasureId(101),
5558 DimensionSpec {
5559 by: vec![],
5560 over: vec![t],
5561 except: vec![],
5562 },
5563 )],
5564 )),
5565 ),
5566 (
5567 "scalar call",
5568 Formula::new(Expr::Call(FuncId(10), vec![price()])), ),
5570 (
5571 "two-arg scalar call",
5572 Formula::new(Expr::Call(FuncId(20), vec![price(), qty()])), ),
5574 (
5575 "comparison",
5576 Formula::new(bin(BinaryOp::Gt, price(), qty())),
5577 ),
5578 (
5579 "logical over comparisons",
5580 Formula::new(bin(
5581 BinaryOp::And,
5582 bin(BinaryOp::Gt, price(), qty()),
5583 Expr::UnaryOp(
5584 UnaryOp::Not,
5585 Box::new(bin(BinaryOp::Eq, qty(), Expr::Literal(Value::Number(0.0)))),
5586 ),
5587 )),
5588 ),
5589 (
5590 "power and negation",
5592 Formula::new(bin(
5593 BinaryOp::Add,
5594 Expr::UnaryOp(UnaryOp::Neg, Box::new(bin(BinaryOp::Pow, price(), qty()))),
5595 bin(
5596 BinaryOp::Pow,
5597 Expr::UnaryOp(UnaryOp::Neg, Box::new(price())),
5598 bin(BinaryOp::Pow, qty(), price()),
5599 ),
5600 )),
5601 ),
5602 (
5603 "left-nested power",
5604 Formula::new(bin(
5605 BinaryOp::Pow,
5606 bin(BinaryOp::Pow, price(), qty()),
5607 price(),
5608 )),
5609 ),
5610 (
5611 "concatenation is text",
5612 Formula::new(bin(
5613 BinaryOp::Eq,
5614 bin(
5615 BinaryOp::Concat,
5616 bin(BinaryOp::Add, price(), qty()),
5617 price(),
5618 ),
5619 Expr::Literal(Value::Text("x".into())),
5620 )),
5621 ),
5622 (
5623 "a compared NOT",
5626 Formula::new(bin(
5627 BinaryOp::Eq,
5628 Expr::UnaryOp(UnaryOp::Not, Box::new(bin(BinaryOp::Gt, price(), qty()))),
5629 Expr::Literal(Value::Boolean(false)),
5630 )),
5631 ),
5632 (
5633 "NOT of an AND",
5634 Formula::new(Expr::UnaryOp(
5635 UnaryOp::Not,
5636 Box::new(bin(
5637 BinaryOp::And,
5638 bin(BinaryOp::Gt, price(), qty()),
5639 bin(BinaryOp::Lt, price(), qty()),
5640 )),
5641 )),
5642 ),
5643 (
5644 "unary negation of a group",
5645 Formula::new(Expr::UnaryOp(
5646 UnaryOp::Neg,
5647 Box::new(bin(BinaryOp::Add, price(), qty())),
5648 )),
5649 ),
5650 (
5651 "dimension-projected ref",
5652 Formula::new(bin(
5653 BinaryOp::Mul,
5654 Expr::Ref(
5655 MeasureId(101),
5656 DimensionSpec {
5657 by: vec![t, p],
5658 over: vec![],
5659 except: vec![],
5660 },
5661 ),
5662 price(),
5663 )),
5664 ),
5665 (
5666 "date literals",
5667 Formula::new(bin(
5668 BinaryOp::Gt,
5669 parser::parse_expr(&revenue_model(), "#2025-01-01#")
5671 .expect("date literal parses")
5672 .expr,
5673 parser::parse_expr(&revenue_model(), "#2024-06-30T12:00:00Z#")
5674 .expect("timestamp literal parses")
5675 .expr,
5676 )),
5677 ),
5678 (
5679 "boolean and text literals",
5680 Formula::new(bin(
5681 BinaryOp::Or,
5682 Expr::Literal(Value::Boolean(true)),
5683 bin(
5684 BinaryOp::Eq,
5685 Expr::Literal(Value::Text("hello world".into())),
5686 Expr::Literal(Value::Text("x".into())),
5687 ),
5688 )),
5689 ),
5690 ]
5691 }
5692
5693 #[test]
5696 fn displayed_formula_commits_unchanged() {
5697 for (label, formula) in displayable_formulas() {
5698 let mut model = revenue_model();
5699 model.measures.get_mut(&MeasureId(102)).unwrap().kind =
5700 MeasureKind::Derived(formula.clone());
5701 let mut app = build_app(model);
5702 select(&mut app, MeasureId(102));
5703
5704 let buf = app
5706 .formula_source(MeasureId(102))
5707 .unwrap_or_else(|| panic!("{label}: nothing displayed"));
5708 assert!(!buf.is_empty(), "{label}: nothing displayed");
5709
5710 let before = app.snapshot.clone();
5711 app.commit_formula(MeasureId(102), &buf)
5712 .unwrap_or_else(|e| panic!("{label}: displayed {buf:?} did not commit: {e}"));
5713
5714 let after = match &app.model.measures[&MeasureId(102)].kind {
5715 MeasureKind::Derived(f) => f.clone(),
5716 MeasureKind::Input => panic!("{label}: measure stopped being derived"),
5717 };
5718 assert_eq!(
5719 after, formula,
5720 "{label}: AST changed by round-trip ({buf:?})"
5721 );
5722 assert!(app.engine.is_some(), "{label}: engine lost");
5723 assert_eq!(app.snapshot, before, "{label}: snapshot changed ({buf:?})");
5724 }
5725 }
5726
5727 #[test]
5728 fn displayed_dsl_is_the_symbolic_language_not_prose() {
5729 let app = build_app(revenue_model());
5731 assert_eq!(
5732 app.formula_source(MeasureId(102)).as_deref(),
5733 Some("Price * Quantity")
5734 );
5735 assert_eq!(app.formula_source(MeasureId(100)), None);
5737 assert_eq!(app.formula_source(MeasureId(999)), None);
5738 }
5739
5740 #[test]
5741 fn shapes_without_a_dsl_spelling_fall_back_to_cnl_and_still_commit() {
5742 let mut model = revenue_model();
5745 let f = Formula::new(Expr::Call(
5746 FuncId(1),
5747 vec![Expr::Ref(
5748 MeasureId(101),
5749 DimensionSpec {
5750 by: vec![],
5751 over: vec![CategoryId(1), CategoryId(2)],
5752 except: vec![],
5753 },
5754 )],
5755 ));
5756 model.measures.get_mut(&MeasureId(102)).unwrap().kind = MeasureKind::Derived(f.clone());
5757 let mut app = build_app(model);
5758
5759 let buf = app.formula_source(MeasureId(102)).expect("displayable");
5760 assert_eq!(buf, "SUM(Quantity OVER Time, Product)");
5761 app.commit_formula(MeasureId(102), &buf).expect("commits");
5762 match &app.model.measures[&MeasureId(102)].kind {
5763 MeasureKind::Derived(got) => assert_eq!(*got, f),
5764 MeasureKind::Input => panic!("stopped being derived"),
5765 }
5766 }
5767
5768 #[test]
5769 fn dsl_printer_refuses_inexpressible_shapes() {
5770 let model = revenue_model();
5771 let bare_over = Formula::new(Expr::Ref(
5774 MeasureId(100),
5775 DimensionSpec {
5776 by: vec![],
5777 over: vec![CategoryId(1)],
5778 except: vec![],
5779 },
5780 ));
5781 assert_eq!(formula_dsl(&model, &bare_over), None);
5782 let excepted = Formula::new(Expr::Ref(
5783 MeasureId(100),
5784 DimensionSpec {
5785 by: vec![],
5786 over: vec![],
5787 except: vec![CategoryId(1)],
5788 },
5789 ));
5790 assert_eq!(formula_dsl(&model, &excepted), None);
5791 let unknown_fn = Formula::new(Expr::Call(
5792 FuncId(4242),
5793 vec![Expr::Ref(MeasureId(100), DimensionSpec::default())],
5794 ));
5795 assert_eq!(formula_dsl(&model, &unknown_fn), None);
5796 assert_eq!(
5797 formula_dsl(&model, &Formula::new(Expr::Literal(Value::Enum(3)))),
5798 None
5799 );
5800 assert_eq!(
5802 formula_dsl(&model, &Formula::new(Expr::Literal(Value::Number(-1.0)))),
5803 None
5804 );
5805 }
5806
5807 #[test]
5810 fn cell_edit_and_formula_commit_report_save_failure() {
5811 let db = std::env::temp_dir()
5813 .join(format!("improv_gui_no_such_dir_{}", std::process::id()))
5814 .join("model.db")
5815 .to_string_lossy()
5816 .into_owned();
5817 let mut app = build_app(revenue_model());
5818 app.db = db;
5819
5820 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5821 key.sort();
5822 let err = app
5823 .set_cell(MeasureId(101), key, Value::Number(9.0))
5824 .expect_err("unwritable store must fail the edit");
5825 assert!(err.starts_with("save failed:"), "got {err:?}");
5826
5827 let err = app
5828 .commit_formula(MeasureId(102), "Price + Quantity")
5829 .expect_err("unwritable store must fail the commit");
5830 assert!(err.starts_with("save failed:"), "got {err:?}");
5831
5832 let err = app
5833 .add_derived_measure("Margin", "Price - Quantity")
5834 .expect_err("unwritable store must fail the add");
5835 assert!(err.starts_with("save failed:"), "got {err:?}");
5836
5837 assert_eq!(app.save_view("L1"), None);
5839 assert!(app.status.starts_with("save failed:"), "{}", app.status);
5840 assert!(app.model.views.is_empty(), "view rolled back");
5841
5842 assert_eq!(
5845 app.formula_source(MeasureId(102)).as_deref(),
5846 Some("Price * Quantity")
5847 );
5848 assert!(app.model.measure_by_name("Margin").is_none());
5849 }
5850
5851 #[test]
5852 fn formula_that_fails_to_build_is_not_published() {
5853 let mut app = build_app(revenue_model());
5854 let before_model = app.model.clone();
5855 let before_snapshot = app.snapshot.clone();
5856
5857 let err = app
5860 .commit_formula(MeasureId(102), "Revenue + Price")
5861 .expect_err("a self-referencing formula must be rejected");
5862 assert!(err.contains("refers to itself"), "got {err:?}");
5863
5864 assert_eq!(app.model, before_model, "model preserved");
5865 assert_eq!(app.snapshot, before_snapshot, "snapshot preserved");
5866 assert!(app.engine.is_some(), "engine preserved");
5867 assert_eq!(
5868 app.formula_source(MeasureId(102)).as_deref(),
5869 Some("Price * Quantity")
5870 );
5871 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
5872 key.sort();
5873 assert_eq!(app.values_for(MeasureId(102)).get(&key), Some(&70.0));
5874
5875 let err = app
5878 .commit_formula(MeasureId(102), "(Price > Quantity) * Price")
5879 .expect_err("a type error must be rejected");
5880 assert!(!err.is_empty());
5881 assert_eq!(app.model, before_model, "model preserved");
5882 assert!(app.engine.is_some(), "engine preserved");
5883
5884 let err = app
5886 .add_derived_measure("Bad", "(Price > Quantity) * Price")
5887 .expect_err("a type error must be rejected");
5888 assert!(!err.is_empty());
5889 assert_eq!(app.model, before_model, "model preserved");
5890 assert!(app.engine.is_some(), "engine preserved");
5891 }
5892
5893 #[test]
5894 fn inspector_data_is_correct() {
5895 let app = build_app(revenue_model());
5896 let d = app.inspector_data(MeasureId(102)).expect("data");
5897 assert_eq!(d.name, "Revenue");
5898 assert!(d.is_derived);
5899 assert_eq!(
5900 d.dimensions,
5901 vec!["Time".to_string(), "Product".to_string()]
5902 );
5903 let mut deps = d.dependencies.clone();
5904 deps.sort();
5905 assert_eq!(deps, vec!["Price".to_string(), "Quantity".to_string()]);
5906 assert!(d.formula_english.is_some());
5907 assert_eq!(d.error_cells, 0);
5908
5909 let di = app.inspector_data(MeasureId(100)).expect("data");
5911 assert!(!di.is_derived);
5912 assert!(di.dependencies.is_empty());
5913 assert!(di.formula_english.is_none());
5914 assert_eq!(di.dimensions, vec!["Product".to_string()]);
5915 }
5916
5917 fn build_app(model: Model) -> ImprovApp {
5921 let (engine, snapshot) = build_engine(&model);
5922 let selected = pick_default_measure(&model);
5923 let matrices = vec![Matrix::new(&model, selected)];
5924 ImprovApp {
5925 db: String::new(),
5926 save_token: improv_store::SaveToken::default(),
5927 model,
5928 engine,
5929 snapshot,
5930 matrices,
5931 focus: 0,
5932 current_view: None,
5933 status: String::new(),
5934 formula_buf: String::new(),
5935 formula_for: None,
5936 formula_error: None,
5937 locale: if cfg!(test) {
5938 improv_core_model::Locale::en()
5939 } else {
5940 improv_core_model::Locale::from_env()
5941 },
5942 view_name: String::new(),
5943 browser_sel: None,
5944 show_chart: false,
5945 chart_line: false,
5946 show_csv_wizard: false,
5947 import_form: ImportForm::default(),
5948 show_inspector: false,
5949 fn_help: Default::default(),
5950 formula_target: FormulaEditor::NewLine,
5951 show_browser: true,
5952 quit_armed: false,
5953 dirty: false,
5954 last_title: String::new(),
5955 status_shown: String::new(),
5956 status_at: 0.0,
5957 menu: menu::MenuState::default(),
5958 export_form: ExportForm::default(),
5959 undo_stack: Vec::new(),
5960 redo_stack: Vec::new(),
5961 formula_row_rects: Vec::new(),
5962 new_formula_line: String::new(),
5963 new_formula_error: None,
5964 }
5965 }
5966
5967 fn select(app: &mut ImprovApp, measure: MeasureId) {
5971 app.focused_mut().select(Some(measure));
5972 }
5973
5974 fn sales_3d_model() -> Model {
5977 let mut m = Model::new();
5978 let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
5979 m.add_category(time, "Time");
5980 m.add_category(product, "Product");
5981 m.add_category(region, "Region");
5982 m.add_item(ItemId(10), time, "2025");
5983 m.add_item(ItemId(20), product, "WidgetA");
5984 m.add_item(ItemId(30), region, "North");
5985 m.add_item(ItemId(31), region, "South");
5986 m.add_measure(Measure {
5987 id: MeasureId(200),
5988 name: Name("Sales".into()),
5989 value_type: ValueType::Number,
5990 categories: vec![time, product, region],
5991 kind: MeasureKind::Input,
5992 description: None,
5993 });
5994 let c = |pairs: &[(CategoryId, ItemId)]| {
5995 improv_core_model::Coordinate::from_pairs(pairs.iter().copied())
5996 };
5997 m.set_input(
5998 MeasureId(200),
5999 c(&[
6000 (time, ItemId(10)),
6001 (product, ItemId(20)),
6002 (region, ItemId(30)),
6003 ]),
6004 Value::Number(100.0),
6005 );
6006 m.set_input(
6007 MeasureId(200),
6008 c(&[
6009 (time, ItemId(10)),
6010 (product, ItemId(20)),
6011 (region, ItemId(31)),
6012 ]),
6013 Value::Number(250.0),
6014 );
6015 m
6016 }
6017
6018 #[test]
6019 fn set_axis_moves_category_from_columns_to_rows() {
6020 let mut app = build_app(revenue_model());
6022 select(&mut app, MeasureId(101));
6023 app.sync_axis_state();
6024 let (r, c, _) = app.resolved_axes();
6025 assert_eq!(r, Some(CategoryId(1))); assert_eq!(c, Some(CategoryId(2))); app.set_axis(CategoryId(2), Axis::Rows);
6031 assert_eq!(app.row_cats(), vec![CategoryId(1), CategoryId(2)]);
6032 assert!(app.col_cats().is_empty());
6033 let (r, c, _) = app.resolved_axes();
6034 assert_eq!(r, Some(CategoryId(1)), "primary row is still Time");
6035 assert_eq!(c, None, "no column category after stacking both on rows");
6036 }
6037
6038 #[test]
6039 fn stacked_rows_form_cartesian_product_with_correct_keys() {
6040 let mut app = build_app(revenue_model());
6042 select(&mut app, MeasureId(101));
6043 app.sync_axis_state();
6044 app.set_axis(CategoryId(2), Axis::Rows); assert_eq!(app.row_cats(), vec![CategoryId(1), CategoryId(2)]);
6046
6047 let tuples = app.axis_tuples(&app.row_cats());
6049 assert_eq!(tuples.len(), 1);
6052 assert_eq!(tuples[0].len(), 2, "tuple binds both stacked categories");
6053 let key = cell_key_multi(&app.row_cats(), &tuples[0], &[], &[], &[]);
6055 assert_eq!(key, vec![(1, 10), (2, 20)]);
6056 assert_eq!(app.grid_dims(), (1, 1));
6058 }
6059
6060 #[test]
6061 fn nth_tuple_decodes_mixed_radix_and_matches_axis_tuples() {
6062 let time: Vec<(ItemId, String)> =
6065 (0..4).map(|k| (ItemId(10 + k), format!("T{k}"))).collect();
6066 let region: Vec<(ItemId, String)> =
6067 (0..2).map(|k| (ItemId(30 + k), format!("R{k}"))).collect();
6068 let lists = vec![time.clone(), region.clone()];
6069 assert_eq!(product_len(&lists), 8);
6070
6071 assert_eq!(
6073 nth_tuple(&lists, 0),
6074 vec![time[0].clone(), region[0].clone()]
6075 );
6076 assert_eq!(
6077 nth_tuple(&lists, 1),
6078 vec![time[0].clone(), region[1].clone()]
6079 );
6080 assert_eq!(
6081 nth_tuple(&lists, 7),
6082 vec![time[3].clone(), region[1].clone()]
6083 );
6084
6085 let mut reference: Vec<Vec<(ItemId, String)>> = vec![Vec::new()];
6088 for list in &lists {
6089 let mut next = Vec::new();
6090 for prefix in &reference {
6091 for it in list {
6092 let mut t = prefix.clone();
6093 t.push(it.clone());
6094 next.push(t);
6095 }
6096 }
6097 reference = next;
6098 }
6099 let decoded: Vec<_> = (0..product_len(&lists))
6100 .map(|i| nth_tuple(&lists, i))
6101 .collect();
6102 assert_eq!(decoded, reference);
6103 }
6104
6105 #[test]
6106 fn nth_tuple_matches_apps_axis_tuples() {
6107 let mut app = build_app(grid_2x2_model());
6109 select(&mut app, MeasureId(101)); app.sync_axis_state();
6111 app.set_axis(CategoryId(2), Axis::Rows); let cats = app.row_cats();
6113 let lists = app.axis_item_lists(&cats);
6114 let want = app.axis_tuples(&cats);
6115 assert_eq!(product_len(&lists), want.len());
6116 for (i, w) in want.iter().enumerate() {
6117 assert_eq!(nth_tuple(&lists, i), *w, "line {i}");
6118 }
6119 }
6120
6121 #[test]
6122 fn large_grid_row_count_is_correct_and_does_not_panic() {
6123 let mut m = Model::new();
6127 let (big, small) = (CategoryId(1), CategoryId(2));
6128 m.add_category(big, "Big");
6129 m.add_category(small, "Small");
6130 let n_big = 5_000u32;
6131 for k in 0..n_big {
6132 m.add_item(ItemId(1_000 + k), big, format!("b{k}"));
6133 }
6134 for k in 0..3u32 {
6135 m.add_item(ItemId(10 + k), small, format!("s{k}"));
6136 }
6137 m.add_measure(Measure {
6138 id: MeasureId(300),
6139 name: Name("M".into()),
6140 value_type: ValueType::Number,
6141 categories: vec![big, small],
6142 kind: MeasureKind::Input,
6143 description: None,
6144 });
6145 let mut app = build_app(m);
6146 select(&mut app, MeasureId(300));
6147 app.sync_axis_state();
6148 app.set_axis(small, Axis::Rows);
6150 assert_eq!(app.row_cats(), vec![big, small]);
6151
6152 let lists = app.axis_item_lists(&app.row_cats());
6153 let total = product_len(&lists);
6154 assert_eq!(total, (n_big as usize) * 3);
6155 assert_eq!(app.grid_dims(), (total, 1));
6157
6158 let first = nth_tuple(&lists, 0);
6160 assert_eq!(first[0].0, ItemId(1_000));
6161 assert_eq!(first[1].0, ItemId(10));
6162 let last = nth_tuple(&lists, total - 1);
6163 assert_eq!(last[0].0, ItemId(1_000 + n_big - 1));
6164 assert_eq!(last[1].0, ItemId(12));
6165 app.focused_mut().cursor_row = total - 1;
6167 app.focused_mut().cursor_col = 0;
6168 let key = app.cursor_key().expect("cursor addresses a cell");
6169 let mut want = vec![(big.0, 1_000 + n_big - 1), (small.0, 12)];
6170 want.sort();
6171 assert_eq!(key, want);
6172 }
6173
6174 #[test]
6175 fn pivot_rotate_swaps_axes_and_back() {
6176 let mut app = build_app(revenue_model());
6177 select(&mut app, MeasureId(101)); app.sync_axis_state();
6179 let (r0, c0, _) = app.resolved_axes();
6180 assert_eq!((r0, c0), (Some(CategoryId(1)), Some(CategoryId(2))));
6181
6182 app.pivot_rotate();
6183 let (r1, c1, _) = app.resolved_axes();
6184 assert_eq!((r1, c1), (Some(CategoryId(2)), Some(CategoryId(1))));
6185
6186 app.pivot_rotate(); let (r2, c2, _) = app.resolved_axes();
6188 assert_eq!((r2, c2), (Some(CategoryId(1)), Some(CategoryId(2))));
6189 }
6190
6191 #[test]
6192 fn set_page_changes_pinned_item_and_cell_value() {
6193 let mut app = build_app(sales_3d_model());
6194 select(&mut app, MeasureId(200));
6195 app.sync_axis_state();
6196 let (_, _, pinned) = app.resolved_axes();
6198 assert_eq!(pinned, vec![(CategoryId(3), ItemId(30))]); let vals = app.values_for(MeasureId(200));
6202 let mut north = vec![(1u32, 10u32), (2, 20), (3, 30)];
6203 north.sort();
6204 assert_eq!(vals.get(&north), Some(&100.0));
6205
6206 app.set_page(0, 1);
6208 let (_, _, pinned) = app.resolved_axes();
6209 assert_eq!(pinned, vec![(CategoryId(3), ItemId(31))]); let mut south = vec![(1u32, 10u32), (2, 20), (3, 31)];
6211 south.sort();
6212 assert_eq!(vals.get(&south), Some(&250.0));
6213 }
6214
6215 #[test]
6216 fn switching_measure_resets_axis_order() {
6217 let mut app = build_app(sales_3d_model());
6218 select(&mut app, MeasureId(200));
6219 app.sync_axis_state();
6220 app.pivot_rotate();
6222 assert_ne!(
6223 app.focused().axis_order,
6224 vec![CategoryId(1), CategoryId(2), CategoryId(3)]
6225 );
6226
6227 app.model.add_measure(Measure {
6229 id: MeasureId(201),
6230 name: Name("Tax".into()),
6231 value_type: ValueType::Number,
6232 categories: vec![CategoryId(2)],
6233 kind: MeasureKind::Input,
6234 description: None,
6235 });
6236 select(&mut app, MeasureId(201));
6237 app.sync_axis_state();
6238 assert_eq!(app.focused().axis_order, vec![CategoryId(2)]);
6239 assert!(app.focused().page_idx.is_empty());
6240
6241 select(&mut app, MeasureId(200));
6243 app.sync_axis_state();
6244 assert_eq!(
6245 app.focused().axis_order,
6246 vec![CategoryId(1), CategoryId(2), CategoryId(3)]
6247 );
6248 }
6249
6250 fn grid_2x2_model() -> Model {
6253 let mut m = Model::new();
6254 let (t, p) = (CategoryId(1), CategoryId(2));
6255 m.add_category(t, "Time");
6256 m.add_category(p, "Product");
6257 m.add_item(ItemId(10), t, "2025");
6258 m.add_item(ItemId(11), t, "2026");
6259 m.add_item(ItemId(20), p, "WidgetA");
6260 m.add_item(ItemId(21), p, "WidgetB");
6261 m.add_measure(Measure {
6262 id: MeasureId(100),
6263 name: Name("Price".into()),
6264 value_type: ValueType::Number,
6265 categories: vec![p],
6266 kind: MeasureKind::Input,
6267 description: None,
6268 });
6269 m.add_measure(Measure {
6270 id: MeasureId(101),
6271 name: Name("Quantity".into()),
6272 value_type: ValueType::Number,
6273 categories: vec![t, p],
6274 kind: MeasureKind::Input,
6275 description: None,
6276 });
6277 m.add_measure(Measure {
6278 id: MeasureId(102),
6279 name: Name("Revenue".into()),
6280 value_type: ValueType::Number,
6281 categories: vec![t, p],
6282 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
6283 BinaryOp::Mul,
6284 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
6285 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
6286 ))),
6287 description: None,
6288 });
6289 let c = |pairs: &[(CategoryId, ItemId)]| {
6290 improv_core_model::Coordinate::from_pairs(pairs.iter().copied())
6291 };
6292 m.set_input(MeasureId(100), c(&[(p, ItemId(20))]), Value::Number(10.0));
6293 m.set_input(MeasureId(100), c(&[(p, ItemId(21))]), Value::Number(20.0));
6294 for (ti, pi, q) in [
6295 (ItemId(10), ItemId(20), 100.0),
6296 (ItemId(10), ItemId(21), 50.0),
6297 (ItemId(11), ItemId(20), 120.0),
6298 (ItemId(11), ItemId(21), 80.0),
6299 ] {
6300 m.set_input(MeasureId(101), c(&[(t, ti), (p, pi)]), Value::Number(q));
6301 }
6302 m
6303 }
6304
6305 #[test]
6306 fn cursor_clamps_at_all_edges_and_after_pivot_shrink() {
6307 let mut app = build_app(grid_2x2_model());
6308 select(&mut app, MeasureId(101)); app.sync_axis_state();
6310
6311 app.move_cursor(-5, -5);
6313 assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (0, 0));
6314 app.move_cursor(100, 100);
6316 assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (1, 1));
6317
6318 select(&mut app, MeasureId(100));
6320 app.sync_axis_state(); app.move_cursor(5, 5);
6322 assert_eq!(
6323 app.focused().cursor_col,
6324 0,
6325 "single-column grid clamps col to 0"
6326 );
6327 assert_eq!(app.focused().cursor_row, 1, "two rows -> max row 1");
6328
6329 let mut app = build_app(sales_3d_model());
6332 select(&mut app, MeasureId(200));
6333 app.sync_axis_state();
6334 app.pivot_rotate(); app.move_cursor(100, 100);
6337 let (r, c) = (app.focused().cursor_row, app.focused().cursor_col);
6338 let (rows, cols) = app.grid_dims();
6339 assert!(r < rows && c < cols, "cursor within {rows}x{cols}");
6340 app.pivot_rotate();
6343 let (rows, cols) = app.grid_dims();
6344 assert!(app.focused().cursor_row < rows && app.focused().cursor_col < cols);
6345 }
6346
6347 #[test]
6348 fn cursor_maps_to_expected_coord_key() {
6349 let mut app = build_app(grid_2x2_model());
6350 select(&mut app, MeasureId(101)); app.sync_axis_state();
6352 app.focused_mut().cursor_row = 1;
6354 app.focused_mut().cursor_col = 1;
6355 let mut expect = vec![(1u32, 11u32), (2, 21)];
6356 expect.sort();
6357 assert_eq!(app.cursor_key(), Some(expect));
6358
6359 app.focused_mut().cursor_row = 0;
6361 app.focused_mut().cursor_col = 1;
6362 let mut expect = vec![(1u32, 10u32), (2, 21)];
6363 expect.sort();
6364 assert_eq!(app.cursor_key(), Some(expect));
6365 }
6366
6367 #[test]
6368 fn move_then_edit_routes_through_set_cell_and_recomputes() {
6369 let mut app = build_app(grid_2x2_model());
6370 select(&mut app, MeasureId(101)); app.sync_axis_state();
6372 assert!(app.cursor_is_editable());
6373
6374 app.focused_mut().cursor_row = 0;
6376 app.focused_mut().cursor_col = 0;
6377 let key = app.cursor_key().expect("cursor addresses a cell");
6378 app.set_cell(MeasureId(101), key, Value::Number(200.0))
6379 .unwrap();
6380
6381 let mut rkey = vec![(1u32, 10u32), (2, 20)];
6383 rkey.sort();
6384 let rev = app.values_for(MeasureId(102));
6385 assert_eq!(rev.get(&rkey), Some(&2000.0));
6386
6387 select(&mut app, MeasureId(102));
6389 app.sync_axis_state();
6390 assert!(!app.cursor_is_editable());
6391 app.begin_edit_cursor();
6392 assert!(app.focused().editing.is_none());
6393 assert_eq!(app.status, "derived cells are computed, not editable");
6394 }
6395
6396 #[test]
6397 fn filter_hides_an_item_from_the_rendered_rows() {
6398 let mut app = build_app(grid_2x2_model());
6399 select(&mut app, MeasureId(101)); app.sync_axis_state();
6401 let (row_cat, _, _) = app.resolved_axes();
6403 assert_eq!(row_cat, Some(CategoryId(1)));
6404 assert_eq!(app.sorted_items(CategoryId(1)).len(), 2);
6405 app.toggle_filter_item(CategoryId(1), ItemId(11));
6406 let rows: Vec<ItemId> = app
6407 .sorted_items(CategoryId(1))
6408 .iter()
6409 .map(|(id, _)| *id)
6410 .collect();
6411 assert_eq!(rows, vec![ItemId(10)], "2026 filtered out of the row axis");
6412 assert_eq!(app.sorted_items(CategoryId(2)).len(), 2);
6414 app.toggle_filter_item(CategoryId(1), ItemId(11));
6416 assert!(app.focused().filters.is_empty());
6417 assert_eq!(app.sorted_items(CategoryId(1)).len(), 2);
6418 }
6419
6420 fn hide_all_items(app: &mut ImprovApp, cat: CategoryId) {
6423 let items: Vec<ItemId> = app
6424 .model
6425 .categories
6426 .get(&cat)
6427 .map(|c| c.items.clone())
6428 .unwrap_or_default();
6429 for it in items {
6430 app.toggle_filter_item(cat, it);
6431 }
6432 assert!(
6433 app.sorted_items(cat).is_empty(),
6434 "category {cat:?} should be filtered to nothing"
6435 );
6436 }
6437
6438 #[test]
6439 fn row_category_filtered_to_empty_renders_zero_lines_not_one() {
6440 let mut app = build_app(grid_2x2_model());
6441 select(&mut app, MeasureId(101)); app.sync_axis_state(); assert_eq!(app.grid_dims(), (2, 2));
6444
6445 hide_all_items(&mut app, CategoryId(1)); let (rows, cols) = app.grid_dims();
6447 assert_eq!(rows, 0, "an empty row category must render ZERO row lines");
6448 assert_eq!(cols, 2, "the column axis is untouched");
6449 assert_eq!(app.cursor_key(), None);
6452 assert!(!app.cursor_is_editable());
6453 app.begin_edit_cursor();
6454 assert!(
6455 app.focused().editing.is_none(),
6456 "no edit may start on a missing cell"
6457 );
6458
6459 app.toggle_filter_item(CategoryId(1), ItemId(10));
6461 assert_eq!(app.grid_dims(), (1, 2));
6462 let key = app.cursor_key().expect("cell exists again");
6463 assert_eq!(key.len(), 2, "key binds both Time and Product");
6464 }
6465
6466 #[test]
6467 fn column_category_filtered_to_empty_renders_zero_lines_not_one() {
6468 let mut app = build_app(grid_2x2_model());
6469 select(&mut app, MeasureId(101));
6470 app.sync_axis_state(); hide_all_items(&mut app, CategoryId(2)); let (rows, cols) = app.grid_dims();
6473 assert_eq!(cols, 0, "an empty column category renders ZERO columns");
6474 assert_eq!(rows, 2, "the row axis is untouched");
6475 assert_eq!(app.cursor_key(), None);
6476 assert!(!app.cursor_is_editable());
6477 app.begin_edit_cursor();
6478 assert!(app.focused().editing.is_none());
6479 }
6480
6481 #[test]
6482 fn page_category_filtered_to_empty_leaves_no_addressable_cell() {
6483 let mut app = build_app(sales_3d_model());
6486 select(&mut app, MeasureId(200));
6487 app.sync_axis_state();
6488 assert_eq!(app.page_cats(), vec![CategoryId(3)]);
6489 let full = app.cursor_key().expect("a cell before filtering");
6490 assert_eq!(full.len(), 3, "key binds Time, Product AND Region");
6491
6492 hide_all_items(&mut app, CategoryId(3)); assert_eq!(
6494 app.grid_dims(),
6495 (0, 0),
6496 "no page item pinnable -> nothing to render"
6497 );
6498 assert_eq!(
6499 app.cursor_key(),
6500 None,
6501 "must not yield a Region-less (under-specified) key"
6502 );
6503 assert!(!app.cursor_is_editable());
6504 app.begin_edit_cursor();
6505 assert!(app.focused().editing.is_none());
6506 assert!(app.status.contains("filtered"), "status: {}", app.status);
6507 }
6508
6509 #[test]
6510 fn stacked_axis_with_one_empty_category_renders_zero_lines() {
6511 let mut app = build_app(grid_2x2_model());
6514 select(&mut app, MeasureId(101));
6515 app.sync_axis_state();
6516 app.set_axis(CategoryId(2), Axis::Rows); assert_eq!(app.grid_dims(), (4, 1));
6518 hide_all_items(&mut app, CategoryId(2));
6519 assert_eq!(app.grid_dims(), (0, 1));
6520 assert_eq!(app.cursor_key(), None);
6521 }
6522
6523 #[test]
6524 fn scalar_axis_with_no_categories_still_has_one_line() {
6525 let mut app = build_app(grid_2x2_model());
6529 select(&mut app, MeasureId(100)); app.sync_axis_state();
6531 assert_eq!(app.col_cats(), vec![], "no column category: scalar axis");
6532 assert_eq!(
6533 app.grid_dims(),
6534 (2, 1),
6535 "2 Product rows x 1 scalar column line"
6536 );
6537 let key = app.cursor_key().expect("scalar column line has a cell");
6539 assert_eq!(key, vec![(2u32, 20u32)]);
6540 assert!(app.cursor_is_editable());
6541
6542 hide_all_items(&mut app, CategoryId(2));
6545 assert_eq!(app.grid_dims(), (0, 1));
6546 assert_eq!(app.cursor_key(), None);
6547 }
6548
6549 #[test]
6550 fn cursor_never_decodes_a_line_of_an_empty_axis() {
6551 let mut app = build_app(grid_2x2_model());
6554 select(&mut app, MeasureId(101));
6555 app.sync_axis_state();
6556 app.move_cursor(10, 10); assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (1, 1));
6558 hide_all_items(&mut app, CategoryId(1));
6559 assert_eq!(app.focused().cursor_row, 0);
6562 assert_eq!(app.cursor_key(), None);
6563 app.focused_mut().cursor_row = 7;
6565 app.focused_mut().cursor_col = 7;
6566 assert_eq!(app.cursor_key(), None);
6567 app.move_cursor(1, 1);
6568 assert_eq!(
6569 app.focused().cursor_row,
6570 0,
6571 "an empty axis cannot be moved into"
6572 );
6573 }
6574
6575 #[test]
6576 fn save_view_captures_measure_axis_order_and_filters() {
6577 let mut app = build_app(grid_2x2_model());
6578 select(&mut app, MeasureId(101)); app.sync_axis_state();
6580 app.pivot_rotate(); app.toggle_filter_item(CategoryId(2), ItemId(21)); let id = app.save_view("L1").expect("saved");
6584 let v = app.model.views.get(&id).expect("view stored");
6585 assert_eq!(v.measure, MeasureId(101));
6586 assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
6587 let f = v
6588 .filters
6589 .iter()
6590 .find(|f| f.category == CategoryId(2))
6591 .unwrap();
6592 assert_eq!(f.items, vec![ItemId(20)]); assert_eq!(app.model.view_by_name("L1").map(|v| v.id), Some(id));
6594 }
6595
6596 #[test]
6597 fn apply_view_restores_axis_order_and_filters() {
6598 let mut src = build_app(grid_2x2_model());
6600 select(&mut src, MeasureId(101));
6601 src.sync_axis_state();
6602 src.pivot_rotate(); src.toggle_filter_item(CategoryId(2), ItemId(21)); let v = src.build_view(ViewId(1), "L1").expect("view");
6605
6606 let mut dst = build_app(grid_2x2_model());
6607 assert_eq!(dst.selected(), Some(MeasureId(102)));
6609 dst.apply_view(&v);
6610 dst.sync_axis_state(); assert_eq!(dst.selected(), Some(MeasureId(101)));
6613 assert_eq!(dst.focused().axis_order, vec![CategoryId(2), CategoryId(1)]);
6614 assert_eq!(dst.focused().filters, v.filters);
6615 let (row_cat, col_cat, _) = dst.resolved_axes();
6616 assert_eq!(row_cat, Some(CategoryId(2))); assert_eq!(col_cat, Some(CategoryId(1))); let rows: Vec<ItemId> = dst
6620 .sorted_items(CategoryId(2))
6621 .iter()
6622 .map(|(id, _)| *id)
6623 .collect();
6624 assert_eq!(rows, vec![ItemId(20)]);
6625 }
6626
6627 #[test]
6628 fn apply_view_restores_page_item() {
6629 let mut src = build_app(sales_3d_model());
6630 select(&mut src, MeasureId(200));
6631 src.sync_axis_state();
6632 src.set_page(0, 1);
6634 let (_, _, pinned) = src.resolved_axes();
6635 assert_eq!(pinned, vec![(CategoryId(3), ItemId(31))]); let v = src.build_view(ViewId(1), "south").expect("view");
6637 assert_eq!(v.page_items, vec![(CategoryId(3), ItemId(31))]);
6638
6639 let mut dst = build_app(sales_3d_model());
6640 select(&mut dst, MeasureId(200));
6641 dst.sync_axis_state();
6642 let (_, _, pinned) = dst.resolved_axes();
6643 assert_eq!(pinned, vec![(CategoryId(3), ItemId(30))]); dst.apply_view(&v);
6645 let (_, _, pinned) = dst.resolved_axes();
6646 assert_eq!(
6647 pinned,
6648 vec![(CategoryId(3), ItemId(31))],
6649 "page item restored"
6650 );
6651 }
6652
6653 #[test]
6656 fn chart_series_yields_labels_and_oracle_values() {
6657 let app = build_app(grid_2x2_model());
6660 assert_eq!(app.selected(), Some(MeasureId(102))); let d = app.chart_series();
6662 assert_eq!(d.x_title, "Time");
6663 assert_eq!(d.x_labels, vec!["2025".to_string(), "2026".to_string()]);
6664 assert_eq!(d.series.len(), 2);
6665 assert_eq!(d.series[0].name, "WidgetA");
6666 assert_eq!(d.series[0].points, vec![Some(1000.0), Some(1200.0)]);
6667 assert_eq!(d.series[1].name, "WidgetB");
6668 assert_eq!(d.series[1].points, vec![Some(1000.0), Some(1600.0)]);
6669 assert_eq!(d.y_range(), (0.0, 1600.0));
6671 }
6672
6673 #[test]
6674 fn chart_series_stacked_rows_join_tuple_labels_and_match_cell_keys() {
6675 let mut app = build_app(grid_2x2_model());
6680 select(&mut app, MeasureId(102)); app.sync_axis_state();
6682 app.set_axis(CategoryId(2), Axis::Rows); assert_eq!(app.row_cats(), vec![CategoryId(1), CategoryId(2)]);
6684 assert!(app.col_cats().is_empty());
6685
6686 let d = app.chart_series();
6687 assert_eq!(d.x_title, "Time / Product");
6688 assert_eq!(
6689 d.x_labels,
6690 vec![
6691 "2025 / WidgetA".to_string(),
6692 "2025 / WidgetB".to_string(),
6693 "2026 / WidgetA".to_string(),
6694 "2026 / WidgetB".to_string(),
6695 ]
6696 );
6697 assert_eq!(d.series.len(), 1);
6699 assert_eq!(d.series[0].name, "");
6700
6701 let values = app.values_for(MeasureId(102));
6703 let row_tuples = app.axis_tuples(&app.row_cats());
6704 let want: Vec<Option<f64>> = row_tuples
6705 .iter()
6706 .map(|t| {
6707 let key = cell_key_multi(&app.row_cats(), t, &[], &[], &[]);
6708 values.get(&key).copied()
6709 })
6710 .collect();
6711 assert_eq!(d.series[0].points, want);
6712 assert_eq!(
6715 d.series[0].points,
6716 vec![Some(1000.0), Some(1000.0), Some(1200.0), Some(1600.0)]
6717 );
6718 }
6719
6720 #[test]
6721 fn chart_series_stacked_columns_form_one_series_per_col_tuple() {
6722 let mut app = build_app(grid_2x2_model());
6727 select(&mut app, MeasureId(102));
6728 app.sync_axis_state();
6729 app.pivot_rotate(); assert_eq!(app.row_cats(), vec![CategoryId(2)]);
6731 assert_eq!(app.col_cats(), vec![CategoryId(1)]);
6732
6733 let d = app.chart_series();
6734 assert_eq!(d.x_title, "Product");
6735 assert_eq!(
6736 d.x_labels,
6737 vec!["WidgetA".to_string(), "WidgetB".to_string()]
6738 );
6739 assert_eq!(d.series.len(), 2);
6741 assert_eq!(d.series[0].name, "2025");
6742 assert_eq!(d.series[1].name, "2026");
6743 assert_eq!(d.series[0].points, vec![Some(1000.0), Some(1000.0)]);
6745 assert_eq!(d.series[1].points, vec![Some(1200.0), Some(1600.0)]);
6746 }
6747
6748 #[test]
6749 fn chart_series_1d_single_series() {
6750 let mut app = build_app(grid_2x2_model());
6752 select(&mut app, MeasureId(100)); app.sync_axis_state();
6754 let d = app.chart_series();
6755 assert_eq!(d.x_title, "Product");
6756 assert_eq!(
6757 d.x_labels,
6758 vec!["WidgetA".to_string(), "WidgetB".to_string()]
6759 );
6760 assert_eq!(d.series.len(), 1);
6761 assert_eq!(d.series[0].name, "");
6762 assert_eq!(d.series[0].points, vec![Some(10.0), Some(20.0)]);
6763 }
6764
6765 #[test]
6766 fn chart_filter_removes_a_bar() {
6767 let mut app = build_app(grid_2x2_model());
6769 select(&mut app, MeasureId(102));
6770 app.sync_axis_state();
6771 app.toggle_filter_item(CategoryId(1), ItemId(11)); let d = app.chart_series();
6773 assert_eq!(d.x_labels, vec!["2025".to_string()], "2026 filtered out");
6774 assert_eq!(d.series[0].points, vec![Some(1000.0)]); assert_eq!(d.series[1].points, vec![Some(1000.0)]); }
6777
6778 #[test]
6779 fn chart_non_numeric_cell_is_a_gap_not_a_panic() {
6780 let mut app = build_app(grid_2x2_model());
6783 let c = |pairs: &[(CategoryId, ItemId)]| {
6786 improv_core_model::Coordinate::from_pairs(pairs.iter().copied())
6787 };
6788 app.model.set_input(
6789 MeasureId(101),
6790 c(&[(CategoryId(1), ItemId(11)), (CategoryId(2), ItemId(20))]),
6791 Value::Text("oops".into()),
6792 );
6793 app.rebuild_engine();
6794 select(&mut app, MeasureId(102));
6795 app.sync_axis_state();
6796 let d = app.chart_series(); assert_eq!(d.series[0].name, "WidgetA");
6799 assert_eq!(d.series[0].points, vec![Some(1000.0), None]);
6800 assert_eq!(d.series[1].points, vec![Some(1000.0), Some(1600.0)]);
6802 let (lo, hi) = d.y_range();
6804 assert_eq!(lo, 0.0);
6805 assert!(hi >= 1600.0);
6806 }
6807
6808 fn aggregation_arg_space() -> Vec<(String, Formula)> {
6819 let cats = [CategoryId(1), CategoryId(2), CategoryId(3)];
6820 let mut out = Vec::new();
6821 for func in [
6822 parser::FUNC_SUM,
6823 parser::FUNC_AVG,
6824 parser::FUNC_MIN,
6825 parser::FUNC_MAX,
6826 ] {
6827 for over_n in 0..=3usize {
6829 for except_n in 0..=2usize {
6830 for by_n in 0..=2usize {
6831 let spec = DimensionSpec {
6832 by: cats[..by_n].to_vec(),
6833 over: cats[..over_n].to_vec(),
6834 except: cats[..except_n].to_vec(),
6835 };
6836 out.push((
6837 format!(
6838 "f{}(Quantity over={over_n} except={except_n} by={by_n})",
6839 func.0
6840 ),
6841 Formula::new(Expr::Call(func, vec![Expr::Ref(MeasureId(101), spec)])),
6842 ));
6843 }
6844 }
6845 }
6846 let price = || Expr::Ref(MeasureId(100), DimensionSpec::default());
6848 let qty = || Expr::Ref(MeasureId(101), DimensionSpec::default());
6849 for (what, arg) in [
6850 ("literal", Expr::Literal(Value::Number(1.0))),
6851 (
6852 "product",
6853 Expr::BinaryOp(BinaryOp::Mul, Box::new(price()), Box::new(qty())),
6854 ),
6855 ("negated ref", Expr::UnaryOp(UnaryOp::Neg, Box::new(qty()))),
6856 ("scalar call", Expr::Call(FuncId(10), vec![qty()])),
6857 (
6858 "nested agg",
6859 Expr::Call(
6860 parser::FUNC_SUM,
6861 vec![Expr::Ref(
6862 MeasureId(101),
6863 DimensionSpec {
6864 by: vec![],
6865 over: vec![CategoryId(1)],
6866 except: vec![],
6867 },
6868 )],
6869 ),
6870 ),
6871 ("no args", Expr::Literal(Value::Boolean(true))),
6872 ] {
6873 out.push((
6874 format!("f{}({what})", func.0),
6875 Formula::new(Expr::Call(func, vec![arg])),
6876 ));
6877 }
6878 out.push((
6880 format!("f{}() no args at all", func.0),
6881 Formula::new(Expr::Call(func, vec![])),
6882 ));
6883 out.push((
6884 format!("f{}(two refs)", func.0),
6885 Formula::new(Expr::Call(
6886 func,
6887 vec![
6888 Expr::Ref(MeasureId(100), DimensionSpec::default()),
6889 Expr::Ref(MeasureId(101), DimensionSpec::default()),
6890 ],
6891 )),
6892 ));
6893 out.push((
6896 format!("f{}(Quantity) * Price", func.0),
6897 Formula::new(Expr::BinaryOp(
6898 BinaryOp::Mul,
6899 Box::new(Expr::Call(
6900 func,
6901 vec![Expr::Ref(MeasureId(101), DimensionSpec::default())],
6902 )),
6903 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
6904 )),
6905 ));
6906 }
6907 out
6908 }
6909
6910 #[test]
6915 fn every_aggregation_arg_shape_displays_only_committable_text() {
6916 let mut failures: Vec<String> = Vec::new();
6917 for (label, formula) in aggregation_arg_space() {
6918 let mut model = revenue_model();
6919 model.add_category(CategoryId(3), "Region");
6920 model.add_item(ItemId(30), CategoryId(3), "North");
6921 model.measures.get_mut(&MeasureId(102)).unwrap().kind =
6922 MeasureKind::Derived(formula.clone());
6923 let mut app = build_app(model);
6924 select(&mut app, MeasureId(102));
6925
6926 if let Some(dsl) = formula_dsl(&app.model, &formula) {
6928 match parser::parse_expr(&app.model, &dsl) {
6929 Ok(back) if back == formula => {}
6930 Ok(back) => failures.push(format!(
6931 "[{label}] DSL {dsl:?} reparsed to a DIFFERENT ast: {:?}",
6932 back.expr
6933 )),
6934 Err(e) => failures.push(format!("[{label}] DSL {dsl:?} DOES NOT PARSE: {e}")),
6935 }
6936 }
6937 let Some(shown) = app.formula_source(MeasureId(102)) else {
6939 continue; };
6941 match app.commit_formula(MeasureId(102), &shown) {
6942 Ok(()) => match &app.model.measures[&MeasureId(102)].kind {
6943 MeasureKind::Derived(got) if *got == formula => {}
6944 other => failures.push(format!(
6945 "[{label}] displayed {shown:?} committed to a DIFFERENT formula: {other:?}"
6946 )),
6947 },
6948 Err(e) => failures.push(format!(
6949 "[{label}] displayed {shown:?} does not commit: {e}"
6950 )),
6951 }
6952 }
6953 assert!(
6954 failures.is_empty(),
6955 "{} shape(s) display text that does not re-commit:\n{}",
6956 failures.len(),
6957 failures.join("\n")
6958 );
6959 }
6960
6961 #[test]
6965 fn cnl_aggregation_without_over_recommits_from_the_bar() {
6966 let mut app = build_app(revenue_model());
6967 select(&mut app, MeasureId(102));
6968 app.commit_formula(MeasureId(102), "the sum of Quantity")
6969 .expect("CNL commits");
6970 let committed = match &app.model.measures[&MeasureId(102)].kind {
6971 MeasureKind::Derived(f) => f.clone(),
6972 MeasureKind::Input => panic!("stopped being derived"),
6973 };
6974 let shown = app
6976 .formula_source(MeasureId(102))
6977 .expect("an editable spelling exists (CNL)");
6978 app.commit_formula(MeasureId(102), &shown)
6979 .unwrap_or_else(|e| panic!("bar text {shown:?} does not commit: {e}"));
6980 match &app.model.measures[&MeasureId(102)].kind {
6981 MeasureKind::Derived(f) => assert_eq!(*f, committed, "AST changed on re-commit"),
6982 MeasureKind::Input => panic!("stopped being derived"),
6983 }
6984 }
6985
6986 #[test]
7001 fn awkward_measure_names_are_spelled_with_quoted_identifiers() {
7002 let bare = ["UnitPrice", "Unit_Price", "Price2024", "_Price"];
7004 let quoted = [
7007 "Unit Price",
7008 "Price/Unit",
7009 "Price-2024",
7010 "Q1 Revenue",
7011 "Over",
7012 "over",
7013 "AND",
7014 "Price(net)",
7015 ];
7016 let extra = ["Bob's Rate", "2024Price", "Ünit", "SUM", "Price.Unit"];
7019 let unspellable = [""];
7020
7021 for name in bare
7022 .iter()
7023 .chain(quoted.iter())
7024 .chain(extra.iter())
7025 .chain(unspellable.iter())
7026 {
7027 let mut model = revenue_model();
7028 model.measures.get_mut(&MeasureId(100)).unwrap().name = Name((*name).into());
7029 let mut app = build_app(model);
7030 select(&mut app, MeasureId(102));
7031 let formula = match &app.model.measures[&MeasureId(102)].kind {
7032 MeasureKind::Derived(f) => f.clone(),
7033 MeasureKind::Input => panic!("Revenue is derived"),
7034 };
7035 match app.formula_source(MeasureId(102)) {
7036 Some(shown) => {
7040 assert!(
7041 !unspellable.contains(name),
7042 "name {name:?} must stay unspellable but the bar shows {shown:?}"
7043 );
7044 if bare.contains(name) {
7046 assert!(
7047 shown.starts_with(&format!("{name} ")),
7048 "name {name:?} needs no quoting, got {shown:?}"
7049 );
7050 } else if quoted.contains(name) {
7051 assert!(
7052 shown.starts_with(&format!("'{name}'")),
7053 "name {name:?} must print quoted, got {shown:?}"
7054 );
7055 }
7056 app.commit_formula(MeasureId(102), &shown)
7057 .unwrap_or_else(|e| {
7058 panic!("name {name:?}: displayed {shown:?} does not commit: {e}")
7059 });
7060 match &app.model.measures[&MeasureId(102)].kind {
7061 MeasureKind::Derived(f) => {
7062 assert_eq!(*f, formula, "name {name:?}: AST changed ({shown:?})")
7063 }
7064 MeasureKind::Input => panic!("stopped being derived"),
7065 }
7066 }
7067 None => {
7070 assert!(
7071 unspellable.contains(name),
7072 "name {name:?} is spellable but the bar refuses to show it"
7073 );
7074 let cnl = describe_formula(&NlContext::new(&app.model), &formula);
7075 assert_ne!(
7076 app.parse_formula_text(&cnl).ok().as_ref(),
7077 Some(&formula),
7078 "name {name:?}: CNL {cnl:?} DOES round-trip; it should be editable"
7079 );
7080 }
7081 }
7082 }
7083 }
7084
7085 #[test]
7089 fn an_embedded_quote_is_printed_doubled() {
7090 let mut model = revenue_model();
7091 model.measures.get_mut(&MeasureId(100)).unwrap().name = Name("Bob's Rate".into());
7092 let app = build_app(model);
7093 let shown = app
7094 .formula_source(MeasureId(102))
7095 .expect("a quote in a name is spellable by doubling it");
7096 assert_eq!(shown, "'Bob''s Rate' * Quantity");
7097 }
7098
7099 #[test]
7105 fn dimensional_forms_round_trip_through_the_bar() {
7106 let mut model = revenue_model();
7107 model.add_category(CategoryId(3), "Region");
7108 model.add_item(ItemId(30), CategoryId(3), "North");
7109 for text in [
7110 "Quantity - PREV(Quantity, Time, 1, 0)",
7111 "NEXT(Quantity, Time)",
7112 "Quantity * POSITION(Time)",
7113 "SUM(Quantity OVER Time[FIRST..THIS])",
7114 "SUM(Quantity OVER Time[THIS-2..THIS+1])",
7115 "SUM(Quantity OVER Time WHERE Quantity > 90)",
7116 "TEXTJOIN(\"-\", TRUE, Quantity OVER Time)",
7117 "FIRST(Quantity OVER Time)",
7118 "SCAN(0, Quantity OVER Time, LAMBDA(acc0, val0, acc0 + val0 * 2))",
7119 ] {
7120 let mut app = build_app(model.clone());
7121 app.commit_formula(MeasureId(102), text)
7122 .unwrap_or_else(|e| panic!("{text}: {e}"));
7123 let shown = app.formula_source(MeasureId(102)).expect(text);
7124 assert_eq!(shown, text, "displayed as typed");
7125 }
7126 }
7127
7128 #[test]
7132 fn missing_cell_and_role_forms_round_trip_through_the_bar() {
7133 let mut model = revenue_model();
7134 model.add_category(CategoryId(3), "Year");
7135 model.add_item(ItemId(30), CategoryId(3), "2025");
7136 for text in [
7137 "COALESCE(Quantity, Price * 0, 1)",
7138 "FILL(Quantity, 0)",
7139 "RELABEL(Quantity, Time, Year) * 2",
7140 "SWAP(Quantity, Time, Product)",
7141 "FIXPOINT(Quantity, LAMBDA(acc0, acc0 / 2 + Price), 50, 0.0000000001)",
7142 "FIXPOINT(FILL(Quantity, 0), LAMBDA(acc0, RELABEL(acc0, Time, Year) * 2))",
7143 ] {
7144 let f = parser::parse_expr(&model, text).unwrap_or_else(|e| panic!("{text}: {e}"));
7145 assert_eq!(formula_dsl(&model, &f).as_deref(), Some(text));
7146 }
7147 }
7148
7149 #[test]
7151 fn named_lambda_calls_print_by_name() {
7152 let mut model = revenue_model();
7153 let improv_core_model::Definition::Lambda(l) =
7154 improv_core_model::parse_definition(&model, "Margin = LAMBDA(p, c, p - c)").unwrap()
7155 else {
7156 panic!("a lambda")
7157 };
7158 model.lambdas.insert(1, l.lambda);
7159 let text = "Margin(Price, Quantity * 2)";
7160 let f = parser::parse_expr(&model, text).unwrap();
7161 assert_eq!(formula_dsl(&model, &f).as_deref(), Some(text));
7162 let improv_core_model::Definition::Lambda(l) = improv_core_model::parse_definition(
7164 &model,
7165 "LOGNORM = LAMBDA(x, c, LN1P(x) / LN1P(MAX(x OVER c)))",
7166 )
7167 .unwrap() else {
7168 panic!("a lambda")
7169 };
7170 model.lambdas.insert(2, l.lambda);
7171 let text = "LOGNORM(Quantity, Time) + Margin(Price, 1)";
7172 let f = parser::parse_expr(&model, text).unwrap();
7173 assert_eq!(formula_dsl(&model, &f).as_deref(), Some(text));
7174 model.lambdas.clear();
7175 assert_eq!(
7176 formula_dsl(&model, &f),
7177 None,
7178 "a dangling call does not print"
7179 );
7180 }
7181
7182 #[test]
7184 fn into_clause_round_trips() {
7185 let mut model = revenue_model();
7186 model.add_category(CategoryId(3), "Region");
7187 model.add_item(ItemId(30), CategoryId(3), "North");
7188 model.add_category(CategoryId(4), "Slot");
7189 for text in [
7190 "SORT(Quantity OVER Time, -1 INTO Region)",
7191 "SORT(Quantity OVER Time INTO Region, Slot)",
7192 "BYROW(Quantity OVER Product, LAMBDA(r, SUM(r)))",
7193 "LINEST(Quantity, Price, Price OVER Time, FALSE, TRUE INTO Region, Slot)",
7194 ] {
7195 let f = parser::parse_expr(&model, text).unwrap();
7196 let shown = formula_dsl(&model, &f);
7197 if text.starts_with("BYROW") {
7198 assert_eq!(shown.as_deref(), Some("SUM(Quantity OVER Product)"));
7200 } else {
7201 assert_eq!(shown.as_deref(), Some(text));
7202 }
7203 }
7204 }
7205
7206 #[test]
7209 fn formulas_are_typed_and_shown_in_the_locale() {
7210 let mut app = build_app(revenue_model());
7211 app.locale = improv_core_model::Locale::de();
7212 app.commit_formula(MeasureId(102), "RUNDEN(Price * Quantity * 1,5; 0)")
7213 .expect("German commits");
7214 assert_eq!(
7215 app.formula_source(MeasureId(102)).as_deref(),
7216 Some("RUNDEN(Price * Quantity * 1,5; 0)")
7217 );
7218 let MeasureKind::Derived(f) = &app.model.measures[&MeasureId(102)].kind else {
7219 panic!("derived")
7220 };
7221 app.locale = improv_core_model::Locale::en();
7222 assert_eq!(
7223 formula_dsl(&app.model, f).as_deref(),
7224 Some("ROUND(Price * Quantity * 1.5, 0)")
7225 );
7226 }
7227
7228 #[test]
7229 fn awkward_category_names_are_quoted_in_dim_lists_and_over() {
7230 let mut model = revenue_model();
7231 model.categories.get_mut(&CategoryId(1)).unwrap().name = Name("Fiscal Year".into());
7232 model.measures.get_mut(&MeasureId(102)).unwrap().kind =
7234 MeasureKind::Derived(Formula::new(Expr::Call(
7235 parser::FUNC_SUM,
7236 vec![Expr::Ref(
7237 MeasureId(101),
7238 DimensionSpec {
7239 by: vec![],
7240 over: vec![CategoryId(1)],
7241 except: vec![],
7242 },
7243 )],
7244 )));
7245 let mut app = build_app(model);
7246 let shown = app
7247 .formula_source(MeasureId(102))
7248 .expect("a quoted category name is spellable");
7249 assert_eq!(shown, "SUM(Quantity OVER 'Fiscal Year')");
7250 let before = match &app.model.measures[&MeasureId(102)].kind {
7252 MeasureKind::Derived(f) => f.clone(),
7253 MeasureKind::Input => panic!("derived"),
7254 };
7255 app.commit_formula(MeasureId(102), &shown).expect("commits");
7256 match &app.model.measures[&MeasureId(102)].kind {
7257 MeasureKind::Derived(f) => assert_eq!(*f, before),
7258 MeasureKind::Input => panic!("stopped being derived"),
7259 }
7260 }
7261
7262 #[test]
7265 fn chart_with_an_empty_row_axis_has_no_x_lines() {
7266 let mut app = build_app(grid_2x2_model());
7267 select(&mut app, MeasureId(102));
7268 app.sync_axis_state();
7269 assert_eq!(app.grid_dims(), (2, 2));
7270 for it in [ItemId(10), ItemId(11)] {
7272 app.toggle_filter_item(CategoryId(1), it);
7273 }
7274 assert_eq!(app.grid_dims(), (0, 2), "grid renders no rows");
7275 let d = app.chart_series();
7276 assert!(
7277 d.x_labels.is_empty() && d.series.is_empty(),
7278 "chart invented {} x line(s) / {} series for an empty row axis: {d:?}",
7279 d.x_labels.len(),
7280 d.series.len()
7281 );
7282 }
7283
7284 #[test]
7288 fn chart_with_an_empty_column_or_page_axis_has_no_data() {
7289 let mut app = build_app(grid_2x2_model());
7291 select(&mut app, MeasureId(102));
7292 app.sync_axis_state();
7293 for it in [ItemId(20), ItemId(21)] {
7294 app.toggle_filter_item(CategoryId(2), it);
7295 }
7296 assert_eq!(app.grid_dims(), (2, 0), "grid renders no columns");
7297 let d = app.chart_series();
7298 assert!(
7299 d.x_labels.is_empty() && d.series.is_empty(),
7300 "chart drew data for an empty column axis: {d:?}"
7301 );
7302
7303 let mut app = build_app(sales_3d_model());
7305 select(&mut app, MeasureId(200));
7306 app.sync_axis_state();
7307 assert_eq!(app.page_cats(), vec![CategoryId(3)]);
7308 for it in [ItemId(30), ItemId(31)] {
7309 app.toggle_filter_item(CategoryId(3), it);
7310 }
7311 assert_eq!(app.grid_dims(), (0, 0), "grid renders nothing");
7312 let d = app.chart_series();
7313 assert_eq!(
7314 d,
7315 crate::chart::ChartData::default(),
7316 "chart plotted at Region-less (under-specified) keys: {d:?}"
7317 );
7318 }
7319
7320 #[test]
7324 fn stale_parse_error_is_cleared_when_a_later_save_fails() {
7325 let mut app = build_app(revenue_model());
7326 app.commit_formula(MeasureId(102), "Price *** Quantity")
7328 .expect_err("a parse error");
7329 assert!(app.formula_error.is_some(), "parse error marked");
7330
7331 app.db = std::env::temp_dir()
7333 .join(format!("improv_gui_no_such_dir_{}", std::process::id()))
7334 .join("model.db")
7335 .to_string_lossy()
7336 .into_owned();
7337 let err = app
7338 .commit_formula(MeasureId(102), "Price + Quantity")
7339 .expect_err("unwritable store must fail the commit");
7340 assert!(err.starts_with("save failed:"), "got {err:?}");
7341 assert_eq!(
7342 app.formula_error, None,
7343 "a stale inline parse error survived a save failure"
7344 );
7345 }
7346 #[test]
7351 fn stale_non_dimension_page_category_does_not_blank_the_grid() {
7352 let mut m = Model::new();
7353 let (t, p, r) = (CategoryId(1), CategoryId(2), CategoryId(3));
7354 for (c, n) in [(t, "Time"), (p, "Product"), (r, "Region")] {
7355 m.add_category(c, n);
7356 }
7357 m.add_item(ItemId(10), t, "2025");
7358 m.add_item(ItemId(20), p, "W");
7359 m.add_item(ItemId(30), r, "North");
7360 m.add_measure(Measure {
7362 id: MeasureId(1),
7363 name: Name("Sales".into()),
7364 value_type: ValueType::Number,
7365 categories: vec![t, p],
7366 kind: MeasureKind::Input,
7367 description: None,
7368 });
7369 let cell = improv_core_model::Coordinate::from_pairs([(t, ItemId(10)), (p, ItemId(20))]);
7370 m.set_input(MeasureId(1), cell, Value::Number(42.0));
7371
7372 let mut app = build_app(m);
7373 select(&mut app, MeasureId(1));
7374 app.sync_axis_state();
7375 app.focused_mut().axis_order = vec![t, p, r];
7378 app.focused_mut().n_rows = 1;
7379 app.focused_mut().n_cols = 1;
7380 app.focused_mut().page_idx = vec![0];
7381 app.focused_mut().filters = vec![Filter {
7382 category: r,
7383 items: vec![],
7384 }];
7385
7386 assert!(
7387 app.page_cats().is_empty(),
7388 "a non-dimension category must not count as a page axis"
7389 );
7390 assert_eq!(
7391 app.grid_dims(),
7392 (1, 1),
7393 "grid must still render its one cell"
7394 );
7395 assert!(
7396 app.cursor_key().is_some(),
7397 "cell coordinate is fully specified"
7398 );
7399
7400 app.focused_mut().filters = vec![Filter {
7403 category: p,
7404 items: vec![],
7405 }];
7406 assert_eq!(
7407 app.grid_dims().1,
7408 0,
7409 "a real dimension filtered empty => no lines"
7410 );
7411 }
7412
7413 #[test]
7416 fn failed_cell_save_rolls_back_the_in_memory_edit() {
7417 let mut app = build_app(revenue_model());
7418 app.db = "/nonexistent-dir-improv/cannot-write.db".to_string();
7420 let measure = MeasureId(101); let coord = vec![(1u32, 10u32), (2u32, 20u32)];
7422 let before = app.model.input(measure, &decode(&coord)).cloned();
7423
7424 let res = app.set_cell(measure, coord.clone(), Value::Number(999.0));
7425 assert!(res.is_err(), "save to an unwritable path must fail");
7426 assert_eq!(
7427 app.model.input(measure, &decode(&coord)).cloned(),
7428 before,
7429 "defect #6: rejected value must not remain in the model"
7430 );
7431 }
7432
7433 fn date(text: &str) -> Value {
7438 parse_typed(&Model::new(), ValueType::DateTime, text).expect("fixture date")
7439 }
7440
7441 fn typed_model() -> Model {
7444 let mut m = Model::new();
7445 let p = CategoryId(1);
7446 m.add_category(p, "Product");
7447 m.add_item(ItemId(10), p, "WidgetA");
7448 for (id, name, vt) in [
7449 (1u32, "Qty", ValueType::Number),
7450 (2, "Label", ValueType::Text),
7451 (3, "Active", ValueType::Boolean),
7452 (4, "Shipped", ValueType::DateTime),
7453 ] {
7454 m.add_measure(Measure {
7455 id: MeasureId(id),
7456 name: Name(name.into()),
7457 value_type: vt,
7458 categories: vec![p],
7459 kind: MeasureKind::Input,
7460 description: None,
7461 });
7462 }
7463 let cell = improv_core_model::Coordinate::from_pairs([(p, ItemId(10))]);
7464 m.set_input(MeasureId(1), cell.clone(), Value::Number(7.0));
7465 m.set_input(MeasureId(2), cell.clone(), Value::Text("hello".into()));
7466 m.set_input(MeasureId(3), cell.clone(), Value::Boolean(true));
7467 m.set_input(MeasureId(4), cell, date("2025-03-04"));
7468 m
7469 }
7470
7471 fn typed_key() -> CoordKey {
7472 vec![(1u32, 10u32)]
7473 }
7474
7475 #[test]
7480 fn typed_edits_recompute_their_dependents_live() {
7481 let mut m = typed_model();
7482 let p = CategoryId(1);
7483 let derived = |id: u32, name: &str, expr: Expr| Measure {
7484 id: MeasureId(id),
7485 name: Name(name.into()),
7486 value_type: ValueType::Boolean,
7487 categories: vec![p],
7488 kind: MeasureKind::Derived(Formula::new(expr)),
7489 description: None,
7490 };
7491 m.add_measure(derived(
7492 10,
7493 "IsGo",
7494 Expr::BinaryOp(
7495 improv_core_model::BinaryOp::Eq,
7496 Box::new(Expr::Ref(MeasureId(2), Default::default())),
7497 Box::new(Expr::Literal(Value::Text("go".into()))),
7498 ),
7499 ));
7500 m.add_measure(derived(
7501 11,
7502 "Idle",
7503 Expr::UnaryOp(
7504 improv_core_model::UnaryOp::Not,
7505 Box::new(Expr::Ref(MeasureId(3), Default::default())),
7506 ),
7507 ));
7508 let mut app = build_app(m);
7509 let key = typed_key();
7510 let shown = |app: &ImprovApp, id| app.cell_text(MeasureId(id), &key);
7511 assert_eq!(shown(&app, 10).as_deref(), Some("false"));
7512 assert_eq!(shown(&app, 11).as_deref(), Some("false"));
7513 app.commit_cell_text(MeasureId(2), key.clone(), "go")
7514 .unwrap();
7515 app.commit_cell_text(MeasureId(3), key.clone(), "false")
7516 .unwrap();
7517 assert_eq!(
7518 shown(&app, 10).as_deref(),
7519 Some("true"),
7520 "text edit recomputed"
7521 );
7522 assert_eq!(
7523 shown(&app, 11).as_deref(),
7524 Some("true"),
7525 "bool edit recomputed"
7526 );
7527 app.commit_cell_text(MeasureId(2), key.clone(), "").unwrap();
7529 assert_eq!(shown(&app, 10), None);
7530 }
7531
7532 #[test]
7535 fn typed_input_cells_render_their_values() {
7536 let app = build_app(typed_model());
7537 let key = typed_key();
7538 assert_eq!(app.cell_text(MeasureId(1), &key).as_deref(), Some("7"));
7539 assert_eq!(app.cell_text(MeasureId(2), &key).as_deref(), Some("hello"));
7540 assert_eq!(app.cell_text(MeasureId(3), &key).as_deref(), Some("true"));
7541 assert_eq!(
7542 app.cell_text(MeasureId(4), &key).as_deref(),
7543 Some("2025-03-04T00:00:00+00:00")
7544 );
7545 assert_eq!(app.cell_text(MeasureId(2), &vec![(1u32, 99u32)]), None);
7547 }
7548
7549 #[test]
7552 fn every_declared_type_round_trips_through_an_edit() {
7553 let mut app = build_app(typed_model());
7554 let key = typed_key();
7555 let coord = decode(&key);
7556 let cases: Vec<(MeasureId, &str, Value)> = vec![
7557 (MeasureId(1), "12.5", Value::Number(12.5)),
7558 (
7559 MeasureId(2),
7560 "widget label",
7561 Value::Text("widget label".into()),
7562 ),
7563 (MeasureId(3), "false", Value::Boolean(false)),
7564 (MeasureId(4), "2026-01-02", date("2026-01-02")),
7565 ];
7566 for (m, text, want) in cases {
7567 assert_eq!(
7568 app.commit_cell_text(m, key.clone(), text).as_deref(),
7569 Ok("cell updated"),
7570 "measure {} rejected {text:?}",
7571 m.0
7572 );
7573 assert_eq!(
7574 app.model.input(m, &coord),
7575 Some(&want),
7576 "measure {} stored the wrong variant",
7577 m.0
7578 );
7579 assert_eq!(
7580 app.cell_text(m, &key),
7581 Some(
7582 CellValue::from_model_value(&want)
7583 .expect("typed cell")
7584 .to_string()
7585 ),
7586 "measure {} reads back differently than it stored",
7587 m.0
7588 );
7589 }
7590 }
7591
7592 #[test]
7596 fn text_measure_never_stores_a_number() {
7597 let mut app = build_app(typed_model());
7598 let key = typed_key();
7599 let coord = decode(&key);
7600 for text in ["hello", "42"] {
7601 app.commit_cell_text(MeasureId(2), key.clone(), text)
7602 .expect("text commit");
7603 assert_eq!(
7604 app.model.input(MeasureId(2), &coord),
7605 Some(&Value::Text(text.into())),
7606 "{text:?} must stay Text for a Text-declared measure"
7607 );
7608 }
7609 let err = app
7611 .set_cell(MeasureId(2), key, Value::Number(42.0))
7612 .expect_err("a Text measure must refuse a Number");
7613 assert!(err.contains("declared Text"), "got {err:?}");
7614 }
7615
7616 #[test]
7619 fn invalid_typed_input_is_rejected_and_prior_value_survives() {
7620 let mut app = build_app(typed_model());
7621 let key = typed_key();
7622 let coord = decode(&key);
7623 let cases = [
7624 (MeasureId(1), "abc", Value::Number(7.0), "not a number"),
7625 (MeasureId(3), "maybe", Value::Boolean(true), "not a boolean"),
7626 (MeasureId(4), "not-a-date", date("2025-03-04"), "not a date"),
7627 ];
7628 for (m, bad, prior, msg) in cases {
7629 let err = app
7630 .commit_cell_text(m, key.clone(), bad)
7631 .expect_err("invalid input must be rejected");
7632 assert!(err.contains(msg), "measure {} said {err:?}", m.0);
7633 assert_eq!(
7634 app.model.input(m, &coord),
7635 Some(&prior),
7636 "measure {} lost its prior value to a rejected edit",
7637 m.0
7638 );
7639 }
7640 }
7641
7642 #[test]
7645 fn empty_commit_clears_the_cell() {
7646 let mut app = build_app(typed_model());
7647 let key = typed_key();
7648 let coord = decode(&key);
7649 assert_eq!(
7650 app.commit_cell_text(MeasureId(2), key.clone(), " ")
7651 .as_deref(),
7652 Ok("cell cleared")
7653 );
7654 assert_eq!(app.model.input(MeasureId(2), &coord), None);
7655 assert_eq!(app.cell_text(MeasureId(2), &key), None, "cleared = blank");
7656 assert!(app.commit_cell_text(MeasureId(2), key, "").is_ok());
7658 }
7659
7660 #[test]
7663 fn clearing_a_numeric_cell_retracts_it_from_the_engine() {
7664 let mut app = build_app(revenue_model());
7665 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
7666 key.sort();
7667 assert_eq!(
7668 app.values_for(MeasureId(102)).get(&key),
7669 Some(&70.0),
7670 "Revenue = Price(10) * Quantity(7)"
7671 );
7672 app.clear_cell(MeasureId(101), key.clone())
7673 .expect("clear input cell");
7674 assert_eq!(
7675 app.values_for(MeasureId(102)).get(&key),
7676 None,
7677 "a cleared input must not leave a stale derived cell"
7678 );
7679 }
7680
7681 #[test]
7685 fn retyping_a_numeric_cell_as_text_retracts_it_from_the_engine() {
7686 let mut app = build_app(revenue_model());
7691 assert!(app.engine.is_some(), "the fixture needs a live engine");
7692 app.model
7693 .measures
7694 .get_mut(&MeasureId(101))
7695 .expect("Quantity")
7696 .value_type = ValueType::Text;
7697 let mut key = vec![(1u32, 10u32), (2u32, 20u32)];
7698 key.sort();
7699 assert_eq!(
7700 app.values_for(MeasureId(102)).get(&key),
7701 Some(&70.0),
7702 "the engine starts out computing from the numeric cell"
7703 );
7704 app.commit_cell_text(MeasureId(101), key.clone(), "n/a")
7705 .expect("text commit");
7706 assert_eq!(
7707 app.cell_text(MeasureId(101), &key).as_deref(),
7708 Some("n/a"),
7709 "the text value is what the grid shows"
7710 );
7711 let revenue = app.values_for(MeasureId(102)).get(&key).copied();
7714 assert!(
7715 !revenue.is_some_and(f64::is_finite),
7716 "derived cell must not keep computing from the retracted number: {revenue:?}"
7717 );
7718 }
7719
7720 #[test]
7723 fn failed_save_rolls_back_a_typed_edit() {
7724 let mut app = build_app(typed_model());
7725 app.db = "/nonexistent-dir-improv/cannot-write.db".to_string();
7726 let key = typed_key();
7727 let coord = decode(&key);
7728
7729 let err = app
7730 .commit_cell_text(MeasureId(2), key.clone(), "replacement")
7731 .expect_err("save to an unwritable path must fail");
7732 assert!(err.starts_with("save failed:"), "got {err:?}");
7733 assert_eq!(
7734 app.model.input(MeasureId(2), &coord),
7735 Some(&Value::Text("hello".into())),
7736 "a failed save must not leave the typed edit in memory"
7737 );
7738
7739 let err = app
7740 .commit_cell_text(MeasureId(2), key, "")
7741 .expect_err("save must fail");
7742 assert!(err.starts_with("save failed:"), "got {err:?}");
7743 assert_eq!(
7744 app.model.input(MeasureId(2), &coord),
7745 Some(&Value::Text("hello".into())),
7746 "a failed save must not leave the cell cleared in memory"
7747 );
7748 }
7749
7750 #[test]
7753 fn begin_edit_seeds_the_typed_display_text() {
7754 let mut app = build_app(typed_model());
7755 select(&mut app, MeasureId(2)); app.sync_axis_state();
7757 app.begin_edit_cursor();
7758 assert_eq!(
7759 app.focused().editing.as_ref().map(|(m, _)| *m),
7760 Some(MeasureId(2))
7761 );
7762 assert_eq!(app.focused().edit_buf, "hello", "text cell seeded blank");
7763 }
7764
7765 #[test]
7772 fn grid_keys_are_enabled_only_when_nothing_owns_the_keyboard() {
7773 assert!(grid_keys_enabled(false, false), "idle grid: keys act");
7774 assert!(!grid_keys_enabled(true, false), "cell editor open");
7775 assert!(
7776 !grid_keys_enabled(false, true),
7777 "another text field is focused: it must get the keystroke"
7778 );
7779 assert!(!grid_keys_enabled(true, true), "cell editor focused");
7780 }
7781
7782 fn frame_with_keys(
7790 app: &mut ImprovApp,
7791 keys: &[egui::Key],
7792 focus: Option<&str>,
7793 ) -> (Option<MeasureId>, (usize, usize), usize) {
7794 let mi = app.focus_index();
7795 frame_with_keys_for(app, mi, keys, focus)
7796 }
7797
7798 fn frame_with_keys_for(
7800 app: &mut ImprovApp,
7801 mi: usize,
7802 keys: &[egui::Key],
7803 focus: Option<&str>,
7804 ) -> (Option<MeasureId>, (usize, usize), usize) {
7805 let ctx = egui::Context::default();
7806 let raw = egui::RawInput {
7807 events: keys
7808 .iter()
7809 .map(|k| egui::Event::Key {
7810 key: *k,
7811 physical_key: None,
7812 pressed: true,
7813 repeat: false,
7814 modifiers: egui::Modifiers::NONE,
7815 })
7816 .collect(),
7817 ..Default::default()
7818 };
7819 let _ = ctx.run(raw, |ctx| {
7820 if let Some(id) = focus {
7821 ctx.memory_mut(|m| m.request_focus(egui::Id::new(id)));
7823 }
7824 egui::CentralPanel::default().show(ctx, |ui| {
7825 app.handle_grid_keys(ui, mi);
7826 });
7827 });
7828 (
7829 app.selected(),
7830 (app.focused().cursor_row, app.focused().cursor_col),
7831 app.focused().page_idx.first().copied().unwrap_or(0),
7832 )
7833 }
7834
7835 #[test]
7840 fn focused_text_field_elsewhere_swallows_grid_shortcuts() {
7841 let mut app = build_app(sales_3d_model());
7842 select(&mut app, MeasureId(200));
7843 app.sync_axis_state();
7844 let before = (
7845 app.selected(),
7846 (app.focused().cursor_row, app.focused().cursor_col),
7847 0,
7848 );
7849
7850 use egui::Key;
7851 let keys = [Key::N, Key::J, Key::L, Key::CloseBracket, Key::Enter];
7852 for field in ["formula_edit", "csv_path", "view_name"] {
7853 let after = frame_with_keys(&mut app, &keys, Some(field));
7854 assert_eq!(after, before, "{field} focused, yet the grid reacted");
7855 assert!(
7856 app.focused().editing.is_none(),
7857 "{field} focused, yet Enter opened a cell editor"
7858 );
7859 }
7860 }
7861
7862 #[test]
7865 fn grid_shortcuts_work_again_once_focus_is_released() {
7866 let mut app = build_app(sales_3d_model());
7867 select(&mut app, MeasureId(200)); app.sync_axis_state();
7869
7870 use egui::Key;
7872 let (_, _, page) = frame_with_keys(&mut app, &[Key::CloseBracket], Some("some_text_field"));
7873 assert_eq!(page, 0, "paged while a text field had focus");
7874
7875 let (_, cursor, page) = frame_with_keys(&mut app, &[Key::CloseBracket], None);
7878 assert_eq!(page, 1, "`]` must page the first page dimension again");
7879 assert_eq!(cursor, (0, 0));
7880 }
7881
7882 #[test]
7885 fn cell_editing_still_works_while_editing() {
7886 let mut app = build_app(grid_2x2_model());
7887 select(&mut app, MeasureId(101)); app.sync_axis_state();
7889
7890 use egui::Key;
7892 frame_with_keys(&mut app, &[Key::Enter], None);
7893 let editing = app.focused().editing.clone();
7894 assert!(
7895 editing.is_some(),
7896 "Enter must begin editing the cursor cell"
7897 );
7898
7899 app.focused_mut().edit_buf = "7".into();
7902 let after = frame_with_keys(&mut app, &[Key::N, Key::J, Key::L], Some("cell_editor"));
7903 assert_eq!(after.1, (0, 0), "cursor moved under the open editor");
7904 assert_eq!(
7905 app.focused().editing,
7906 editing,
7907 "the open editor was disturbed"
7908 );
7909 assert_eq!(app.focused().edit_buf, "7");
7910
7911 let key = app.cursor_key().expect("cursor addresses a cell");
7913 let msg = app
7914 .commit_cell_text(MeasureId(101), key.clone(), &app.focused().edit_buf.clone())
7915 .expect("commit");
7916 assert_eq!(msg, "cell updated");
7917 assert_eq!(app.cell_text(MeasureId(101), &key).as_deref(), Some("7"));
7918 }
7919
7920 #[test]
7924 fn undo_shortcut_honors_the_focus_gate() {
7925 let mut app = build_app(grid_2x2_model());
7926 select(&mut app, MeasureId(101));
7927 app.sync_axis_state();
7928 let key = app.cursor_key().expect("cell");
7929 app.set_cell(MeasureId(101), key.clone(), Value::Number(999.0))
7930 .expect("edit");
7931 assert!(app.can_undo());
7932
7933 let press_undo = |app: &mut ImprovApp, focus: Option<&str>| {
7934 let ctx = egui::Context::default();
7935 let raw = egui::RawInput {
7936 modifiers: egui::Modifiers::COMMAND,
7937 events: vec![egui::Event::Key {
7938 key: egui::Key::Z,
7939 physical_key: None,
7940 pressed: true,
7941 repeat: false,
7942 modifiers: egui::Modifiers::COMMAND,
7943 }],
7944 ..Default::default()
7945 };
7946 let _ = ctx.run(raw, |ctx| {
7947 if let Some(id) = focus {
7948 ctx.memory_mut(|m| m.request_focus(egui::Id::new(id)));
7949 }
7950 app.handle_command_keys(ctx);
7951 });
7952 };
7953
7954 press_undo(&mut app, Some("formula_edit"));
7955 assert_eq!(
7956 app.cell_text(MeasureId(101), &key).as_deref(),
7957 Some("999"),
7958 "Ctrl+Z fired while typing in another field"
7959 );
7960
7961 press_undo(&mut app, None);
7962 assert_eq!(
7963 app.cell_text(MeasureId(101), &key).as_deref(),
7964 Some("100"),
7965 "Ctrl+Z with no focus must undo"
7966 );
7967 }
7968
7969 fn q_and_rev_keys() -> (CoordKey, CoordKey) {
7973 let mut k = vec![(1u32, 10u32), (2, 20)];
7974 k.sort();
7975 (k.clone(), k)
7976 }
7977
7978 #[test]
7979 fn undo_restores_the_cell_and_the_engine_snapshot() {
7980 let mut app = build_app(grid_2x2_model());
7981 let (qkey, rkey) = q_and_rev_keys();
7982 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&1000.0));
7984
7985 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(200.0))
7986 .expect("edit");
7987 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&2000.0));
7988
7989 app.undo().expect("undo");
7990 assert_eq!(
7991 app.cell_text(MeasureId(101), &qkey).as_deref(),
7992 Some("100"),
7993 "undo must restore the prior cell value"
7994 );
7995 assert_eq!(
7996 app.values_for(MeasureId(102)).get(&rkey),
7997 Some(&1000.0),
7998 "the engine snapshot must agree with the restored model"
7999 );
8000
8001 app.redo().expect("redo");
8002 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("200"));
8003 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&2000.0));
8004 }
8005
8006 #[test]
8007 fn undo_restores_a_cleared_cell() {
8008 let mut app = build_app(grid_2x2_model());
8009 let (qkey, rkey) = q_and_rev_keys();
8010 app.clear_cell(MeasureId(101), qkey.clone()).expect("clear");
8011 assert_eq!(app.cell_text(MeasureId(101), &qkey), None);
8012
8013 app.undo().expect("undo");
8014 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("100"));
8015 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&1000.0));
8016 }
8017
8018 #[test]
8019 fn undo_a_formula_commit_and_a_new_derived_measure() {
8020 let mut app = build_app(revenue_model());
8021 let mut rkey = vec![(1u32, 10u32), (2, 20)];
8022 rkey.sort();
8023 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&70.0));
8024
8025 app.commit_formula(MeasureId(102), "Price + Quantity")
8026 .expect("commit");
8027 assert_eq!(app.values_for(MeasureId(102)).get(&rkey), Some(&17.0));
8028
8029 app.undo().expect("undo");
8030 assert_eq!(
8031 app.formula_source(MeasureId(102)).as_deref(),
8032 Some("Price * Quantity"),
8033 "undo must restore the prior formula"
8034 );
8035 assert_eq!(
8036 app.values_for(MeasureId(102)).get(&rkey),
8037 Some(&70.0),
8038 "the engine must be rebuilt from the restored formula"
8039 );
8040
8041 let id = app
8043 .add_derived_measure("Margin", "Price - Quantity")
8044 .expect("add");
8045 assert!(app.model.measures.contains_key(&id));
8046 app.undo().expect("undo");
8047 assert!(
8048 app.model.measure_by_name("Margin").is_none(),
8049 "undo must remove the added measure"
8050 );
8051 assert!(
8052 !app.snapshot.contains_key(&id),
8053 "the snapshot must not keep values for a measure that is gone"
8054 );
8055 assert_eq!(app.selected(), Some(MeasureId(102)), "selection re-pointed");
8056 }
8057
8058 #[test]
8059 fn undo_a_view_save() {
8060 let mut app = build_app(revenue_model());
8061 let id = app.save_view("L1").expect("saved");
8062 assert!(app.model.views.contains_key(&id));
8063 app.undo().expect("undo");
8064 assert!(app.model.views.is_empty(), "undo must drop the saved view");
8065 }
8066
8067 #[test]
8070 fn undo_a_csv_import_removes_what_it_created() {
8071 let path = std::env::temp_dir().join(format!(
8072 "improv_gui_undo_import_{}_{}.csv",
8073 std::process::id(),
8074 std::time::SystemTime::now()
8075 .duration_since(std::time::UNIX_EPOCH)
8076 .expect("clock")
8077 .as_nanos()
8078 ));
8079 std::fs::write(&path, "region,amount\nNorth,5\nSouth,7\n").expect("write csv");
8080 struct Cleanup(std::path::PathBuf);
8081 impl Drop for Cleanup {
8082 fn drop(&mut self) {
8083 let _ = std::fs::remove_file(&self.0);
8084 }
8085 }
8086 let _cleanup = Cleanup(path.clone());
8087
8088 let mut app = build_app(inputs_only_model());
8089 let before = app.model.clone();
8090 app.import_form = ImportForm {
8091 path: path.to_string_lossy().into_owned(),
8092 tsv: false,
8093 has_header: true,
8094 measure_id: "500".into(),
8095 measure_name: "Imported".into(),
8096 value_column: "amount".into(),
8097 dimensions: vec![csv_wizard::DimRow {
8098 column: "region".into(),
8099 category_id: "9".into(),
8100 category_name: "Region".into(),
8101 }],
8102 };
8103 app.run_csv_import();
8104 assert!(
8105 app.status.starts_with("imported 2 cell(s)"),
8106 "import failed: {}",
8107 app.status
8108 );
8109 assert!(app.model.measures.contains_key(&MeasureId(500)));
8110 assert!(app.model.categories.contains_key(&CategoryId(9)));
8111 let imported_items = app.model.items.len();
8112 assert!(imported_items > before.items.len(), "items were minted");
8113
8114 app.undo().expect("undo");
8115 assert!(
8116 !app.model.measures.contains_key(&MeasureId(500)),
8117 "undo must remove the imported measure"
8118 );
8119 assert!(
8120 !app.model.categories.contains_key(&CategoryId(9)),
8121 "undo must remove the category the import created"
8122 );
8123 assert_eq!(app.model, before, "undo must restore the pre-import model");
8124 assert_eq!(
8125 app.selected(),
8126 pick_default_measure(&before),
8127 "selection must leave the measure that no longer exists"
8128 );
8129
8130 app.redo().expect("redo");
8131 assert!(app.model.measures.contains_key(&MeasureId(500)));
8132 assert_eq!(app.model.items.len(), imported_items);
8133 }
8134
8135 #[test]
8136 fn undo_at_the_bottom_of_the_stack_is_a_no_op() {
8137 let mut app = build_app(revenue_model());
8138 let before = app.model.clone();
8139 assert!(!app.can_undo());
8140 app.undo().expect("undo on an empty history is a no-op");
8141 app.redo().expect("redo on an empty history is a no-op");
8142 assert_eq!(app.model, before);
8143 let (qkey, _) = q_and_rev_keys();
8145 app.set_cell(MeasureId(101), qkey, Value::Number(1.0))
8146 .expect("edit");
8147 app.undo().expect("undo");
8148 assert!(!app.can_undo());
8149 app.undo().expect("still a no-op, not a panic");
8150 assert_eq!(app.model, before);
8151 }
8152
8153 #[test]
8154 fn history_depth_is_bounded_and_evicts_the_oldest() {
8155 let mut app = build_app(grid_2x2_model());
8156 let (qkey, _) = q_and_rev_keys();
8157 for i in 1..=(UNDO_DEPTH + 5) {
8159 app.set_cell(
8160 MeasureId(101),
8161 qkey.clone(),
8162 Value::Number(1000.0 + i as f64),
8163 )
8164 .expect("edit");
8165 }
8166 assert_eq!(app.undo_stack.len(), UNDO_DEPTH, "depth bound not enforced");
8167
8168 while app.can_undo() {
8171 app.undo().expect("undo");
8172 }
8173 assert_eq!(
8174 app.cell_text(MeasureId(101), &qkey).as_deref(),
8175 Some("1005"),
8176 "the oldest retained undo point should be the state after edit 5"
8177 );
8178 }
8179
8180 #[test]
8181 fn a_new_mutation_after_undo_clears_the_redo_stack() {
8182 let mut app = build_app(grid_2x2_model());
8183 let (qkey, _) = q_and_rev_keys();
8184 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(200.0))
8185 .expect("edit");
8186 app.undo().expect("undo");
8187 assert!(app.can_redo(), "undo must make the step redoable");
8188
8189 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(300.0))
8190 .expect("edit");
8191 assert!(
8192 !app.can_redo(),
8193 "a fresh mutation must make the undone future unreachable"
8194 );
8195 app.undo().expect("undo");
8196 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("100"));
8197 }
8198
8199 #[test]
8201 fn undo_is_saved_to_the_store() {
8202 let db = std::env::temp_dir()
8203 .join(format!(
8204 "improv_gui_undo_persist_{}_{}.db",
8205 std::process::id(),
8206 std::time::SystemTime::now()
8207 .duration_since(std::time::UNIX_EPOCH)
8208 .expect("clock")
8209 .as_nanos()
8210 ))
8211 .to_string_lossy()
8212 .into_owned();
8213 struct Cleanup(String);
8214 impl Drop for Cleanup {
8215 fn drop(&mut self) {
8216 let _ = std::fs::remove_file(&self.0);
8217 }
8218 }
8219 let _cleanup = Cleanup(db.clone());
8220
8221 Store::open(&db)
8222 .and_then(|mut s| s.save_model(&grid_2x2_model()))
8223 .expect("seed the store");
8224 let mut app = ImprovApp::load(&db).expect("load");
8225 let (qkey, _) = q_and_rev_keys();
8226
8227 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(200.0))
8228 .expect("edit");
8229 app.undo().expect("undo");
8230
8231 let reloaded = Store::open(&db)
8232 .and_then(|mut s| s.load_model())
8233 .expect("reload");
8234 assert_eq!(
8235 reloaded.input(MeasureId(101), &decode(&qkey)),
8236 Some(&Value::Number(100.0)),
8237 "the undone value was left in the store"
8238 );
8239 }
8240
8241 #[test]
8244 fn a_failed_save_during_undo_is_reported_and_keeps_the_step() {
8245 let mut app = build_app(grid_2x2_model());
8246 let (qkey, _) = q_and_rev_keys();
8247 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(200.0))
8248 .expect("edit");
8249
8250 app.db = std::env::temp_dir()
8251 .join(format!("improv_gui_no_such_dir_{}", std::process::id()))
8252 .join("model.db")
8253 .to_string_lossy()
8254 .into_owned();
8255 let err = app
8256 .undo()
8257 .expect_err("an unwritable store must fail the undo");
8258 assert!(err.starts_with("save failed:"), "got {err:?}");
8259 assert_eq!(
8260 app.cell_text(MeasureId(101), &qkey).as_deref(),
8261 Some("200"),
8262 "a failed undo must leave the live state alone"
8263 );
8264 assert!(app.can_undo(), "the undo point must survive a failed undo");
8265
8266 app.undo_with_status();
8268 assert!(app.status.starts_with("undo failed:"), "{}", app.status);
8269 }
8270
8271 fn layout_harness() -> (
8285 egui::Context,
8286 impl FnMut(&egui::Context, &mut ImprovApp) -> GutterRects,
8287 ) {
8288 let ctx = egui::Context::default();
8289 crate::theme::apply(&ctx);
8290 let mut time = 0.0_f64;
8291 let pass = move |ctx: &egui::Context, app: &mut ImprovApp| {
8292 time += 1.0 / 60.0;
8293 let _ = full_frame(ctx, app, egui::vec2(1200.0, 800.0), time);
8294 app.focused()
8295 .gutters
8296 .expect("the focused matrix must record its gutters")
8297 };
8298 (ctx, pass)
8299 }
8300
8301 fn full_frame(
8303 ctx: &egui::Context,
8304 app: &mut ImprovApp,
8305 size: egui::Vec2,
8306 time: f64,
8307 ) -> egui::FullOutput {
8308 full_frame_with(ctx, app, size, time, Vec::new(), |_| ())
8309 }
8310
8311 fn full_frame_with(
8314 ctx: &egui::Context,
8315 app: &mut ImprovApp,
8316 size: egui::Vec2,
8317 time: f64,
8318 events: Vec<egui::Event>,
8319 mut inspect: impl FnMut(&egui::Context),
8320 ) -> egui::FullOutput {
8321 let raw = egui::RawInput {
8322 time: Some(time),
8323 screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
8324 modifiers: events
8326 .iter()
8327 .find_map(|e| match e {
8328 egui::Event::Key { modifiers, .. }
8329 | egui::Event::PointerButton { modifiers, .. } => Some(*modifiers),
8330 _ => None,
8331 })
8332 .unwrap_or_default(),
8333 events,
8334 ..Default::default()
8335 };
8336 ctx.run(raw, |ctx| {
8337 app.sync_axis_state();
8338 app.sync_native_title(ctx);
8339 app.status_bar(ctx);
8340 app.chart_panel(ctx);
8341 app.csv_wizard_panel(ctx);
8342 let ws = app.canvas_panel(ctx);
8343 app.model_browser(ctx, ws);
8344 app.info_panel(ctx, ws);
8345 app.function_help(ctx, ws);
8346 app.menu_bar(ctx, ws);
8347 inspect(ctx);
8348 })
8349 }
8350
8351 #[test]
8354 fn item_headers_are_plain_text() {
8355 let mut app = build_app(revenue_model());
8356 select(&mut app, MeasureId(102));
8357 let ctx = egui::Context::default();
8358 crate::theme::apply(&ctx);
8359 let mut shapes = Vec::new();
8360 for i in 0..8 {
8361 shapes = full_frame(
8362 &ctx,
8363 &mut app,
8364 egui::vec2(1280.0, 800.0),
8365 f64::from(i) / 60.0,
8366 )
8367 .shapes;
8368 }
8369 for header in ["WidgetA", "2025"] {
8371 let (rect, color) = shapes
8372 .iter()
8373 .find_map(|c| match &c.shape {
8374 egui::Shape::Text(t) if t.galley.text() == header => Some((
8375 t.galley.rect.translate(t.pos.to_vec2()),
8376 t.galley.job.sections[0].format.color,
8377 )),
8378 _ => None,
8379 })
8380 .unwrap_or_else(|| panic!("header {header:?} was not drawn"));
8381 assert!(
8382 color.r() < 0x60 && color.g() < 0x60 && color.b() < 0x60,
8383 "header {header:?} must be dark body text, got {color:?}"
8384 );
8385 let chip = shapes.iter().any(|c| match &c.shape {
8386 egui::Shape::Rect(r) => {
8389 r.fill == crate::theme::DARK_GRAY
8390 && r.rect.contains_rect(rect)
8391 && r.rect.height() < 4.0 * rect.height()
8392 }
8393 _ => false,
8394 });
8395 assert!(!chip, "header {header:?} sits on a beveled chip");
8396 }
8397 }
8398
8399 #[test]
8403 fn numeric_cells_right_align() {
8404 let (t, p) = (CategoryId(1), CategoryId(2));
8405 let mut m = Model::new();
8406 m.add_category(t, "Time");
8407 m.add_category(p, "Product");
8408 m.add_item(ItemId(10), t, "Q1");
8409 m.add_item(ItemId(11), t, "Q2");
8410 m.add_item(ItemId(20), p, "W");
8411 for (id, name, ty) in [(1, "N", ValueType::Number), (2, "S", ValueType::Text)] {
8412 m.add_measure(Measure {
8413 id: MeasureId(id),
8414 name: Name(name.into()),
8415 value_type: ty,
8416 categories: vec![t, p],
8417 kind: MeasureKind::Input,
8418 description: None,
8419 });
8420 }
8421 let at = |q| improv_core_model::Coordinate::from_pairs([(t, ItemId(q)), (p, ItemId(20))]);
8422 m.set_input(MeasureId(1), at(10), Value::Number(5.0));
8423 m.set_input(MeasureId(1), at(11), Value::Number(12345.5));
8424 m.set_input(MeasureId(2), at(10), Value::Text("a".into()));
8425 m.set_input(MeasureId(2), at(11), Value::Text("abcdefgh".into()));
8426
8427 let extent = |shapes: &[egui::epaint::ClippedShape], s: &str| {
8429 shapes
8430 .iter()
8431 .find_map(|c| match &c.shape {
8432 egui::Shape::Text(t) if t.galley.text() == s => {
8433 let r = t.galley.rect.translate(t.pos.to_vec2());
8434 Some((r.min.x, r.max.x))
8435 }
8436 _ => None,
8437 })
8438 .unwrap_or_else(|| panic!("{s:?} was not drawn"))
8439 };
8440 let mut app = build_app(m);
8441 let mut lay_out = |measure, short, long| {
8442 select(&mut app, MeasureId(measure));
8443 let ctx = egui::Context::default();
8444 crate::theme::apply(&ctx);
8445 let mut shapes = Vec::new();
8446 for i in 0..8 {
8447 shapes = full_frame(
8448 &ctx,
8449 &mut app,
8450 egui::vec2(1280.0, 800.0),
8451 f64::from(i) / 60.0,
8452 )
8453 .shapes;
8454 }
8455 (extent(&shapes, short), extent(&shapes, long))
8456 };
8457 let (num_short, num_long) = lay_out(1, "5", "12345.5");
8458 let (txt_short, txt_long) = lay_out(2, "a", "abcdefgh");
8459 assert!(
8460 (num_short.1 - num_long.1).abs() < 0.5,
8461 "numbers share a right edge: {num_short:?} vs {num_long:?}"
8462 );
8463 assert!(
8464 (txt_short.0 - txt_long.0).abs() < 0.5 && (txt_short.1 - txt_long.1).abs() > 0.5,
8465 "text stays left-aligned: {txt_short:?} vs {txt_long:?}"
8466 );
8467 assert!(
8471 (num_long.0 - txt_short.0).abs() < 0.5,
8472 "the numeric column grew past its content: {num_long:?} starts after {txt_short:?}"
8473 );
8474 }
8475
8476 #[test]
8482 fn window_content_never_paints_outside_its_window() {
8483 for size in [
8484 egui::Vec2::from(crate::app::MIN_WINDOW),
8485 egui::vec2(800.0, 600.0),
8486 ] {
8487 let mut app = gutter_app();
8488 app.add_matrix(app.focused().measure);
8489 for m in &mut app.matrices {
8492 (m.rect.w, m.rect.h) = (120.0, 80.0);
8493 }
8494 let ctx = egui::Context::default();
8495 crate::theme::apply(&ctx);
8496 let mut shapes: Vec<Vec<egui::epaint::ClippedShape>> = Vec::new();
8497 let ids = [win_id(&app, 0), win_id(&app, 1)];
8498 for i in 0..8 {
8499 let _ = full_frame_with(
8500 &ctx,
8501 &mut app,
8502 size,
8503 f64::from(i) / 60.0,
8504 Vec::new(),
8505 |ctx| {
8506 shapes = ids
8507 .iter()
8508 .map(|&id| {
8509 let layer = egui::LayerId::new(egui::Order::Middle, id);
8510 ctx.graphics(|g| {
8511 g.get(layer)
8512 .map(|l| l.all_entries().cloned().collect())
8513 .unwrap_or_default()
8514 })
8515 })
8516 .collect();
8517 },
8518 );
8519 }
8520 let widgets = ctx.viewport(|v| v.prev_pass.widgets.clone());
8521 for (mi, shapes) in shapes.iter().enumerate() {
8522 let win = win_rect(&ctx, &app, mi).expand(1.0);
8523 assert!(shapes.len() > 20, "{size:?}: window {mi} painted its grid");
8524 for s in shapes {
8525 let seen = s.shape.visual_bounding_rect().intersect(s.clip_rect);
8526 assert!(
8527 !seen.is_positive() || win.contains_rect(seen),
8528 "{size:?}: window {mi} {win:?} paints at {seen:?}"
8529 );
8530 }
8531 let layer = egui::LayerId::new(egui::Order::Middle, ids[mi]);
8532 let mut n = 0;
8533 for w in widgets.get_layer(layer) {
8534 n += 1;
8535 assert!(
8536 !w.sense.interactive()
8537 || !w.interact_rect.is_positive()
8538 || win.contains_rect(w.interact_rect),
8539 "{size:?}: window {mi} {win:?} takes input at {:?}",
8540 w.interact_rect
8541 );
8542 }
8543 assert!(n > 5, "{size:?}: window {mi} registered its widgets");
8544 }
8545 }
8546 }
8547
8548 fn layout_all(app: &mut ImprovApp) -> Vec<GutterRects> {
8554 let (ctx, mut pass) = layout_harness();
8555 for _ in 0..8 {
8556 let _ = pass(&ctx, app);
8557 }
8558 app.matrices
8559 .iter()
8560 .enumerate()
8561 .map(|(i, m)| {
8562 m.gutters
8563 .unwrap_or_else(|| panic!("matrix {i} recorded no gutters"))
8564 })
8565 .collect()
8566 }
8567
8568 fn settle(
8576 ctx: &egui::Context,
8577 app: &mut ImprovApp,
8578 pass: &mut impl FnMut(&egui::Context, &mut ImprovApp) -> GutterRects,
8579 ) -> GutterRects {
8580 let mut prev = pass(ctx, app);
8581 for _ in 0..20 {
8582 let now = pass(ctx, app);
8583 if now == prev {
8584 return now;
8585 }
8586 prev = now;
8587 }
8588 panic!("the gutter layout never settled (it drifts every frame): {prev:?}");
8589 }
8590
8591 fn layout_frame(app: &mut ImprovApp) -> GutterRects {
8593 let (ctx, mut pass) = layout_harness();
8594 settle(&ctx, app, &mut pass)
8595 }
8596
8597 fn gutter_app() -> ImprovApp {
8600 let mut app = build_app(sales_3d_model());
8601 select(&mut app, MeasureId(200));
8602 app.sync_axis_state();
8603 app
8604 }
8605
8606 #[test]
8615 fn gutters_do_not_creep_across_frames_or_repivots() {
8616 let mut app = gutter_app();
8617 app.set_axis(CategoryId(3), Axis::Rows);
8620 let (ctx, mut pass) = layout_harness();
8621 let one = settle(&ctx, &mut app, &mut pass);
8622
8623 app.set_axis(CategoryId(3), Axis::Columns);
8624 let two = settle(&ctx, &mut app, &mut pass);
8625 assert!(
8626 two.top.height() > one.top.height(),
8627 "a second stacked column tile must make the gutter taller"
8628 );
8629
8630 app.set_axis(CategoryId(3), Axis::Rows);
8631 let back = settle(&ctx, &mut app, &mut pass);
8632 assert_eq!(
8633 back.top.height(),
8634 one.top.height(),
8635 "the column gutter must shrink back, not latch open"
8636 );
8637 assert_eq!(
8638 back.well.height(),
8639 one.well.height(),
8640 "the corner well must shrink back, not ratchet"
8641 );
8642 assert!(gutters_frame_table(&back), "{back:?}");
8643 }
8644
8645 #[test]
8655 fn shrinking_the_window_never_panics_and_never_lies_about_framing() {
8656 let sizes = [
8657 (1200.0, 800.0),
8658 (900.0, 600.0),
8659 (600.0, 400.0),
8660 (300.0, 200.0),
8661 (120.0, 80.0),
8662 (10.0, 10.0),
8663 ];
8664 let mut framed_at_least_once = false;
8665 for (w, h) in sizes {
8666 let mut app = gutter_app();
8667 let ctx = egui::Context::default();
8668 crate::theme::apply(&ctx);
8669 for i in 0..8 {
8672 let raw = egui::RawInput {
8673 time: Some(f64::from(i) / 60.0),
8674 screen_rect: Some(egui::Rect::from_min_size(
8675 egui::Pos2::ZERO,
8676 egui::vec2(w, h),
8677 )),
8678 ..Default::default()
8679 };
8680 let _ = ctx.run(raw, |ctx| {
8681 app.sync_axis_state();
8682 app.canvas_panel(ctx);
8683 });
8684 }
8685 let g = app
8686 .focused()
8687 .gutters
8688 .expect("gutters recorded at every size");
8689 if gutters_have_room(&g) {
8690 framed_at_least_once = true;
8691 assert!(
8692 gutters_frame_table(&g),
8693 "{w}x{h}: there was room, so the gutters must frame the table: {g:?}"
8694 );
8695 }
8696 }
8697 assert!(
8698 framed_at_least_once,
8699 "no size had room — the test would be vacuous"
8700 );
8701 }
8702
8703 #[test]
8708 fn row_gutter_adjoins_the_tables_left_edge_and_column_gutter_its_top() {
8709 let g = layout_frame(&mut gutter_app());
8710
8711 assert!(
8712 (g.left.max.x - g.table.min.x).abs() <= EDGE_EPS,
8713 "row gutter right edge {} must BE the table's left edge {} (gutters {g:?})",
8714 g.left.max.x,
8715 g.table.min.x
8716 );
8717 assert!(
8718 (g.top.max.y - g.table.min.y).abs() <= EDGE_EPS,
8719 "column gutter bottom edge {} must BE the table's top edge {} (gutters {g:?})",
8720 g.top.max.y,
8721 g.table.min.y
8722 );
8723 assert!(
8725 g.left.min.y <= g.table.min.y && g.left.max.y > g.table.min.y,
8726 "the row gutter must span the table vertically: {g:?}"
8727 );
8728 assert!(
8729 g.top.min.x <= g.table.min.x && g.top.max.x > g.table.min.x,
8730 "the column gutter must span the table horizontally: {g:?}"
8731 );
8732 assert!(g.left.height() > 0.0, "{g:?}");
8734 assert!(g.top.width() > 0.0 && g.top.height() > 0.0, "{g:?}");
8735 assert!(gutters_frame_table(&g), "{g:?}");
8737 }
8738
8739 #[test]
8743 fn the_page_well_is_the_bottom_left_corner() {
8744 let g = layout_frame(&mut gutter_app());
8745 assert!(
8746 g.well.min.y >= g.left.max.y - EDGE_EPS,
8747 "the well must sit BELOW the framed region (the row gutter): {g:?}"
8748 );
8749 assert!(
8750 g.well.min.y >= g.table.min.y,
8751 "the well must be below the table's top, not beside it: {g:?}"
8752 );
8753 assert!(
8754 (g.well.min.x - g.left.min.x).abs() <= EDGE_EPS,
8755 "the well must be flush with the row gutter's left edge: {g:?}"
8756 );
8757 }
8758
8759 #[test]
8762 fn tiles_sit_in_their_next_gutters() {
8763 let mut app = gutter_app();
8764 app.set_axis(CategoryId(3), Axis::Pages);
8765 let mut desk = Desk::new();
8766 desk.settle(&mut app);
8767 let g = app.focused().gutters.expect("laid out");
8768 let (rows, cols, pages) = (app.row_cats(), app.col_cats(), app.page_cats());
8769 assert!(!rows.is_empty() && !cols.is_empty() && !pages.is_empty());
8770 let page_right = pages
8771 .iter()
8772 .map(|&c| tile_rect(&desk, &app, c).right())
8773 .fold(f32::MIN, f32::max);
8774 for c in rows {
8775 let t = tile_rect(&desk, &app, c);
8776 assert!(g.well.contains_rect(t), "row tile {t:?} outside {g:?}");
8777 }
8778 for c in cols {
8779 let t = tile_rect(&desk, &app, c);
8780 assert!(g.top.contains_rect(t), "col tile {t:?} outside {g:?}");
8781 assert!(t.left() > page_right, "col tile {t:?} left of pages");
8782 }
8783 }
8784
8785 #[test]
8789 fn stacked_column_tiles_grow_the_gutter_downward_and_stay_docked() {
8790 let mut app = gutter_app();
8791 app.set_axis(CategoryId(3), Axis::Rows);
8793 let one = layout_frame(&mut app);
8794 assert_eq!(app.col_cats().len(), 1);
8795
8796 app.set_axis(CategoryId(3), Axis::Columns);
8798 assert_eq!(app.col_cats().len(), 2, "two categories on the column axis");
8799 let two = layout_frame(&mut app);
8800
8801 assert!(
8802 two.top.height() > one.top.height(),
8803 "a second stacked column tile must make the gutter taller: {} -> {}",
8804 one.top.height(),
8805 two.top.height()
8806 );
8807 assert!(
8808 (two.top.max.y - two.table.min.y).abs() <= EDGE_EPS,
8809 "the taller gutter must still adjoin the table's top edge: {two:?}"
8810 );
8811 assert!(gutters_frame_table(&two), "{two:?}");
8812
8813 app.set_axis(CategoryId(3), Axis::Rows);
8817 assert_eq!(app.col_cats().len(), 1);
8818 let back = layout_frame(&mut app);
8819 assert!(
8820 back.top.height() <= one.top.height() + EDGE_EPS,
8821 "the column gutter latched open: {} vs {}",
8822 back.top.height(),
8823 one.top.height()
8824 );
8825 assert!(gutters_frame_table(&back), "{back:?}");
8826 }
8827
8828 #[test]
8831 fn gutters_still_frame_the_table_when_an_axis_is_empty() {
8832 let mut app = gutter_app();
8833 let rows = app.row_cats();
8834 hide_all_items(&mut app, rows[0]);
8835 assert_eq!(app.grid_dims().0, 0, "the row axis must render zero lines");
8836 let g = layout_frame(&mut app);
8837 assert!(gutters_frame_table(&g), "{g:?}");
8838 }
8839
8840 #[test]
8844 fn dropping_a_tile_in_another_gutter_repivots() {
8845 let mut app = gutter_app(); let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
8847 assert_eq!(app.row_cats(), vec![time]);
8848 assert_eq!(app.col_cats(), vec![product]);
8849 assert_eq!(app.page_cats(), vec![region]);
8850
8851 app.set_axis(time, Axis::Columns);
8855 assert!(app.row_cats().is_empty(), "Time must leave the row gutter");
8856 assert_eq!(
8857 app.col_cats(),
8858 vec![product, time],
8859 "Time must stack UNDER the existing column category"
8860 );
8861
8862 app.set_axis(region, Axis::Rows);
8864 assert_eq!(app.row_cats(), vec![region]);
8865 assert!(app.page_cats().is_empty(), "the well must be empty now");
8866
8867 let g = layout_frame(&mut app);
8869 assert!(gutters_frame_table(&g), "{g:?}");
8870 }
8871
8872 #[test]
8875 fn set_axis_cycles_a_category_through_all_three_axes() {
8876 let mut app = gutter_app();
8877 let time = CategoryId(1);
8878 assert_eq!(app.row_cats(), vec![time]);
8879 app.set_axis(time, Axis::Columns);
8880 assert!(app.col_cats().contains(&time));
8881 app.set_axis(time, Axis::Pages);
8882 assert!(app.page_cats().contains(&time));
8883 app.set_axis(time, Axis::Rows);
8884 assert_eq!(app.row_cats(), vec![time]);
8885 }
8886
8887 #[test]
8891 fn the_old_horizontal_shelf_fails_the_framing_invariant() {
8892 let r = |x0, y0, x1, y1| egui::Rect::from_min_max(egui::pos2(x0, y0), egui::pos2(x1, y1));
8893 let shelf = GutterRects {
8895 top: r(0.0, 0.0, 120.0, 30.0),
8896 left: r(126.0, 0.0, 246.0, 30.0),
8897 well: r(252.0, 0.0, 372.0, 30.0),
8898 table: r(0.0, 40.0, 800.0, 600.0),
8899 hbar: egui::Rect::NOTHING,
8900 };
8901 assert!(
8902 !gutters_frame_table(&shelf),
8903 "the shelf must NOT count as framing the table"
8904 );
8905 let docked = GutterRects {
8907 top: r(0.0, 0.0, 800.0, 40.0),
8908 left: r(0.0, 40.0, 120.0, 600.0),
8909 table: r(120.0, 40.0, 800.0, 600.0),
8910 well: r(0.0, 600.0, 800.0, 634.0),
8911 hbar: egui::Rect::NOTHING,
8912 };
8913 assert!(gutters_frame_table(&docked), "{docked:?}");
8914 }
8915
8916 #[test]
8920 fn a_wide_grid_scrolls_sideways_with_the_inline_scroller() {
8921 let mut m = grid_2x2_model();
8923 for i in 0..40 {
8924 m.add_item(ItemId(100 + i), CategoryId(2), format!("Product{i:02}"));
8925 }
8926 let mut app = build_app(m);
8927 select(&mut app, MeasureId(101));
8928 app.sync_axis_state();
8929 let mut desk = Desk::new();
8930 desk.settle(&mut app);
8931 let g = app.focused().gutters.expect("laid out");
8932 let win = win_rect(&desk.ctx, &app, 0);
8933 assert!(g.hbar.is_positive(), "the scroller has room: {g:?}");
8934 assert!(g.well.contains_rect(g.hbar), "inline in the bottom strip");
8935 let tile = tile_rect(&desk, &app, CategoryId(1));
8936 assert!(tile.right() <= g.hbar.left(), "right of the row tile");
8937 assert!(
8938 (g.hbar.center().y - tile.center().y).abs() < 6.0,
8939 "level with it"
8940 );
8941 assert!(g.hbar.right() <= win.right(), "inside the window");
8942
8943 let header_x = |desk: &mut Desk, app: &mut ImprovApp, name: &str| {
8944 desk.frame(app, Vec::new())
8945 .shapes
8946 .iter()
8947 .find_map(|c| match &c.shape {
8948 egui::Shape::Text(t) if t.galley.text() == name => Some(t.pos.x),
8949 _ => None,
8950 })
8951 };
8952 let first = header_x(&mut desk, &mut app, "Product00").expect("first column shown");
8953 let [_, right, _] = crate::theme::hscroller_rects(g.hbar);
8955 for _ in 0..3 {
8956 desk.click(&mut app, right.center());
8957 }
8958 let moved = header_x(&mut desk, &mut app, "Product00").expect("still shown");
8959 assert!((first - moved - 60.0).abs() < 1.0, "{first} -> {moved}");
8960 let [left, _, _] = crate::theme::hscroller_rects(g.hbar);
8964 for _ in 0..3 {
8965 desk.click(&mut app, left.center());
8966 }
8967 assert_eq!(header_x(&mut desk, &mut app, "Product00"), Some(first));
8968 let knob_from = egui::pos2(
8969 g.hbar.left() + 2.0 * crate::theme::ARROW + 4.0,
8970 g.hbar.center().y,
8971 );
8972 desk.drag(
8973 &mut app,
8974 knob_from,
8975 egui::pos2(win.right() + 400.0, g.hbar.center().y),
8976 );
8977 let last = header_x(&mut desk, &mut app, "Product39").expect("last column reachable");
8978 assert!(last < win.right(), "{last} vs {win:?}");
8979 let g2 = app.focused().gutters.expect("laid out");
8981 assert!(g2.table.right() <= win.right() + EDGE_EPS, "{g2:?} {win:?}");
8982 }
8983
8984 fn four_formula_model() -> Model {
8995 let mut m = grid_2x2_model();
8996 let (t, p) = (CategoryId(1), CategoryId(2));
8997 m.add_measure(Measure {
8998 id: MeasureId(110),
8999 name: Name("Rate".into()),
9000 value_type: ValueType::Number,
9001 categories: vec![t, p],
9002 kind: MeasureKind::Input,
9003 description: None,
9004 });
9005 for ti in [ItemId(10), ItemId(11)] {
9009 for pi in [ItemId(20), ItemId(21)] {
9010 m.set_input(
9011 MeasureId(110),
9012 improv_core_model::Coordinate::from_pairs([(t, ti), (p, pi)]),
9013 Value::Number(0.1),
9014 );
9015 }
9016 }
9017 let refm = |id| Expr::Ref(id, DimensionSpec::default());
9018 for (id, name, expr) in [
9019 (
9020 103u32,
9021 "Tax",
9022 Expr::BinaryOp(
9023 BinaryOp::Mul,
9024 Box::new(refm(MeasureId(102))),
9025 Box::new(refm(MeasureId(110))),
9026 ),
9027 ),
9028 (
9029 104,
9030 "Net",
9031 Expr::BinaryOp(
9032 BinaryOp::Sub,
9033 Box::new(refm(MeasureId(102))),
9034 Box::new(refm(MeasureId(103))),
9035 ),
9036 ),
9037 (
9038 105,
9039 "Doubled",
9040 Expr::BinaryOp(
9041 BinaryOp::Add,
9042 Box::new(refm(MeasureId(101))),
9043 Box::new(refm(MeasureId(101))),
9044 ),
9045 ),
9046 ] {
9047 m.add_measure(Measure {
9048 id: MeasureId(id),
9049 name: Name(name.into()),
9050 value_type: ValueType::Number,
9051 categories: vec![t, p],
9052 kind: MeasureKind::Derived(Formula::new(expr)),
9053 description: None,
9054 });
9055 }
9056 m
9057 }
9058
9059 #[test]
9064 fn the_formula_list_shows_every_formula_numbered() {
9065 let app = build_app(four_formula_model());
9066 let rows = app.formula_rows();
9067
9068 assert_eq!(
9071 rows.iter().map(|r| r.target.as_str()).collect::<Vec<_>>(),
9072 vec!["Revenue", "Tax", "Net", "Doubled"],
9073 "all four formulas, in measure-id order"
9074 );
9075 assert_eq!(
9076 app.model
9077 .measures
9078 .values()
9079 .filter(|m| m.is_derived())
9080 .count(),
9081 rows.len(),
9082 "one row per derived measure, no more"
9083 );
9084
9085 assert_eq!(rows[0].text, "Price * Quantity");
9087 assert_eq!(rows[1].text, "Revenue * Rate");
9088 assert_eq!(rows[2].text, "Revenue - Tax");
9089 assert_eq!(rows[3].text, "Quantity + Quantity");
9090 assert!(rows.iter().all(|r| r.error.is_none()), "{rows:?}");
9091 assert!(rows.iter().all(|r| r.enabled), "formulas start enabled");
9092
9093 let numbered: Vec<String> = rows
9096 .iter()
9097 .enumerate()
9098 .map(|(i, r)| format!("{}. {} = {}", i + 1, r.target, r.text))
9099 .collect();
9100 assert_eq!(numbered[0], "1. Revenue = Price * Quantity");
9101 assert_eq!(numbered[3], "4. Doubled = Quantity + Quantity");
9102 }
9103
9104 #[test]
9109 fn clicking_a_formula_row_selects_its_measure() {
9110 let mut app = build_app(four_formula_model());
9111 select(&mut app, MeasureId(102));
9112 app.sync_axis_state();
9113
9114 let target = MeasureId(105);
9116 assert_ne!(app.selected(), Some(target), "not already selected");
9117 let clicked = click_formula_row(&mut app, target);
9118 assert!(clicked, "row for measure {target:?} was never laid out");
9119 assert_eq!(
9120 app.selected(),
9121 Some(target),
9122 "clicking the row must select its measure so the grid follows"
9123 );
9124 app.sync_axis_state();
9126 assert_eq!(app.focused().axis_for, Some(target));
9127 }
9128
9129 fn click_formula_row(app: &mut ImprovApp, measure: MeasureId) -> bool {
9139 let ctx = egui::Context::default();
9140 crate::theme::apply(&ctx);
9141 let frame = |app: &mut ImprovApp, events: Vec<egui::Event>, time: f64| {
9142 let raw = egui::RawInput {
9143 time: Some(time),
9144 screen_rect: Some(egui::Rect::from_min_size(
9145 egui::Pos2::ZERO,
9146 egui::vec2(1200.0, 800.0),
9147 )),
9148 events,
9149 ..Default::default()
9150 };
9151 let _ = ctx.run(raw, |ctx| {
9152 app.sync_axis_state();
9153 let ws = app.canvas_panel(ctx);
9154 app.model_browser(ctx, ws);
9155 app.info_panel(ctx, ws);
9156 });
9157 };
9158
9159 for i in 0..4 {
9161 frame(app, Vec::new(), f64::from(i) / 60.0);
9162 }
9163 let Some(&(_, rect)) = app.formula_row_rects.iter().find(|(m, _)| *m == measure) else {
9164 return false;
9165 };
9166 let at = rect.center();
9167
9168 frame(
9170 app,
9171 vec![
9172 egui::Event::PointerMoved(at),
9173 egui::Event::PointerButton {
9174 pos: at,
9175 button: egui::PointerButton::Primary,
9176 pressed: true,
9177 modifiers: egui::Modifiers::NONE,
9178 },
9179 egui::Event::PointerButton {
9180 pos: at,
9181 button: egui::PointerButton::Primary,
9182 pressed: false,
9183 modifiers: egui::Modifiers::NONE,
9184 },
9185 ],
9186 1.0,
9187 );
9188 true
9189 }
9190
9191 #[test]
9195 fn formula_list_lives_in_each_worksheet_window() {
9196 let mut app = build_app(four_formula_model());
9197 let ctx = egui::Context::default();
9198 crate::theme::apply(&ctx);
9199 let size = egui::vec2(1200.0, 800.0);
9200 let check = |app: &ImprovApp, ctx: &egui::Context, mi: usize| {
9201 let win = crate::nxwin::stored_rect(ctx, matrix_window_id(app.matrices[mi].uid))
9202 .expect("window shown");
9203 let table = app.matrices[mi].gutters.expect("laid out").table;
9204 assert!(!app.formula_row_rects.is_empty(), "rows laid out");
9205 for (m, r) in &app.formula_row_rects {
9206 assert!(
9207 win.contains_rect(*r),
9208 "row {m:?} {r:?} outside window {win:?}"
9209 );
9210 assert!(
9211 r.min.y >= table.max.y,
9212 "row {m:?} {r:?} not below grid {table:?}"
9213 );
9214 }
9215 };
9216 for i in 0..4 {
9217 let _ = full_frame(&ctx, &mut app, size, f64::from(i) / 60.0);
9218 }
9219 check(&app, &ctx, 0);
9220 let last = app.add_matrix(Some(MeasureId(101)));
9221 for i in 4..8 {
9222 let _ = full_frame(&ctx, &mut app, size, f64::from(i) / 60.0);
9223 }
9224 check(&app, &ctx, last);
9225 }
9226
9227 #[test]
9242 fn disabling_a_formula_recomputes_its_dependents() {
9243 let mut app = build_app(four_formula_model());
9244 let (qkey, _) = q_and_rev_keys();
9245
9246 assert_eq!(app.values_for(MeasureId(102)).get(&qkey), Some(&1000.0));
9248 assert_eq!(app.values_for(MeasureId(103)).get(&qkey), Some(&100.0));
9249 assert_eq!(app.values_for(MeasureId(104)).get(&qkey), Some(&900.0));
9250
9251 app.set_formula_enabled(MeasureId(103), false)
9252 .expect("disable Tax");
9253
9254 assert!(
9256 !app.formula_enabled(MeasureId(103)),
9257 "the model records Tax as disabled"
9258 );
9259 assert_eq!(
9260 app.values_for(MeasureId(103)).get(&qkey),
9261 None,
9262 "a disabled formula must compute nothing"
9263 );
9264 assert_eq!(
9267 app.values_for(MeasureId(104)).get(&qkey),
9268 None,
9269 "Net must recompute without Tax, not keep its old 900"
9270 );
9271 assert!(
9272 app.values_for(MeasureId(104)).is_empty(),
9273 "no coordinate of Net survives its operand being switched off"
9274 );
9275 assert_eq!(app.values_for(MeasureId(102)).get(&qkey), Some(&1000.0));
9277 assert_eq!(app.values_for(MeasureId(105)).get(&qkey), Some(&200.0));
9278
9279 app.set_formula_enabled(MeasureId(103), true)
9282 .expect("re-enable Tax");
9283 assert!(app.formula_enabled(MeasureId(103)));
9284 assert_eq!(app.values_for(MeasureId(103)).get(&qkey), Some(&100.0));
9285 assert_eq!(app.values_for(MeasureId(104)).get(&qkey), Some(&900.0));
9286 }
9287
9288 #[test]
9293 fn a_disabled_formula_survives_a_save_reload_round_trip() {
9294 let db = std::env::temp_dir()
9295 .join(format!(
9296 "improv_gui_disabled_persist_{}_{}.db",
9297 std::process::id(),
9298 std::time::SystemTime::now()
9299 .duration_since(std::time::UNIX_EPOCH)
9300 .expect("clock")
9301 .as_nanos()
9302 ))
9303 .to_string_lossy()
9304 .into_owned();
9305 struct Cleanup(String);
9306 impl Drop for Cleanup {
9307 fn drop(&mut self) {
9308 let _ = std::fs::remove_file(&self.0);
9309 }
9310 }
9311 let _cleanup = Cleanup(db.clone());
9312
9313 Store::open(&db)
9314 .and_then(|mut s| s.save_model(&four_formula_model()))
9315 .expect("seed the store");
9316 let mut app = ImprovApp::load(&db).expect("load");
9317 let (qkey, _) = q_and_rev_keys();
9318
9319 app.model
9322 .measures
9323 .get_mut(&MeasureId(103))
9324 .expect("Tax")
9325 .description = Some("rate applied to revenue".into());
9326 app.set_formula_enabled(MeasureId(103), false)
9327 .expect("disable Tax");
9328
9329 let reloaded = ImprovApp::load(&db).expect("reload");
9331 assert!(
9332 !reloaded.formula_enabled(MeasureId(103)),
9333 "the disabled bit did not survive the round trip"
9334 );
9335 let tax = &reloaded.model.measures[&MeasureId(103)];
9338 assert!(
9339 tax.is_derived(),
9340 "a disabled measure stays derived, keeping its formula"
9341 );
9342 assert_eq!(
9343 split_marker(tax.description.as_deref()).1,
9344 Some("rate applied to revenue"),
9345 "the user's description must survive the toggle"
9346 );
9347 assert_eq!(reloaded.values_for(MeasureId(103)).get(&qkey), None);
9350 assert_eq!(reloaded.values_for(MeasureId(104)).get(&qkey), None);
9351 let rows = reloaded.formula_rows();
9353 let tax_row = rows
9354 .iter()
9355 .find(|r| r.measure == MeasureId(103))
9356 .expect("a disabled formula is still listed");
9357 assert!(!tax_row.enabled);
9358 assert_eq!(tax_row.text, "Revenue * Rate", "its formula is still shown");
9359
9360 let mut reloaded = reloaded;
9362 reloaded
9363 .set_formula_enabled(MeasureId(103), true)
9364 .expect("re-enable");
9365 assert_eq!(
9366 reloaded.values_for(MeasureId(104)).get(&qkey),
9367 Some(&900.0),
9368 "re-enabling after a reload must restore the exact formula"
9369 );
9370 }
9371
9372 #[test]
9376 fn a_formula_that_fails_to_build_shows_its_error_inline() {
9377 let mut model = four_formula_model();
9378 model.add_measure(Measure {
9379 id: MeasureId(120),
9380 name: Name("Label".into()),
9381 value_type: ValueType::Text,
9382 categories: vec![CategoryId(2)],
9383 kind: MeasureKind::Input,
9384 description: None,
9385 });
9386 model.add_measure(Measure {
9388 id: MeasureId(121),
9389 name: Name("Broken".into()),
9390 value_type: ValueType::Number,
9391 categories: vec![CategoryId(1), CategoryId(2)],
9392 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
9393 BinaryOp::Mul,
9394 Box::new(Expr::Ref(MeasureId(102), DimensionSpec::default())),
9395 Box::new(Expr::Ref(MeasureId(120), DimensionSpec::default())),
9396 ))),
9397 description: None,
9398 });
9399 let app = build_app(model);
9400 let rows = app.formula_rows();
9401
9402 let broken = rows
9404 .iter()
9405 .find(|r| r.measure == MeasureId(121))
9406 .expect("a broken formula is still listed");
9407 assert_eq!(broken.text, "Revenue * Label");
9408 let err = broken
9409 .error
9410 .as_deref()
9411 .expect("a formula that does not build must show an error inline");
9412 assert!(
9413 err.to_lowercase().contains("type"),
9414 "the inline error must say what is wrong, got {err:?}"
9415 );
9416 for r in rows.iter().filter(|r| r.measure != MeasureId(121)) {
9418 assert_eq!(r.error, None, "{r:?} must not report an error");
9419 }
9420 }
9421
9422 #[test]
9425 fn a_formula_with_no_editable_spelling_says_so_inline() {
9426 let mut model = four_formula_model();
9427 model.measures.get_mut(&MeasureId(100)).unwrap().name = Name(String::new());
9430 let app = build_app(model);
9431 let rows = app.formula_rows();
9432 let rev = rows
9433 .iter()
9434 .find(|r| r.measure == MeasureId(102))
9435 .expect("still listed");
9436 assert_eq!(
9437 rev.error.as_deref(),
9438 Some("no editable spelling (shown as description)"),
9439 "the list must admit this row is not editable"
9440 );
9441 assert!(!rev.text.is_empty(), "it still shows a description");
9442 }
9443
9444 #[test]
9448 fn the_disabled_marker_never_eats_a_user_description() {
9449 let lookalike = format!("{DISABLED_MARKER}ish notes");
9451 assert_eq!(
9452 split_marker(Some(lookalike.as_str())),
9453 (false, Some(lookalike.as_str()))
9454 );
9455 assert_eq!(split_marker(Some(DISABLED_MARKER)), (true, None));
9457 let with_text = format!("{DISABLED_MARKER}{MARKER_SEP}keep me");
9458 assert_eq!(split_marker(Some(&with_text)), (true, Some("keep me")));
9459 assert_eq!(split_marker(None), (false, None));
9461 for (disabled, user) in [
9463 (false, None),
9464 (false, Some("plain")),
9465 (true, None),
9466 (true, Some("keep me")),
9467 ] {
9468 let joined = join_marker(disabled, user);
9469 assert_eq!(
9470 split_marker(joined.as_deref()),
9471 (disabled, user),
9472 "join/split must round-trip ({disabled}, {user:?})"
9473 );
9474 }
9475
9476 let mut model = four_formula_model();
9479 model.measures.get_mut(&MeasureId(103)).unwrap().description = Some(lookalike.clone());
9480 let mut app = build_app(model);
9481 assert!(app.formula_enabled(MeasureId(103)), "not actually disabled");
9482 app.set_formula_enabled(MeasureId(103), false).expect("off");
9483 app.set_formula_enabled(MeasureId(103), true).expect("on");
9484 assert_eq!(
9485 app.model.measures[&MeasureId(103)].description.as_deref(),
9486 Some(&*lookalike),
9487 "a disable/enable cycle must restore the description exactly"
9488 );
9489 }
9490
9491 #[test]
9495 fn toggling_a_formula_is_undoable_and_atomic() {
9496 let mut app = build_app(four_formula_model());
9497 let (qkey, _) = q_and_rev_keys();
9498 app.set_formula_enabled(MeasureId(103), false).expect("off");
9499 assert!(app.can_undo(), "a toggle must be an undo point");
9500 app.undo().expect("undo the toggle");
9501 assert!(app.formula_enabled(MeasureId(103)), "undo re-enabled it");
9502 assert_eq!(
9503 app.values_for(MeasureId(104)).get(&qkey),
9504 Some(&900.0),
9505 "undo restored the dependent's value too"
9506 );
9507 let mut app = build_app(four_formula_model());
9509 app.db = std::env::temp_dir()
9510 .join(format!("improv_gui_no_dir_{}", std::process::id()))
9511 .join("model.db")
9512 .to_string_lossy()
9513 .into_owned();
9514 let err = app
9515 .set_formula_enabled(MeasureId(103), false)
9516 .expect_err("an unwritable store must fail the toggle");
9517 assert!(err.starts_with("save failed:"), "got {err:?}");
9518 assert!(
9519 app.formula_enabled(MeasureId(103)),
9520 "a failed toggle must leave the model alone"
9521 );
9522 assert_eq!(app.values_for(MeasureId(103)).get(&qkey), Some(&100.0));
9523 }
9524
9525 #[test]
9529 fn toggling_without_a_formula_is_a_no_op_and_all_off_is_valid() {
9530 let mut app = build_app(four_formula_model());
9531 app.set_formula_enabled(MeasureId(101), false)
9533 .expect("input measure");
9534 app.set_formula_enabled(MeasureId(9999), false)
9535 .expect("unknown id");
9536 assert!(!app.can_undo(), "a no-op must not create an undo point");
9537 assert!(app.formula_enabled(MeasureId(101)));
9538
9539 for id in [102u32, 103, 104, 105] {
9540 app.set_formula_enabled(MeasureId(id), false)
9541 .unwrap_or_else(|e| panic!("disable {id}: {e}"));
9542 }
9543 assert!(app.engine.is_none(), "nothing left to compute");
9544 let (qkey, _) = q_and_rev_keys();
9545 assert_eq!(app.values_for(MeasureId(102)).get(&qkey), None);
9546 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("100"));
9548 assert_eq!(app.formula_rows().len(), 4);
9549 assert!(app.formula_rows().iter().all(|r| !r.enabled));
9550 }
9551
9552 #[test]
9555 fn the_formula_pane_lays_out_at_every_window_size() {
9556 for (w, h) in [
9557 (1200.0, 800.0),
9558 (800.0, 400.0),
9559 (300.0, 200.0),
9560 (60.0, 40.0),
9561 ] {
9562 let mut app = build_app(four_formula_model());
9563 let ctx = egui::Context::default();
9564 crate::theme::apply(&ctx);
9565 for i in 0..4 {
9566 let raw = egui::RawInput {
9567 time: Some(f64::from(i) / 60.0),
9568 screen_rect: Some(egui::Rect::from_min_size(
9569 egui::Pos2::ZERO,
9570 egui::vec2(w, h),
9571 )),
9572 ..Default::default()
9573 };
9574 let _ = ctx.run(raw, |ctx| {
9575 app.sync_axis_state();
9576 app.canvas_panel(ctx);
9577 });
9578 }
9579 assert_eq!(app.formula_rows().len(), 4, "{w}x{h}");
9581 }
9582 }
9583 fn two_matrix_app() -> ImprovApp {
9592 let mut app = build_app(grid_2x2_model());
9593 select(&mut app, MeasureId(101)); let i = app.add_matrix(Some(MeasureId(102))); assert_eq!(i, 1);
9596 app.set_focus(0);
9597 app.sync_axis_state();
9598 app
9599 }
9600
9601 #[test]
9609 fn two_matrices_are_independently_pivotable() {
9610 let mut app = two_matrix_app();
9611 let (t, p) = (CategoryId(1), CategoryId(2));
9612 assert_eq!(app.matrix_count(), 2);
9613
9614 for mi in 0..2 {
9617 let m = &app.matrices[mi];
9618 assert_eq!(m.row_cats(&app.model), vec![t], "matrix {mi} rows");
9619 assert_eq!(m.col_cats(&app.model), vec![p], "matrix {mi} cols");
9620 }
9621 assert_eq!(app.matrices[0].measure, Some(MeasureId(101)));
9622 assert_eq!(app.matrices[1].measure, Some(MeasureId(102)));
9623
9624 let model = app.model.clone();
9627 app.matrices[1].pivot_rotate(&model);
9628
9629 assert_eq!(
9630 app.matrices[1].row_cats(&app.model),
9631 vec![p],
9632 "matrix 1 pivoted: Product on rows"
9633 );
9634 assert_eq!(app.matrices[1].col_cats(&app.model), vec![t]);
9635 assert_eq!(
9636 app.matrices[0].row_cats(&app.model),
9637 vec![t],
9638 "matrix 0 must be untouched by matrix 1's pivot"
9639 );
9640 assert_eq!(app.matrices[0].col_cats(&app.model), vec![p]);
9641
9642 app.matrices[0].set_axis(&model, p, Axis::Rows);
9644 assert_eq!(app.matrices[0].row_cats(&app.model), vec![t, p]);
9645 assert!(app.matrices[0].col_cats(&app.model).is_empty());
9646 assert_eq!(
9647 app.matrices[1].row_cats(&app.model),
9648 vec![p],
9649 "matrix 1 still has its own layout"
9650 );
9651
9652 app.matrices[0].toggle_filter_item(&model, t, ItemId(11));
9655 assert_eq!(app.matrices[0].sorted_items(&app.model, t).len(), 1);
9656 assert_eq!(
9657 app.matrices[1].sorted_items(&app.model, t).len(),
9658 2,
9659 "a filter is presentation on ONE matrix, not the model"
9660 );
9661 app.matrices[1].move_cursor(&model, 5, 5);
9663 assert_eq!(
9664 (app.matrices[0].cursor_row, app.matrices[0].cursor_col),
9665 (0, 0),
9666 "matrix 0's cursor must not follow matrix 1's"
9667 );
9668
9669 let _ = layout_all(&mut app);
9675 let drew = |app: &ImprovApp, mi: usize| {
9676 app.matrices[mi]
9677 .rendered_axes
9678 .clone()
9679 .unwrap_or_else(|| panic!("matrix {mi} never rendered"))
9680 };
9681 assert_eq!(
9682 drew(&app, 0),
9683 (vec![t, p], vec![]),
9684 "matrix 0 must be drawn with ITS stacked rows"
9685 );
9686 assert_eq!(
9687 drew(&app, 1),
9688 (vec![p], vec![t]),
9689 "matrix 1 must be drawn with ITS pivot, not matrix 0's"
9690 );
9691 assert_ne!(
9692 drew(&app, 0),
9693 drew(&app, 1),
9694 "two matrices with different pivots must render differently"
9695 );
9696 }
9697
9698 #[test]
9707 fn an_edit_in_one_matrix_recomputes_a_dependent_in_another() {
9708 let mut app = two_matrix_app();
9709 let qkey = app.matrices[0]
9712 .cursor_key(&app.model)
9713 .expect("matrix 0 addresses a cell");
9714 let rkey = app.matrices[1]
9715 .cursor_key(&app.model)
9716 .expect("matrix 1 addresses a cell");
9717 assert_eq!(qkey, rkey, "both matrices are on the same coordinate");
9718 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("100"));
9719 assert_eq!(
9720 app.cell_text(MeasureId(102), &rkey).as_deref(),
9721 Some("1000")
9722 );
9723
9724 app.commit_cell_text(MeasureId(101), qkey.clone(), "250")
9726 .expect("commit the edit");
9727
9728 assert_eq!(
9729 app.cell_text(MeasureId(101), &qkey).as_deref(),
9730 Some("250"),
9731 "matrix 0 shows the edit"
9732 );
9733 assert_eq!(
9734 app.cell_text(MeasureId(102), &rkey).as_deref(),
9735 Some("2500"),
9736 "matrix 1's dependent measure must recompute: 10 * 250"
9737 );
9738 let mut other = vec![(1u32, 11u32), (2, 20)];
9741 other.sort();
9742 assert_eq!(
9743 app.cell_text(MeasureId(102), &other).as_deref(),
9744 Some("1200"),
9745 "an unrelated coordinate must keep its value"
9746 );
9747 }
9748
9749 #[test]
9753 fn save_reload_round_trips_a_multi_matrix_canvas() {
9754 let db = temp_db("multi_matrix");
9755 let _cleanup = Cleanup(db.clone());
9756 Store::open(&db)
9757 .and_then(|mut s| s.save_model(&sales_3d_model()))
9758 .expect("seed the store");
9759
9760 let mut app = ImprovApp::load(&db).expect("load");
9761 select(&mut app, MeasureId(200));
9762 app.sync_axis_state();
9763 let model = app.model.clone();
9765 app.matrices[0].pivot_rotate(&model);
9766 app.matrices[0].set_page(&model, 0, 1);
9767 app.matrices[0].rect = CanvasRect {
9768 x: 12.0,
9769 y: 34.0,
9770 w: 500.0,
9771 h: 300.0,
9772 };
9773 app.add_matrix(Some(MeasureId(200)));
9782 app.sync_axis_state();
9783 let stack = app.matrices[1].col_cats(&app.model)[0];
9784 app.matrices[1].set_axis(&model, stack, Axis::Rows);
9785 assert_eq!(app.matrices[1].n_rows, 2, "the fixture must really stack");
9786 app.matrices[1].rect = CanvasRect {
9787 x: 600.0,
9788 y: 40.0,
9789 w: 420.0,
9790 h: 260.0,
9791 };
9792 let want: Vec<MatrixPlacement> = app
9793 .matrices
9794 .iter()
9795 .map(|m| m.to_placement(&app.model).expect("placement"))
9796 .collect();
9797
9798 let id = app.save_view("Canvas A").expect("saved");
9799 let stored = app.model.views.get(&id).expect("view stored");
9801 assert_eq!(
9802 stored.placements.len(),
9803 1,
9804 "one EXTRA matrix beyond primary"
9805 );
9806 assert_eq!(stored.matrices(), want, "the view describes both matrices");
9807
9808 let mut reloaded = ImprovApp::load(&db).expect("reload");
9810 assert_eq!(reloaded.matrix_count(), 1, "a fresh app starts single");
9811 let v = reloaded.model.views[&id].clone();
9812 reloaded.apply_view(&v);
9813 reloaded.sync_axis_state(); assert_eq!(reloaded.matrix_count(), 2, "both matrices restored");
9816 let got: Vec<MatrixPlacement> = reloaded
9817 .matrices
9818 .iter()
9819 .map(|m| m.to_placement(&reloaded.model).expect("placement"))
9820 .collect();
9821 assert_eq!(got, want, "every matrix round-tripped exactly");
9822
9823 for (mi, w) in want.iter().enumerate() {
9829 let got = &reloaded.matrices[mi];
9830 assert_eq!(
9831 got.row_cats(&reloaded.model),
9832 w.axis_order[..w.n_rows].to_vec(),
9833 "matrix {mi}'s row stack"
9834 );
9835 assert_eq!(
9836 got.col_cats(&reloaded.model),
9837 w.axis_order[w.n_rows..w.n_rows + w.n_cols].to_vec(),
9838 "matrix {mi}'s column stack"
9839 );
9840 assert_eq!(
9841 got.pinned_pages(&reloaded.model),
9842 w.page_items,
9843 "matrix {mi}'s page pin"
9844 );
9845 }
9846 assert_eq!(reloaded.matrices[0].n_rows, 1);
9849 assert_eq!(reloaded.matrices[1].n_rows, 2);
9850 assert!(
9851 reloaded.matrices[1]
9852 .row_cats(&reloaded.model)
9853 .contains(&stack),
9854 "matrix 1's stacked category came back on its row axis"
9855 );
9856 assert_eq!(reloaded.matrices[0].rect.x, 12.0, "geometry per matrix");
9857 assert_eq!(reloaded.matrices[1].rect.x, 600.0);
9858 }
9859
9860 #[test]
9869 fn a_legacy_single_matrix_view_still_loads() {
9870 let (time, product) = (CategoryId(1), CategoryId(2));
9871 let legacy = View {
9873 id: ViewId(7),
9874 name: Name("Legacy".into()),
9875 measure: MeasureId(101),
9876 axis_order: vec![product, time],
9877 n_rows: 1,
9878 n_cols: 1,
9879 page_items: vec![],
9880 filters: vec![Filter {
9881 category: product,
9882 items: vec![ItemId(20)],
9883 }],
9884 rect: CanvasRect::default(),
9885 placements: vec![],
9886 };
9887 assert!(
9888 legacy.placements.is_empty(),
9889 "the fixture must be a pre-canvas view, or it proves nothing"
9890 );
9891
9892 let mut app = two_matrix_app();
9895 assert_eq!(app.matrix_count(), 2);
9896 app.apply_view(&legacy);
9897 app.sync_axis_state();
9898
9899 assert_eq!(
9900 app.matrix_count(),
9901 1,
9902 "a legacy view is a ONE-matrix canvas"
9903 );
9904 let m = &app.matrices[0];
9905 assert_eq!(m.measure, Some(MeasureId(101)));
9906 assert_eq!(
9907 m.row_cats(&app.model),
9908 vec![product],
9909 "saved pivot restored"
9910 );
9911 assert_eq!(m.col_cats(&app.model), vec![time]);
9912 assert_eq!(m.filters, legacy.filters, "saved filter restored");
9913 assert_eq!(
9914 m.rect,
9915 CanvasRect::default(),
9916 "no geometry saved -> the default rectangle, visible on the canvas"
9917 );
9918 assert_eq!(
9920 m.sorted_items(&app.model, product)
9921 .iter()
9922 .map(|(id, _)| *id)
9923 .collect::<Vec<_>>(),
9924 vec![ItemId(20)]
9925 );
9926 let again = app.build_view(ViewId(8), "again").expect("view");
9928 assert!(
9929 again.placements.is_empty(),
9930 "a one-matrix canvas must still save as a one-matrix view"
9931 );
9932 assert_eq!(again.measure, MeasureId(101));
9933 }
9934
9935 #[test]
9939 fn a_view_with_a_missing_measure_is_handled_not_shown_blank() {
9940 let mut app = two_matrix_app();
9941 let two = app.build_view(ViewId(1), "two").expect("view");
9942 app.model.measures.remove(&MeasureId(102));
9944 app.apply_view(&two);
9945 assert_eq!(app.matrix_count(), 1, "the dead matrix was dropped");
9946 assert_eq!(app.matrices[0].measure, Some(MeasureId(101)));
9947 assert!(app.status.contains("skipped"), "got {:?}", app.status);
9948
9949 let before = app.matrices.clone();
9951 app.model.measures.remove(&MeasureId(101));
9952 app.apply_view(&two);
9953 assert_eq!(app.matrix_count(), before.len());
9954 assert_eq!(app.status, "view's measure no longer exists");
9955 }
9956
9957 #[test]
9962 fn only_the_focused_matrix_consumes_grid_shortcuts() {
9963 let mut app = two_matrix_app();
9964 app.set_focus(0);
9965 assert_eq!(app.focus_index(), 0);
9966
9967 use egui::Key;
9968 frame_with_keys_for(&mut app, 0, &[Key::ArrowDown, Key::ArrowRight], None);
9970 assert_eq!(
9971 (app.matrices[0].cursor_row, app.matrices[0].cursor_col),
9972 (1, 1),
9973 "the focused matrix must take the keystroke"
9974 );
9975 assert_eq!(
9976 (app.matrices[1].cursor_row, app.matrices[1].cursor_col),
9977 (0, 0),
9978 "the unfocused matrix must not move"
9979 );
9980
9981 app.matrices[0].cursor_row = 0;
9989 app.matrices[0].cursor_col = 0;
9990 frame_with_keys_for(&mut app, 1, &[Key::ArrowDown, Key::ArrowRight], None);
9991 assert_eq!(
9992 (app.matrices[1].cursor_row, app.matrices[1].cursor_col),
9993 (0, 0),
9994 "an unfocused matrix must ignore keys aimed at it"
9995 );
9996 assert_eq!(
9997 (app.matrices[0].cursor_row, app.matrices[0].cursor_col),
9998 (0, 0),
9999 "...and must not forward them to the focused matrix either"
10000 );
10001 app.matrices[0].cursor_row = 1;
10003 app.matrices[0].cursor_col = 1;
10004
10005 app.set_focus(1);
10007 frame_with_keys_for(&mut app, 1, &[Key::ArrowDown], None);
10008 assert_eq!(
10009 (app.matrices[1].cursor_row, app.matrices[1].cursor_col),
10010 (1, 0),
10011 "the newly focused matrix takes the keystroke"
10012 );
10013 assert_eq!(
10014 (app.matrices[0].cursor_row, app.matrices[0].cursor_col),
10015 (1, 1),
10016 "the previously focused matrix froze where it was"
10017 );
10018
10019 app.set_focus(0);
10022 frame_with_keys_for(&mut app, 1, &[Key::Enter], None);
10024 assert!(
10025 app.matrices[0].editing.is_none() && app.matrices[1].editing.is_none(),
10026 "Enter aimed at an unfocused matrix must open no editor at all"
10027 );
10028 frame_with_keys_for(&mut app, 0, &[Key::Enter], None);
10030 assert!(
10031 app.matrices[0].editing.is_some(),
10032 "Enter must open the focused matrix's editor"
10033 );
10034 assert!(
10035 app.matrices[1].editing.is_none(),
10036 "...and no other matrix's"
10037 );
10038 }
10039
10040 #[test]
10048 fn every_matrix_on_the_canvas_frames_its_own_table() {
10049 let mut app = two_matrix_app();
10050 app.matrices[0].rect = CanvasRect {
10053 x: 0.0,
10054 y: 0.0,
10055 w: 460.0,
10056 h: 300.0,
10057 };
10058 app.matrices[1].rect = CanvasRect {
10059 x: 0.0,
10060 y: 340.0,
10061 w: 460.0,
10062 h: 300.0,
10063 };
10064 let all = layout_all(&mut app);
10065 assert_eq!(all.len(), 2, "one gutter set per matrix");
10066
10067 for (i, g) in all.iter().enumerate() {
10068 assert!(
10069 gutters_have_room(g),
10070 "matrix {i} must have room at this size: {g:?}"
10071 );
10072 assert!(
10073 gutters_frame_table(g),
10074 "matrix {i}'s gutters must frame ITS table: {g:?}"
10075 );
10076 assert!(
10078 (g.left.max.x - g.table.min.x).abs() <= EDGE_EPS,
10079 "matrix {i}: row gutter must adjoin its table's left edge: {g:?}"
10080 );
10081 assert!(
10082 (g.top.max.y - g.table.min.y).abs() <= EDGE_EPS,
10083 "matrix {i}: column gutter must adjoin its table's top edge: {g:?}"
10084 );
10085 }
10086 assert!(
10089 all[1].table.min.y > all[0].table.min.y + 100.0,
10090 "the second matrix must frame a DIFFERENT table: {all:?}"
10091 );
10092 assert_ne!(all[0].top, all[1].top, "each matrix has its own gutters");
10093 }
10094
10095 #[test]
10100 fn the_canvas_does_not_creep_across_frames() {
10101 let mut app = two_matrix_app();
10102 let (ctx, mut pass) = layout_harness();
10103 let settled = settle(&ctx, &mut app, &mut pass);
10106 for i in 0..10 {
10107 let now = pass(&ctx, &mut app);
10108 assert_eq!(now, settled, "the canvas drifted on frame {i}");
10109 }
10110 let first: Vec<GutterRects> = app.matrices.iter().filter_map(|m| m.gutters).collect();
10112 assert_eq!(first.len(), 2);
10113 for _ in 0..5 {
10114 let _ = pass(&ctx, &mut app);
10115 }
10116 let again: Vec<GutterRects> = app.matrices.iter().filter_map(|m| m.gutters).collect();
10117 assert_eq!(first, again, "some matrix's geometry drifted");
10118 }
10119
10120 #[test]
10124 fn the_canvas_lays_out_at_every_window_size() {
10125 for (w, h) in [
10126 (1400.0, 900.0),
10127 (1000.0, 700.0),
10128 (600.0, 400.0),
10129 (200.0, 150.0),
10130 (40.0, 30.0),
10131 ] {
10132 let mut app = two_matrix_app();
10133 let ctx = egui::Context::default();
10134 crate::theme::apply(&ctx);
10135 for i in 0..6 {
10136 let raw = egui::RawInput {
10137 time: Some(f64::from(i) / 60.0),
10138 screen_rect: Some(egui::Rect::from_min_size(
10139 egui::Pos2::ZERO,
10140 egui::vec2(w, h),
10141 )),
10142 ..Default::default()
10143 };
10144 let _ = ctx.run(raw, |ctx| {
10145 app.sync_axis_state();
10146 app.status_bar(ctx);
10147 let ws = app.canvas_panel(ctx);
10148 app.menu_bar(ctx, ws);
10149 });
10150 }
10151 assert_eq!(app.matrix_count(), 2, "{w}x{h}: both matrices survive");
10152 }
10153 }
10154
10155 #[test]
10165 fn canvas_layout_is_not_an_undo_point_but_saving_it_is() {
10166 let mut app = build_app(grid_2x2_model());
10167 assert!(!app.can_undo());
10168
10169 app.add_matrix(Some(MeasureId(101)));
10170 assert_eq!(app.matrix_count(), 2);
10171 assert!(
10172 !app.can_undo(),
10173 "placing a matrix is layout, not a model change"
10174 );
10175
10176 app.matrices[1].rect.x += 40.0;
10178 app.matrices[1].rect.w += 60.0;
10179 assert!(!app.can_undo());
10180
10181 assert!(app.remove_matrix(1));
10182 assert_eq!(app.matrix_count(), 1);
10183 assert!(!app.can_undo(), "removing a matrix is layout too");
10184
10185 assert!(!app.remove_matrix(0), "the last matrix must stay");
10188 assert_eq!(app.matrix_count(), 1);
10189 assert!(!app.remove_matrix(9), "an out-of-range index is a no-op");
10190
10191 app.add_matrix(Some(MeasureId(102)));
10194 let id = app.save_view("Canvas").expect("saved");
10195 assert!(app.can_undo(), "saving a view must be an undo point");
10196 app.undo().expect("undo the save");
10197 assert!(
10198 !app.model.views.contains_key(&id),
10199 "undo removed the saved view"
10200 );
10201 assert_eq!(
10202 app.matrix_count(),
10203 2,
10204 "undoing the SAVE does not tear down the canvas it described"
10205 );
10206 }
10207
10208 fn win_id(app: &ImprovApp, mi: usize) -> egui::Id {
10212 matrix_window_id(app.matrices[mi].uid)
10213 }
10214
10215 fn win_rect(ctx: &egui::Context, app: &ImprovApp, mi: usize) -> egui::Rect {
10217 crate::nxwin::stored_rect(ctx, win_id(app, mi))
10218 .unwrap_or_else(|| panic!("window {mi} was never shown"))
10219 }
10220
10221 fn press(pos: egui::Pos2, pressed: bool) -> egui::Event {
10222 egui::Event::PointerButton {
10223 pos,
10224 button: egui::PointerButton::Primary,
10225 pressed,
10226 modifiers: egui::Modifiers::NONE,
10227 }
10228 }
10229
10230 struct Desk {
10232 ctx: egui::Context,
10233 time: f64,
10234 }
10235
10236 impl Desk {
10237 fn new() -> Self {
10238 let ctx = egui::Context::default();
10239 crate::theme::apply(&ctx);
10240 Desk { ctx, time: 0.0 }
10241 }
10242
10243 fn frame(&mut self, app: &mut ImprovApp, events: Vec<egui::Event>) -> egui::FullOutput {
10244 self.time += 1.0 / 60.0;
10245 let size = egui::vec2(1200.0, 800.0);
10246 full_frame_with(&self.ctx, app, size, self.time, events, |_| ())
10247 }
10248
10249 fn settle(&mut self, app: &mut ImprovApp) {
10250 for _ in 0..6 {
10251 let _ = self.frame(app, Vec::new());
10252 }
10253 }
10254
10255 fn click(&mut self, app: &mut ImprovApp, at: egui::Pos2) {
10256 let _ = self.frame(
10257 app,
10258 vec![
10259 egui::Event::PointerMoved(at),
10260 press(at, true),
10261 press(at, false),
10262 ],
10263 );
10264 let _ = self.frame(app, Vec::new());
10265 }
10266
10267 fn drag(&mut self, app: &mut ImprovApp, from: egui::Pos2, to: egui::Pos2) {
10269 let _ = self.frame(app, vec![egui::Event::PointerMoved(from)]);
10270 let _ = self.frame(app, vec![press(from, true)]);
10271 for t in [0.25, 0.5, 0.75, 1.0] {
10272 let _ = self.frame(app, vec![egui::Event::PointerMoved(from.lerp(to, t))]);
10273 }
10274 let _ = self.frame(app, vec![press(to, false)]);
10275 self.settle(app);
10276 }
10277 }
10278
10279 #[test]
10282 fn each_matrix_is_its_own_worksheet_window() {
10283 let mut app = two_matrix_app();
10284 let mut desk = Desk::new();
10285 desk.settle(&mut app);
10286 let shapes = desk.frame(&mut app, Vec::new()).shapes;
10287 assert_ne!(win_id(&app, 0), win_id(&app, 1));
10288 assert_ne!(win_rect(&desk.ctx, &app, 0), win_rect(&desk.ctx, &app, 1));
10289 let titles: Vec<String> = shapes
10290 .iter()
10291 .filter_map(|c| match &c.shape {
10292 egui::Shape::Text(t) if t.galley.text().contains(" - Worksheet - ") => {
10293 Some(t.galley.text().to_string())
10294 }
10295 _ => None,
10296 })
10297 .collect();
10298 assert_eq!(
10299 titles,
10300 [
10301 "Quantity - Worksheet - Untitled",
10302 "Revenue - Worksheet - Untitled"
10303 ],
10304 "one title per window, in memory = Untitled"
10305 );
10306 app.db = "/tmp/models/demo.db".into();
10307 assert_eq!(app.window_title(1), "Revenue - Worksheet - demo");
10308 }
10309
10310 #[test]
10313 fn dragging_a_window_title_moves_the_matrix_into_the_view() {
10314 let mut app = two_matrix_app();
10315 let mut desk = Desk::new();
10316 desk.settle(&mut app);
10317 let before = app.matrices[0].rect;
10318 let win = win_rect(&desk.ctx, &app, 0);
10319 let grab = egui::pos2(win.center().x, win.top() + 12.0);
10320 desk.drag(&mut app, grab, grab + egui::vec2(60.0, 40.0));
10321 let moved = app.matrices[0].rect;
10322 assert_eq!((moved.x, moved.y), (before.x + 60.0, before.y + 40.0));
10323 assert_eq!((moved.w, moved.h), (before.w, before.h));
10324 assert_eq!(
10325 app.matrices[1].rect,
10326 two_matrix_app().matrices[1].rect,
10327 "window 1 stays"
10328 );
10329
10330 let id = app.save_view("Moved").expect("saved");
10331 let view = app.model.views[&id].clone();
10332 app.matrices[0].rect = before;
10333 desk.settle(&mut app);
10334 app.apply_view(&view);
10335 assert_eq!(app.matrices[0].rect, moved, "the view kept the drag");
10336 desk.settle(&mut app);
10337 assert_eq!(
10338 win_rect(&desk.ctx, &app, 0),
10339 win.translate(egui::vec2(60.0, 40.0)),
10340 "and the window follows the view"
10341 );
10342 }
10343
10344 #[test]
10346 fn clicking_a_window_focuses_its_matrix() {
10347 let mut app = two_matrix_app();
10348 let mut desk = Desk::new();
10349 desk.settle(&mut app);
10350 assert_eq!(app.focus_index(), 0);
10351 let win = win_rect(&desk.ctx, &app, 1);
10352 desk.click(&mut app, win.max - egui::vec2(20.0, 20.0));
10354 assert_eq!(app.focus_index(), 1);
10355 assert!(crate::nxwin::is_key(&desk.ctx, win_id(&app, 1)));
10356 }
10357
10358 fn grid_desk() -> (ImprovApp, Desk) {
10362 let mut app = build_app(grid_2x2_model());
10363 app.matrices[0].measure = Some(MeasureId(101));
10364 let mut desk = Desk::new();
10365 desk.settle(&mut app);
10366 (app, desk)
10367 }
10368
10369 #[test]
10372 fn the_white_field_fills_the_table_area() {
10373 let (mut app, mut desk) = grid_desk();
10374 let out = desk.frame(&mut app, Vec::new());
10375 let last = cell_rect(&desk, &app, 0, 1);
10376 let at = egui::pos2(last.right() + 20.0, last.center().y);
10377 let win = win_rect(&desk.ctx, &app, 0);
10378 assert!(win.contains(at), "{at:?} inside the window {win:?}");
10379 let white = out.shapes.iter().any(|c| {
10380 c.clip_rect.contains(at)
10381 && matches!(&c.shape, egui::Shape::Rect(r)
10382 if r.fill == crate::theme::WHITE && r.rect.contains(at)
10383 && win.contains_rect(r.rect.shrink(1.0)))
10384 });
10385 assert!(white, "{at:?}, right of the grid, sits on the white field");
10386 }
10387
10388 #[test]
10391 fn the_grid_shows_a_gray_trough() {
10392 let (mut app, mut desk) = grid_desk();
10393 let out = desk.frame(&mut app, Vec::new());
10394 let win = win_rect(&desk.ctx, &app, 0);
10395 let cell = cell_rect(&desk, &app, 0, 1);
10396 let trough = fills(&out).into_iter().any(|(r, c)| {
10397 c == crate::theme::TROUGH
10398 && (r.width() - 19.0).abs() < 1.0
10399 && r.left() >= cell.right() - 1.0
10400 && r.y_range().contains(cell.center().y)
10401 && win.contains_rect(r)
10402 });
10403 assert!(trough, "no 19 px TROUGH strip in {win:?}");
10404 }
10405
10406 #[test]
10409 fn text_edits_in_a_scroller_stay_white() {
10410 let mut app = build_app(revenue_model());
10411 let ctx = egui::Context::default();
10412 crate::theme::apply(&ctx);
10413 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
10414 let out = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.1);
10415 let field = ctx
10416 .viewport(|v| {
10417 let w = v.prev_pass.widgets.get(egui::Id::new("formula_new"));
10418 w.map(|w| w.rect)
10419 })
10420 .expect("formula_new shown");
10421 let at = field.center();
10422 let under: Vec<_> = fills(&out)
10423 .into_iter()
10424 .filter(|(r, _)| r.contains(at) && field.contains_rect(r.shrink(1.0)))
10425 .collect();
10426 assert!(
10427 under.iter().any(|(_, c)| *c == crate::theme::WHITE),
10428 "{field:?}: {under:?}"
10429 );
10430 assert!(under.iter().all(|(_, c)| *c != crate::theme::TROUGH));
10431 }
10432
10433 fn cell_rect(desk: &Desk, app: &ImprovApp, ri: usize, ci: usize) -> egui::Rect {
10435 let id = egui::Id::new(("cell", app.matrices[0].uid, ri, ci));
10436 desk.ctx
10437 .viewport(|v| v.prev_pass.widgets.get(id).map(|w| w.rect))
10438 .unwrap_or_else(|| panic!("cell ({ri},{ci}) was never shown"))
10439 }
10440
10441 fn fills(out: &egui::FullOutput) -> Vec<(egui::Rect, egui::Color32)> {
10443 fn walk(s: &egui::Shape, acc: &mut Vec<(egui::Rect, egui::Color32)>) {
10444 match s {
10445 egui::Shape::Rect(r) if r.fill.a() > 0 => acc.push((r.rect, r.fill)),
10446 egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, acc)),
10447 _ => {}
10448 }
10449 }
10450 let mut acc = Vec::new();
10451 out.shapes.iter().for_each(|c| walk(&c.shape, &mut acc));
10452 acc
10453 }
10454
10455 fn lit_headers(fills: &[(egui::Rect, egui::Color32)], cell: egui::Rect) -> (usize, usize) {
10458 let lit = fills.iter().filter(|(r, c)| {
10459 *c == crate::theme::LIGHT_GRAY && r.width() < 200.0 && r.height() < 100.0
10460 });
10461 let col = lit
10462 .clone()
10463 .filter(|(r, _)| {
10464 r.width() <= cell.width() + 2.0
10465 && cell.x_range().contains(r.center().x)
10466 && (cell.top() - 10.0..=cell.top() + 1.0).contains(&r.bottom())
10467 })
10468 .count();
10469 let row = lit
10470 .filter(|(r, _)| {
10471 r.height() <= cell.height() + 2.0
10472 && cell.y_range().contains(r.center().y)
10473 && (cell.left() - 10.0..=cell.left() + 1.0).contains(&r.right())
10474 })
10475 .count();
10476 (col, row)
10477 }
10478
10479 #[test]
10481 fn the_cursor_cell_is_gray_and_the_rest_white() {
10482 let (mut app, mut desk) = grid_desk();
10483 let out = desk.frame(&mut app, Vec::new());
10484 let f = fills(&out);
10485 let fill_of = |r: egui::Rect| f.iter().rev().find(|(x, _)| *x == r).map(|(_, c)| *c);
10486 assert_eq!(
10487 fill_of(cell_rect(&desk, &app, 0, 0)),
10488 Some(crate::theme::LIGHT_GRAY)
10489 );
10490 assert_eq!(
10491 fill_of(cell_rect(&desk, &app, 0, 1)),
10492 Some(crate::theme::WHITE)
10493 );
10494 assert_eq!(
10495 fill_of(cell_rect(&desk, &app, 1, 0)),
10496 Some(crate::theme::WHITE)
10497 );
10498 }
10499
10500 #[test]
10502 fn the_cursor_lights_its_row_and_column_headers() {
10503 let (mut app, mut desk) = grid_desk();
10504 let out = desk.frame(&mut app, Vec::new());
10505 let f = fills(&out);
10506 let (c00, c11) = (cell_rect(&desk, &app, 0, 0), cell_rect(&desk, &app, 1, 1));
10507 assert_eq!(lit_headers(&f, c00), (1, 1), "cursor's headers lit");
10508 assert_eq!(lit_headers(&f, c11), (0, 0), "the others are not");
10509 }
10510
10511 #[test]
10513 fn double_click_edits_and_single_click_selects() {
10514 let (mut app, mut desk) = grid_desk();
10515 let at = cell_rect(&desk, &app, 1, 1).center();
10516 desk.click(&mut app, at);
10517 assert_eq!(
10518 (app.matrices[0].cursor_row, app.matrices[0].cursor_col),
10519 (1, 1)
10520 );
10521 assert!(app.matrices[0].editing.is_none(), "one click only selects");
10522 desk.click(&mut app, at);
10523 assert!(app.matrices[0].editing.is_some(), "a double-click edits");
10524 }
10525
10526 fn key(desk: &mut Desk, app: &mut ImprovApp, key: egui::Key, modifiers: egui::Modifiers) {
10528 let ev = egui::Event::Key {
10529 key,
10530 physical_key: None,
10531 pressed: true,
10532 repeat: false,
10533 modifiers,
10534 };
10535 let _ = desk.frame(app, vec![ev]);
10536 }
10537
10538 fn three_wide() -> (ImprovApp, Desk) {
10540 let mut model = grid_2x2_model();
10541 let (t, p) = (CategoryId(1), CategoryId(2));
10542 model.add_item(ItemId(22), p, "WidgetC");
10543 let c = |pairs: &[(CategoryId, ItemId)]| {
10544 improv_core_model::Coordinate::from_pairs(pairs.iter().copied())
10545 };
10546 for (pi, q) in [(ItemId(20), 1.0), (ItemId(21), 2.0), (ItemId(22), 4.0)] {
10547 model.set_input(
10548 MeasureId(101),
10549 c(&[(t, ItemId(10)), (p, pi)]),
10550 Value::Number(q),
10551 );
10552 }
10553 let mut app = build_app(model);
10554 app.matrices[0].measure = Some(MeasureId(101));
10555 let mut desk = Desk::new();
10556 desk.settle(&mut app);
10557 assert_eq!(app.matrices[0].grid_dims(&app.model), (2, 3));
10559 (app, desk)
10560 }
10561
10562 #[test]
10565 fn shift_arrows_select_a_range_and_a_plain_arrow_collapses_it() {
10566 let (mut app, mut desk) = three_wide();
10567 assert_eq!(app.selection_sum(), Some(1.0));
10568 for _ in 0..2 {
10569 key(
10570 &mut desk,
10571 &mut app,
10572 egui::Key::ArrowRight,
10573 egui::Modifiers::SHIFT,
10574 );
10575 }
10576 assert_eq!(app.focused().selection_keys(&app.model).len(), 3);
10577 assert_eq!(app.selection_sum(), Some(7.0), "1 + 2 + 4");
10578 key(
10579 &mut desk,
10580 &mut app,
10581 egui::Key::ArrowDown,
10582 egui::Modifiers::NONE,
10583 );
10584 assert_eq!(app.focused().selection_keys(&app.model).len(), 1);
10585 assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (1, 2));
10586 }
10587
10588 #[test]
10590 fn a_range_lights_every_selected_column_header() {
10591 let (mut app, mut desk) = three_wide();
10592 key(
10593 &mut desk,
10594 &mut app,
10595 egui::Key::ArrowRight,
10596 egui::Modifiers::SHIFT,
10597 );
10598 let f = fills(&desk.frame(&mut app, Vec::new()));
10599 for ci in 0..2 {
10600 assert_eq!(
10601 lit_headers(&f, cell_rect(&desk, &app, 0, ci)),
10602 (1, 1),
10603 "col {ci}"
10604 );
10605 }
10606 assert_eq!(lit_headers(&f, cell_rect(&desk, &app, 1, 2)), (0, 0));
10607 }
10608
10609 #[test]
10611 fn shift_click_and_drag_select_ranges() {
10612 let (mut app, mut desk) = three_wide();
10613 let at = |desk: &Desk, app: &ImprovApp, r, c| cell_rect(desk, app, r, c).center();
10614 let p = at(&desk, &app, 1, 2);
10615 let shift_click = vec![
10616 egui::Event::PointerMoved(p),
10617 egui::Event::PointerButton {
10618 pos: p,
10619 button: egui::PointerButton::Primary,
10620 pressed: true,
10621 modifiers: egui::Modifiers::SHIFT,
10622 },
10623 egui::Event::PointerButton {
10624 pos: p,
10625 button: egui::PointerButton::Primary,
10626 pressed: false,
10627 modifiers: egui::Modifiers::SHIFT,
10628 },
10629 ];
10630 let _ = desk.frame(&mut app, shift_click);
10631 let _ = desk.frame(&mut app, Vec::new());
10632 assert_eq!(app.focused().selection_keys(&app.model).len(), 6);
10633 let p = at(&desk, &app, 0, 1);
10634 desk.click(&mut app, p);
10635 assert_eq!(app.focused().selection_keys(&app.model).len(), 1);
10636 let (from, to) = (at(&desk, &app, 0, 0), at(&desk, &app, 1, 1));
10637 desk.drag(&mut app, from, to);
10638 assert_eq!(app.focused().selection(), (0..=1, 0..=1));
10639 }
10640
10641 #[test]
10643 fn a_new_matrix_window_is_key() {
10644 let mut app = build_app(grid_2x2_model());
10645 let mut desk = Desk::new();
10646 desk.settle(&mut app);
10647 assert!(crate::nxwin::is_key(&desk.ctx, win_id(&app, 0)));
10648 app.add_matrix(Some(MeasureId(102)));
10649 let _ = desk.frame(&mut app, Vec::new());
10650 assert!(crate::nxwin::is_key(&desk.ctx, win_id(&app, 1)));
10651 assert!(!crate::nxwin::is_key(&desk.ctx, win_id(&app, 0)));
10652 assert_eq!(app.focus_index(), 1);
10653 }
10654
10655 #[test]
10658 fn the_close_box_removes_a_matrix_but_never_the_last() {
10659 let close_box =
10660 |r: egui::Rect| egui::pos2(r.right() - 1.0 - 4.0 - 7.5, r.top() + 1.0 + 11.5);
10661 let has_close = |ctx: &egui::Context, win: egui::Id| {
10662 ctx.viewport(|v| v.prev_pass.widgets.get(win.with("close")).is_some())
10663 };
10664 let mut app = two_matrix_app();
10665 let mut desk = Desk::new();
10666 desk.settle(&mut app);
10667 assert!(
10668 has_close(&desk.ctx, win_id(&app, 0)),
10669 "two matrices: closable"
10670 );
10671 let at = close_box(win_rect(&desk.ctx, &app, 1));
10672 desk.click(&mut app, at);
10673 assert_eq!(app.matrix_count(), 1);
10674 assert_eq!(
10675 app.matrices[0].measure,
10676 Some(MeasureId(101)),
10677 "window 1 closed"
10678 );
10679 desk.settle(&mut app);
10680 assert!(
10681 !has_close(&desk.ctx, win_id(&app, 0)),
10682 "the last matrix has no close box"
10683 );
10684 let at = close_box(win_rect(&desk.ctx, &app, 0));
10685 desk.click(&mut app, at);
10686 assert_eq!(app.matrix_count(), 1);
10687 }
10688
10689 #[test]
10692 fn closing_a_window_keeps_the_other_window_ids() {
10693 let mut app = two_matrix_app();
10694 app.add_matrix(Some(MeasureId(101)));
10695 let mut desk = Desk::new();
10696 desk.settle(&mut app);
10697 let ids = [win_id(&app, 1), win_id(&app, 2)];
10698 let rects = ids.map(|id| crate::nxwin::stored_rect(&desk.ctx, id));
10699 let r = win_rect(&desk.ctx, &app, 0);
10700 desk.click(
10701 &mut app,
10702 egui::pos2(r.right() - 1.0 - 4.0 - 7.5, r.top() + 1.0 + 11.5),
10703 );
10704 assert_eq!(app.matrix_count(), 2, "window 0 closed");
10705 desk.settle(&mut app);
10706 assert_eq!([win_id(&app, 0), win_id(&app, 1)], ids);
10707 let shown = |id: egui::Id| {
10708 desk.ctx
10709 .viewport(|v| v.prev_pass.widgets.get(id.with("title")).is_some())
10710 };
10711 assert!(
10712 ids.iter().all(|&id| shown(id)),
10713 "still shown under their ids"
10714 );
10715 assert_eq!(
10716 ids.map(|id| crate::nxwin::stored_rect(&desk.ctx, id)),
10717 rects
10718 );
10719 }
10720
10721 #[test]
10724 fn left_resize_stops_at_the_minimum_width() {
10725 let mut app = build_app(grid_2x2_model());
10726 let mut desk = Desk::new();
10727 desk.settle(&mut app);
10728 let before = app.matrices[0].rect;
10729 let win = win_rect(&desk.ctx, &app, 0);
10730 let at = egui::pos2(win.left() + 20.0, win.bottom() - 5.0);
10731 desk.drag(&mut app, at, at + egui::vec2(1000.0, 0.0));
10732 let r = app.matrices[0].rect;
10733 assert_eq!(r.w, MATRIX_MIN_W);
10734 assert_eq!(r.x + r.w, before.x + before.w, "right edge moved");
10735 assert_eq!(win_rect(&desk.ctx, &app, 0).right(), win.right());
10736 }
10737
10738 #[test]
10741 fn a_window_cannot_be_dragged_above_the_workspace() {
10742 let mut app = build_app(grid_2x2_model());
10743 let mut desk = Desk::new();
10744 desk.settle(&mut app);
10745 let win = win_rect(&desk.ctx, &app, 0);
10746 let grab = egui::pos2(win.center().x, win.top() + 12.0);
10747 desk.drag(&mut app, grab, grab - egui::vec2(0.0, 300.0));
10748 assert_eq!(app.matrices[0].rect.y, 0.0);
10749 assert!(
10750 egui::containers::panel::PanelState::load(&desk.ctx, egui::Id::new("formula_bar"))
10751 .is_none()
10752 );
10753 assert!(win_rect(&desk.ctx, &app, 0).top() >= 0.0);
10754 }
10755
10756 #[test]
10759 fn resizing_a_window_resizes_its_matrix_down_to_a_minimum() {
10760 let mut app = build_app(grid_2x2_model());
10761 let mut desk = Desk::new();
10762 desk.settle(&mut app);
10763 let before = app.matrices[0].rect;
10764 let bar = |r: egui::Rect| egui::pos2(r.right() - 20.0, r.bottom() - 5.0);
10765 let at = bar(win_rect(&desk.ctx, &app, 0));
10766 desk.drag(&mut app, at, at + egui::vec2(50.0, 30.0));
10767 let r = app.matrices[0].rect;
10768 assert_eq!((r.w, r.h), (before.w + 50.0, before.h + 30.0));
10769 let at = bar(win_rect(&desk.ctx, &app, 0));
10770 desk.drag(&mut app, at, at - egui::vec2(1000.0, 1000.0));
10771 let r = app.matrices[0].rect;
10772 assert_eq!((r.w, r.h), (MATRIX_MIN_W, MATRIX_MIN_H));
10773 assert_eq!(win_rect(&desk.ctx, &app, 0).size(), egui::vec2(r.w, r.h));
10774 }
10775
10776 #[test]
10779 fn a_tile_drag_inside_a_window_repivots() {
10780 let mut app = gutter_app();
10781 let mut desk = Desk::new();
10782 desk.settle(&mut app);
10783 let time = CategoryId(1);
10784 assert_eq!(app.row_cats(), vec![time]);
10785 let tile = desk
10786 .ctx
10787 .viewport(|v| {
10788 v.prev_pass
10789 .widgets
10790 .get(egui::Id::new(("tile", app.matrices[0].uid, time.0)))
10791 .copied()
10792 })
10793 .expect("the Time tile")
10794 .rect;
10795 let g = app.focused().gutters.expect("laid out");
10796 assert!(g.well.contains_rect(tile), "Time starts bottom-left: {g:?}");
10797 desk.drag(
10799 &mut app,
10800 tile.center(),
10801 egui::pos2(g.top.right() - 10.0, g.top.top() + TILE_H / 2.0 + 2.0),
10802 );
10803 assert!(
10804 app.col_cats().contains(&time),
10805 "Time moved to columns: {:?}",
10806 app.col_cats()
10807 );
10808 assert!(!app.row_cats().contains(&time));
10809 }
10810
10811 #[test]
10814 fn browser_labels_measures_input_or_formula() {
10815 let mut app = build_app(revenue_model());
10816 let ctx = egui::Context::default();
10817 crate::theme::apply(&ctx);
10818 let size = egui::vec2(1400.0, 900.0);
10819 let mut out = full_frame(&ctx, &mut app, size, 0.0);
10820 for i in 1..4 {
10821 out = full_frame(&ctx, &mut app, size, f64::from(i) / 60.0);
10822 }
10823 let name = text_at(&out.shapes, "Revenue").expect("Revenue listed");
10824 let kind = out
10825 .shapes
10826 .iter()
10827 .filter_map(|c| match &c.shape {
10828 egui::Shape::Text(t) if t.galley.text() == "formula" => {
10829 Some((t.galley.rect.translate(t.pos.to_vec2()), c.clip_rect))
10830 }
10831 _ => None,
10832 })
10833 .find(|(r, _)| (r.center().y - name.center().y).abs() < 4.0)
10834 .expect("formula label on Revenue's row");
10835 assert!(kind.0.min.x > name.max.x, "right of the name");
10836 assert!(
10837 kind.1.shrink(4.0).contains_rect(kind.0),
10838 "{kind:?} touches its clip edge"
10839 );
10840 assert!(text_at(&out.shapes, "input").is_some(), "Price is an input");
10841 }
10842
10843 fn grid_model(rows: u32, cols: u32) -> Model {
10845 let mut m = Model::new();
10846 let (r, c) = (CategoryId(1), CategoryId(2));
10847 m.add_category(r, "Row");
10848 m.add_category(c, "Col");
10849 for i in 0..rows {
10850 m.add_item(ItemId(100 + i), r, format!("r{i}"));
10851 }
10852 for i in 0..cols {
10853 m.add_item(ItemId(200 + i), c, format!("c{i}"));
10854 }
10855 m.add_measure(Measure {
10856 id: MeasureId(1),
10857 name: Name("Grid".into()),
10858 value_type: ValueType::Number,
10859 categories: vec![r, c],
10860 kind: MeasureKind::Input,
10861 description: None,
10862 });
10863 m
10864 }
10865
10866 #[test]
10868 fn a_fresh_matrix_fits_its_grid() {
10869 let small = grid_model(4, 2);
10870 let m = Matrix::new(&small, Some(MeasureId(1)));
10871 assert_eq!(
10872 product_len(&m.axis_item_lists(&small, &m.col_cats(&small))),
10873 2
10874 );
10875 assert_eq!(
10876 product_len(&m.axis_item_lists(&small, &m.row_cats(&small))),
10877 4
10878 );
10879 assert!((360.0..=900.0).contains(&m.rect.w), "w {}", m.rect.w);
10880 assert!((300.0..=700.0).contains(&m.rect.h), "h {}", m.rect.h);
10881 let wide = grid_model(4, 12);
10882 assert!(Matrix::new(&wide, Some(MeasureId(1))).rect.w > m.rect.w);
10883 }
10884
10885 #[test]
10887 fn the_first_worksheet_window_clears_the_menu() {
10888 let mut app = build_app(revenue_model());
10889 let ctx = egui::Context::default();
10890 crate::theme::apply(&ctx);
10891 let size = egui::vec2(1400.0, 900.0);
10892 for i in 0..4 {
10893 let _ = full_frame(&ctx, &mut app, size, f64::from(i) / 60.0);
10894 }
10895 let menu = ctx
10896 .memory(|m| m.area_rect(crate::menu::menu_id()))
10897 .expect("menu shown");
10898 let win = crate::nxwin::stored_rect(&ctx, matrix_window_id(app.matrices[0].uid))
10899 .expect("window shown");
10900 assert!(
10901 !menu.intersects(win),
10902 "menu {menu:?} overlaps window {win:?}"
10903 );
10904 }
10905
10906 #[test]
10908 fn status_messages_fade_after_a_few_seconds() {
10909 let mut app = build_app(grid_2x2_model());
10910 let ctx = egui::Context::default();
10911 crate::theme::apply(&ctx);
10912 let size = egui::vec2(1400.0, 900.0);
10913 let _ = full_frame(&ctx, &mut app, size, 10.0);
10914 app.status = "recalculated".into();
10915 let out = full_frame(&ctx, &mut app, size, 10.1);
10916 assert!(text_at(&out.shapes, "recalculated").is_some(), "drawn");
10917 let out = full_frame(&ctx, &mut app, size, 13.0);
10918 assert!(text_at(&out.shapes, "recalculated").is_some(), "still up");
10919 let out = full_frame(&ctx, &mut app, size, 16.2);
10920 assert!(text_at(&out.shapes, "recalculated").is_none(), "faded");
10921 assert!(app.status.is_empty());
10922 }
10923
10924 fn text_at(shapes: &[egui::epaint::ClippedShape], label: &str) -> Option<egui::Rect> {
10926 shapes.iter().find_map(|c| match &c.shape {
10927 egui::Shape::Text(t) if t.galley.text() == label => {
10928 Some(t.galley.rect.translate(t.pos.to_vec2()))
10929 }
10930 _ => None,
10931 })
10932 }
10933
10934 #[test]
10937 fn choosing_a_page_item_in_its_field_pages_the_grid() {
10938 let mut app = gutter_app();
10939 let mut desk = Desk::new();
10940 desk.settle(&mut app);
10941 assert_eq!(app.page_cats(), vec![CategoryId(3)]);
10942 assert_eq!(app.focused().page_idx, vec![0]);
10943 let shapes = desk.frame(&mut app, Vec::new()).shapes;
10944 let field = text_at(&shapes, "North").expect("the field shows North");
10945 desk.click(&mut app, field.center());
10946 let shapes = desk.frame(&mut app, Vec::new()).shapes;
10947 let south = text_at(&shapes, "South").expect("the popup lists South");
10948 desk.click(&mut app, south.center());
10949 assert_eq!(app.focused().page_idx, vec![1]);
10950 }
10951
10952 #[test]
10954 fn an_empty_axis_paints_no_placeholder() {
10955 let mut app = gutter_app();
10956 app.set_axis(CategoryId(3), Axis::Rows);
10957 assert!(app.page_cats().is_empty());
10958 let mut desk = Desk::new();
10959 desk.settle(&mut app);
10960 let shapes = desk.frame(&mut app, Vec::new()).shapes;
10961 assert!(!shapes.iter().any(|c| matches!(&c.shape,
10962 egui::Shape::Text(t) if t.galley.text().contains("(drop"))));
10963 }
10964
10965 fn tile_rect(desk: &Desk, app: &ImprovApp, c: CategoryId) -> egui::Rect {
10967 desk.ctx
10968 .viewport(|v| {
10969 v.prev_pass
10970 .widgets
10971 .get(egui::Id::new(("tile", app.matrices[0].uid, c.0)))
10972 .copied()
10973 })
10974 .expect("the tile")
10975 .rect
10976 }
10977
10978 #[test]
10981 fn a_tile_context_menu_moves_it() {
10982 let mut app = gutter_app();
10983 let mut desk = Desk::new();
10984 desk.settle(&mut app);
10985 let time = CategoryId(1);
10986 let tile = tile_rect(&desk, &app, time);
10987 assert!((tile.height() - TILE_H).abs() < 0.5, "{tile:?}");
10988 let at = tile.center();
10989 let right = |pressed| egui::Event::PointerButton {
10990 pos: at,
10991 button: egui::PointerButton::Secondary,
10992 pressed,
10993 modifiers: egui::Modifiers::NONE,
10994 };
10995 let _ = desk.frame(&mut app, vec![egui::Event::PointerMoved(at)]);
10996 let _ = desk.frame(&mut app, vec![right(true), right(false)]);
10997 let shapes = desk.frame(&mut app, Vec::new()).shapes;
10998 let item = shapes
10999 .iter()
11000 .find_map(|c| match &c.shape {
11001 egui::Shape::Text(t) if t.galley.text() == "Move to Columns" => {
11002 Some(t.galley.rect.translate(t.pos.to_vec2()))
11003 }
11004 _ => None,
11005 })
11006 .expect("the context menu is open");
11007 desk.click(&mut app, item.center());
11008 assert!(app.col_cats().contains(&time), "{:?}", app.col_cats());
11009 assert!(!app.row_cats().contains(&time));
11010 }
11011
11012 fn open_tile_menu(desk: &mut Desk, app: &mut ImprovApp, c: CategoryId) {
11016 let at = tile_rect(desk, app, c).center();
11017 let right = |pressed| egui::Event::PointerButton {
11018 pos: at,
11019 button: egui::PointerButton::Secondary,
11020 pressed,
11021 modifiers: egui::Modifiers::NONE,
11022 };
11023 let _ = desk.frame(app, vec![egui::Event::PointerMoved(at)]);
11024 let _ = desk.frame(app, vec![right(true), right(false)]);
11025 }
11026
11027 fn last_text_at(shapes: &[egui::epaint::ClippedShape], label: &str) -> Option<egui::Rect> {
11030 shapes.iter().rev().find_map(|c| match &c.shape {
11031 egui::Shape::Text(t) if t.galley.text() == label => {
11032 Some(t.galley.rect.translate(t.pos.to_vec2()))
11033 }
11034 _ => None,
11035 })
11036 }
11037
11038 #[test]
11042 fn a_tile_menu_filters_its_category() {
11043 let (mut app, mut desk) = grid_desk();
11044 let (time, y2026) = (CategoryId(1), ItemId(11));
11045 assert_eq!(app.row_cats(), vec![time]);
11046 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11047 assert!(text_at(&shapes, "Filters").is_none(), "no filter shelf");
11048 let row_shown = |desk: &Desk, app: &ImprovApp, ri: usize| {
11049 let id = egui::Id::new(("cell", app.matrices[0].uid, ri, 0_usize));
11050 desk.ctx.viewport(|v| v.prev_pass.widgets.get(id).is_some())
11051 };
11052 assert!(row_shown(&desk, &app, 1), "2026's row is shown");
11053
11054 open_tile_menu(&mut desk, &mut app, time);
11055 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11056 let item = last_text_at(&shapes, "2026").expect("the menu lists 2026");
11057 desk.click(&mut app, item.center());
11058 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11059 assert_eq!(app.matrices[0].grid_dims(&app.model).0, 1);
11060 assert!(!row_shown(&desk, &app, 1), "2026's row is hidden");
11061 assert!(app.matrices[0]
11062 .filters
11063 .iter()
11064 .any(|f| f.category == time && !f.items.contains(&y2026)));
11065 assert!(
11066 text_at(&shapes, "Time*").is_some(),
11067 "filtered tile reads Time*"
11068 );
11069
11070 let all = last_text_at(&shapes, "Show all").expect("the menu is still open");
11071 desk.click(&mut app, all.center());
11072 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11073 assert!(app.matrices[0].filters.is_empty());
11074 assert!(row_shown(&desk, &app, 1), "2026's row is back");
11075 assert!(text_at(&shapes, "Time").is_some(), "tile reads Time again");
11076 }
11077
11078 fn panel_win(name: &str) -> egui::Id {
11081 crate::nxwin::NxWindow::new(name, "").id()
11082 }
11083
11084 fn close_box_of(ctx: &egui::Context, win: egui::Id) -> Option<egui::Rect> {
11086 ctx.viewport(|v| v.prev_pass.widgets.get(win.with("close")).map(|w| w.rect))
11087 }
11088
11089 #[test]
11093 fn the_workspace_spans_the_full_window_width() {
11094 let mut app = build_app(grid_2x2_model());
11095 app.show_inspector = true;
11096 let ctx = egui::Context::default();
11097 crate::theme::apply(&ctx);
11098 let size = egui::vec2(1400.0, 900.0);
11099 let mut shapes = Vec::new();
11100 for i in 0..6 {
11101 shapes = full_frame(&ctx, &mut app, size, f64::from(i) / 60.0).shapes;
11102 }
11103 let ws = shapes
11104 .iter()
11105 .filter_map(|c| match &c.shape {
11106 egui::Shape::Rect(r) if r.fill == crate::theme::DARK_GRAY => Some(r.rect),
11107 _ => None,
11108 })
11109 .max_by(|a, b| a.area().total_cmp(&b.area()))
11110 .expect("the workspace is painted");
11111 assert_eq!((ws.left(), ws.right()), (0.0, 1400.0), "{ws:?}");
11112 assert!(ws.top() == 0.0 && ws.bottom() < 900.0, "{ws:?}");
11113 assert!(close_box_of(&ctx, panel_win("info")).is_some());
11114 assert!(close_box_of(&ctx, panel_win("browser")).is_some());
11115 }
11116
11117 #[test]
11120 fn clicking_a_measure_in_the_browser_selects_it() {
11121 let mut app = build_app(grid_2x2_model());
11122 select(&mut app, MeasureId(101));
11123 let mut desk = Desk::new();
11124 desk.settle(&mut app);
11125 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11126 let text_rect = |label: &str| {
11127 shapes.iter().find_map(|c| match &c.shape {
11128 egui::Shape::Text(t) if t.galley.text() == label => {
11129 Some(t.galley.rect.translate(t.pos.to_vec2()))
11130 }
11131 _ => None,
11132 })
11133 };
11134 let titles: Vec<&str> = shapes
11135 .iter()
11136 .filter_map(|c| match &c.shape {
11137 egui::Shape::Text(t) if t.galley.text().ends_with("- Model") => {
11138 Some(t.galley.text())
11139 }
11140 _ => None,
11141 })
11142 .collect();
11143 assert_eq!(titles, ["Untitled - Model"]);
11144 let at = text_rect("Price").expect("Price is listed").center();
11145 let browser = egui::LayerId::new(egui::Order::Middle, panel_win("browser"));
11146 assert_eq!(desk.ctx.layer_id_at(at), Some(browser), "in the window");
11147 desk.click(&mut app, at);
11148 assert_eq!(app.selected(), Some(MeasureId(100)));
11149 }
11150
11151 fn painted(shapes: &[egui::epaint::ClippedShape], label: &str) -> Option<egui::Rect> {
11153 shapes.iter().find_map(|c| match &c.shape {
11154 egui::Shape::Text(t) if t.galley.text() == label => {
11155 Some(t.galley.rect.translate(t.pos.to_vec2()))
11156 }
11157 _ => None,
11158 })
11159 }
11160
11161 #[test]
11165 fn browser_lists_views_click_selects_double_click_opens() {
11166 let mut app = build_app(grid_2x2_model());
11167 select(&mut app, MeasureId(101));
11168 let a = app.save_view("A").expect("saved A");
11169 select(&mut app, MeasureId(102));
11170 let b = app.save_view("B").expect("saved B");
11171 let mut desk = Desk::new();
11172 desk.settle(&mut app);
11173 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11174 assert!(painted(&shapes, "B - Worksheet").is_some(), "B listed");
11175 let at = painted(&shapes, "A - Worksheet")
11176 .expect("A listed")
11177 .center();
11178 let browser = egui::LayerId::new(egui::Order::Middle, panel_win("browser"));
11179 assert_eq!(desk.ctx.layer_id_at(at), Some(browser), "in the window");
11180
11181 desk.click(&mut app, at);
11182 assert_eq!(app.browser_sel, Some(a), "single click selects A");
11183 assert_eq!(app.current_view, Some(b), "...without opening it");
11184 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11185 assert!(
11186 painted(&shapes, &app.view_description(&app.model.views[&a])).is_some(),
11187 "the description shows A"
11188 );
11189
11190 desk.click(&mut app, at);
11191 desk.click(&mut app, at);
11192 assert_eq!(app.current_view, Some(a), "double-click opens A");
11193 assert_eq!(app.matrices[0].measure, Some(MeasureId(101)));
11194 }
11195
11196 #[test]
11198 fn view_description_names_the_measure() {
11199 let mut app = build_app(grid_2x2_model());
11200 select(&mut app, MeasureId(102));
11201 let id = app.save_view("Rev").expect("saved");
11202 let d = app.view_description(&app.model.views[&id]);
11203 assert!(d.starts_with("Revenue by "), "{d}");
11204 assert!(d.contains(" \u{d7} "), "rows × columns: {d}");
11205 }
11206
11207 #[test]
11209 fn browser_without_views_says_so() {
11210 let mut app = build_app(grid_2x2_model());
11211 let mut desk = Desk::new();
11212 desk.settle(&mut app);
11213 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11214 assert!(painted(&shapes, "(no saved views)").is_some());
11215 }
11216
11217 #[test]
11219 fn the_info_panel_opens_from_the_menu_and_closes_from_its_box() {
11220 let mut app = build_app(grid_2x2_model());
11221 let mut desk = Desk::new();
11222 desk.settle(&mut app);
11223 let info = panel_win("info");
11224 assert!(close_box_of(&desk.ctx, info).is_none(), "closed at start");
11225 app.run_command(&desk.ctx, Command::InfoPanel);
11226 desk.settle(&mut app);
11227 let close = close_box_of(&desk.ctx, info).expect("the Info window is shown");
11228 let shapes = desk.frame(&mut app, Vec::new()).shapes;
11229 assert!(
11230 shapes.iter().any(|c| matches!(&c.shape,
11231 egui::Shape::Text(t) if t.galley.text() == "name: Revenue")),
11232 "it shows the selected measure"
11233 );
11234 desk.click(&mut app, close.center());
11235 assert!(!app.show_inspector);
11236 desk.settle(&mut app);
11237 assert!(close_box_of(&desk.ctx, info).is_none(), "closed again");
11238 }
11239
11240 #[test]
11242 fn the_model_browser_command_toggles_the_browser() {
11243 let mut app = build_app(grid_2x2_model());
11244 let mut desk = Desk::new();
11245 desk.settle(&mut app);
11246 let browser = panel_win("browser");
11247 assert!(close_box_of(&desk.ctx, browser).is_some(), "open at start");
11248 app.run_command(&desk.ctx, Command::ModelBrowser);
11249 desk.settle(&mut app);
11250 assert!(!app.show_browser);
11251 assert!(close_box_of(&desk.ctx, browser).is_none());
11252 app.run_command(&desk.ctx, Command::ModelBrowser);
11253 desk.settle(&mut app);
11254 assert!(close_box_of(&desk.ctx, browser).is_some());
11255 }
11256
11257 #[test]
11260 fn focus_survives_removing_matrices() {
11261 let mut app = build_app(grid_2x2_model());
11262 app.add_matrix(Some(MeasureId(101)));
11263 app.add_matrix(Some(MeasureId(102)));
11264 assert_eq!(app.matrix_count(), 3);
11265 assert_eq!(app.focus_index(), 2, "a new matrix takes the focus");
11266
11267 app.remove_matrix(2);
11269 assert_eq!(app.focus_index(), 1);
11270 assert_eq!(app.focused().measure, app.matrices[1].measure);
11271
11272 app.set_focus(1);
11274 app.remove_matrix(0);
11275 assert_eq!(app.matrix_count(), 1);
11276 assert_eq!(app.focus_index(), 0);
11277 app.set_focus(99);
11279 assert_eq!(app.focus_index(), 0);
11280 }
11281
11282 #[test]
11286 fn single_measure_surfaces_follow_the_focus() {
11287 let mut app = two_matrix_app();
11288 app.set_focus(0);
11289 assert_eq!(app.selected(), Some(MeasureId(101)), "Quantity");
11290 assert_eq!(
11291 app.inspector_data(app.selected().unwrap()).unwrap().name,
11292 "Quantity"
11293 );
11294 let q_chart = app.chart_series();
11296
11297 app.set_focus(1);
11298 assert_eq!(app.selected(), Some(MeasureId(102)), "Revenue");
11299 let d = app.inspector_data(app.selected().unwrap()).unwrap();
11300 assert_eq!(d.name, "Revenue");
11301 assert!(d.is_derived, "the inspector moved to the derived measure");
11302 assert_eq!(
11303 app.formula_source(MeasureId(102)).as_deref(),
11304 Some("Price * Quantity")
11305 );
11306 let r_chart = app.chart_series();
11308 assert_ne!(
11309 q_chart.series[0].points, r_chart.series[0].points,
11310 "the chart must plot the focused matrix's measure"
11311 );
11312 }
11313
11314 #[test]
11320 fn each_matrix_has_its_own_title_naming_its_slice() {
11321 let mut app = build_app(sales_3d_model());
11322 select(&mut app, MeasureId(200)); app.sync_axis_state();
11324 assert_eq!(app.matrix_title(0), "Sales: North");
11326
11327 let model = app.model.clone();
11330 app.matrices[0].set_page(&model, 0, 1);
11331 assert_eq!(app.matrix_title(0), "Sales: South");
11332
11333 app.add_matrix(Some(MeasureId(200)));
11336 app.sync_axis_state();
11337 assert_eq!(app.matrix_title(1), "Sales: North");
11338 assert_eq!(app.matrix_title(0), "Sales: South");
11339
11340 let mut app = build_app(grid_2x2_model());
11343 select(&mut app, MeasureId(101));
11344 app.sync_axis_state();
11345 assert_eq!(app.matrix_title(0), "Quantity");
11346 assert_eq!(app.matrix_title(99), "", "an out-of-range index is empty");
11347 }
11348
11349 #[test]
11355 fn the_status_bar_reads_out_the_focused_selection_sum() {
11356 let mut app = two_matrix_app();
11357 app.set_focus(0);
11359 assert_eq!(app.selection_sum(), Some(100.0));
11360 assert_eq!(app.selection_readout(), "Sum 100");
11361
11362 let model = app.model.clone();
11364 app.matrices[0].move_cursor(&model, 1, 0); assert_eq!(app.selection_readout(), "Sum 120");
11366
11367 app.set_focus(1);
11370 assert_eq!(app.selection_sum(), Some(1000.0));
11371 assert_eq!(app.selection_readout(), "Sum 1000");
11372
11373 app.model
11375 .inputs
11376 .remove(&(MeasureId(101), decode(&app.cursor_key().unwrap())));
11377 app.rebuild_engine();
11378 assert_eq!(app.selection_sum(), None);
11379 assert_eq!(app.selection_readout(), "Sum \u{2014}");
11380 }
11381
11382 #[test]
11386 fn a_non_numeric_selection_has_no_sum() {
11387 let mut app = build_app(typed_model());
11388 select(&mut app, MeasureId(2)); app.sync_axis_state();
11390 assert_eq!(
11391 app.cell_text(MeasureId(2), &typed_key()).as_deref(),
11392 Some("hello")
11393 );
11394 assert_eq!(app.selection_sum(), None, "text has no sum");
11395 assert_eq!(app.selection_readout(), "Sum \u{2014}");
11396 }
11397
11398 #[test]
11402 fn document_tabs_switch_between_saved_canvases() {
11403 let mut app = build_app(grid_2x2_model());
11404 assert!(app.document_tabs().is_empty(), "no views, no tabs");
11405
11406 select(&mut app, MeasureId(101));
11408 app.sync_axis_state();
11409 let a = app.save_view("Canvas A").expect("saved A");
11410 app.add_matrix(Some(MeasureId(102)));
11412 app.sync_axis_state();
11413 let model = app.model.clone();
11414 app.matrices[1].pivot_rotate(&model);
11415 let b = app.save_view("Canvas B").expect("saved B");
11416
11417 let tabs = app.document_tabs();
11419 assert_eq!(
11420 tabs.iter().map(|(_, n, _)| n.as_str()).collect::<Vec<_>>(),
11421 vec!["Canvas A", "Canvas B"]
11422 );
11423 assert_eq!(
11424 tabs.iter()
11425 .filter(|(_, _, cur)| *cur)
11426 .map(|(id, _, _)| *id)
11427 .collect::<Vec<_>>(),
11428 vec![b],
11429 "the just-saved view is the current tab"
11430 );
11431
11432 let ctx = egui::Context::default();
11434 app.run_command(&ctx, Command::OpenView(0));
11435 app.sync_axis_state();
11436 assert_eq!(app.matrix_count(), 1, "Canvas A has one matrix");
11437 assert_eq!(app.matrices[0].measure, Some(MeasureId(101)));
11438 assert!(
11439 app.document_tabs()
11440 .iter()
11441 .any(|(id, _, cur)| *id == a && *cur),
11442 "tab A is now current"
11443 );
11444
11445 app.run_command(&ctx, Command::OpenView(1));
11447 app.sync_axis_state();
11448 assert_eq!(app.matrix_count(), 2, "Canvas B has two matrices");
11449 assert_eq!(
11450 app.matrices[1].row_cats(&app.model),
11451 vec![CategoryId(2)],
11452 "B's second matrix came back pivoted"
11453 );
11454
11455 let before = app.matrix_count();
11457 app.open_view(ViewId(999));
11458 assert_eq!(app.matrix_count(), before);
11459 app.run_command(&ctx, Command::OpenView(99));
11460 assert_eq!(app.matrix_count(), before, "a stale menu index is a no-op");
11461 }
11462
11463 #[test]
11466 fn menu_undo_restores_an_edited_cell() {
11467 let mut app = build_app(grid_2x2_model());
11468 let (qkey, _) = q_and_rev_keys();
11469 let ctx = egui::Context::default();
11470 assert!(!app.command_enabled(Command::Undo), "nothing to undo yet");
11471 app.set_cell(MeasureId(101), qkey.clone(), Value::Number(200.0))
11472 .expect("edit");
11473 assert!(app.command_enabled(Command::Undo));
11474 app.run_command(&ctx, Command::Undo);
11475 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("100"));
11476 assert!(app.command_enabled(Command::Redo));
11477 app.run_command(&ctx, Command::Redo);
11478 assert_eq!(app.cell_text(MeasureId(101), &qkey).as_deref(), Some("200"));
11479 }
11480
11481 #[test]
11482 fn menu_adds_and_removes_matrices() {
11483 let mut app = build_app(grid_2x2_model());
11484 let ctx = egui::Context::default();
11485 assert!(
11486 !app.command_enabled(Command::RemoveMatrix),
11487 "keep the last one"
11488 );
11489 app.run_command(&ctx, Command::AddMatrix);
11490 assert_eq!(app.matrix_count(), 2);
11491 assert!(app.command_enabled(Command::RemoveMatrix));
11492 app.run_command(&ctx, Command::RemoveMatrix);
11493 assert_eq!(app.matrix_count(), 1);
11494 }
11495
11496 #[test]
11497 fn menu_saves_a_view_and_toggles_panels() {
11498 let mut app = build_app(grid_2x2_model());
11499 let ctx = egui::Context::default();
11500 app.run_command(&ctx, Command::SaveView);
11501 assert_eq!(app.model.views.len(), 1);
11502 assert_eq!(app.document_tabs()[0].1, "view 1");
11503 assert!(app.document_tabs()[0].2, "the saved view is current");
11504
11505 assert!(!app.show_inspector, "the Info panel opens on demand");
11506 app.run_command(&ctx, Command::InfoPanel);
11507 assert!(app.show_inspector);
11508 app.run_command(&ctx, Command::InfoPanel);
11509 assert!(!app.show_inspector);
11510
11511 app.run_command(&ctx, Command::ToggleChart);
11512 assert!(app.show_chart);
11513 app.run_command(&ctx, Command::ToggleCsv);
11514 assert!(app.show_csv_wizard);
11515 }
11516
11517 fn press_command_key(
11520 app: &mut ImprovApp,
11521 key: egui::Key,
11522 focus: Option<&str>,
11523 ) -> egui::FullOutput {
11524 command_key_frame(&egui::Context::default(), app, key, focus)
11525 }
11526
11527 fn command_key_frame(
11531 ctx: &egui::Context,
11532 app: &mut ImprovApp,
11533 key: egui::Key,
11534 focus: Option<&str>,
11535 ) -> egui::FullOutput {
11536 let raw = egui::RawInput {
11537 modifiers: egui::Modifiers::COMMAND,
11538 events: vec![egui::Event::Key {
11539 key,
11540 physical_key: None,
11541 pressed: true,
11542 repeat: false,
11543 modifiers: egui::Modifiers::COMMAND,
11544 }],
11545 ..Default::default()
11546 };
11547 ctx.run(raw, |ctx| {
11548 if let Some(id) = focus {
11549 ctx.memory_mut(|m| m.request_focus(egui::Id::new(id)));
11550 }
11551 app.handle_command_keys(ctx);
11552 })
11553 }
11554
11555 fn closes(out: &egui::FullOutput) -> bool {
11557 out.viewport_output
11558 .values()
11559 .any(|v| v.commands.contains(&egui::ViewportCommand::Close))
11560 }
11561
11562 #[test]
11564 fn ctrl_s_on_an_in_memory_model_does_not_claim_saved() {
11565 let mut app = build_app(grid_2x2_model());
11566 assert!(app.db.is_empty());
11567 let _ = press_command_key(&mut app, egui::Key::S, None);
11568 assert_ne!(app.status, "saved model");
11569 }
11570
11571 #[test]
11574 fn quit_on_an_unsaved_in_memory_model_needs_confirming() {
11575 let mut app = build_app(grid_2x2_model());
11576 assert!(closes(&press_command_key(&mut app, egui::Key::Q, None)));
11577
11578 app.record_history(app.model.clone());
11579 assert!(!closes(&press_command_key(&mut app, egui::Key::Q, None)));
11580 assert_eq!(app.status, "unsaved in-memory model: Quit again to discard");
11581 assert!(closes(&press_command_key(&mut app, egui::Key::Q, None)));
11582
11583 app.quit_armed = false;
11584 assert!(!closes(&press_command_key(&mut app, egui::Key::Q, None)));
11585 let _ = press_command_key(&mut app, egui::Key::H, None);
11586 assert!(
11587 !closes(&press_command_key(&mut app, egui::Key::Q, None)),
11588 "Hide disarmed it"
11589 );
11590 }
11591
11592 #[test]
11594 fn native_title_marks_an_unsaved_in_memory_edit() {
11595 let title = |out: &egui::FullOutput| {
11596 out.viewport_output.values().find_map(|v| {
11597 v.commands.iter().find_map(|c| match c {
11598 egui::ViewportCommand::Title(t) => Some(t.clone()),
11599 _ => None,
11600 })
11601 })
11602 };
11603 let mut app = build_app(grid_2x2_model());
11604 let ctx = egui::Context::default();
11605 crate::theme::apply(&ctx);
11606 let size = egui::vec2(1400.0, 900.0);
11607 let out = full_frame(&ctx, &mut app, size, 0.0);
11608 assert_eq!(title(&out).as_deref(), Some("Improv \u{2014} Untitled"));
11609 let out = full_frame(&ctx, &mut app, size, 0.1);
11610 assert_eq!(title(&out), None, "an unchanged title is not resent");
11611
11612 app.record_history(app.model.clone());
11613 let out = full_frame(&ctx, &mut app, size, 0.2);
11614 let t = title(&out).expect("the edit retitles the window");
11615 assert!(t.ends_with(" \u{2022}"), "{t}");
11616 }
11617
11618 #[test]
11620 fn ctrl_s_saves_the_model_unless_a_field_has_focus() {
11621 let db = temp_db("ctrl_s");
11622 let _cleanup = Cleanup(db.clone());
11623 Store::open(&db)
11624 .and_then(|mut s| s.save_model(&grid_2x2_model()))
11625 .expect("seed the store");
11626 let mut app = ImprovApp::load(&db).expect("load");
11627 app.model
11628 .measures
11629 .get_mut(&MeasureId(101))
11630 .expect("Quantity")
11631 .description = Some("saved by ctrl+s".into());
11632 let described = |db: &str| {
11633 ImprovApp::load(db).expect("reload").model.measures[&MeasureId(101)]
11634 .description
11635 .clone()
11636 };
11637
11638 let _ = press_command_key(&mut app, egui::Key::S, Some("formula_edit"));
11639 assert_eq!(described(&db), None, "Ctrl+S fired while typing in a field");
11640 let _ = press_command_key(&mut app, egui::Key::S, None);
11641 assert_eq!(app.status, "saved model");
11642 assert_eq!(described(&db).as_deref(), Some("saved by ctrl+s"));
11643 }
11644
11645 #[test]
11647 fn menu_starts_on_the_workspace() {
11648 let mut app = build_app(grid_2x2_model());
11649 let ctx = egui::Context::default();
11650 crate::theme::apply(&ctx);
11651 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
11652 let menu = ctx
11653 .memory(|m| m.area_rect(menu::menu_id()))
11654 .expect("menu shown");
11655 assert!(menu.top() >= 0.0, "{menu:?}");
11656 let win = win_rect(&ctx, &app, 0);
11657 let title = egui::Rect::from_min_size(win.min, egui::vec2(win.width(), 23.0));
11658 assert!(!menu.intersects(title), "{menu:?} over {title:?}");
11659 }
11660
11661 #[test]
11664 fn ctrl_s_auto_repeat_does_not_save_again() {
11665 let db = temp_db("ctrl_s_repeat");
11666 let _cleanup = Cleanup(db.clone());
11667 Store::open(&db)
11668 .and_then(|mut s| s.save_model(&grid_2x2_model()))
11669 .expect("seed the store");
11670 let mut app = ImprovApp::load(&db).expect("load");
11671 let ctx = egui::Context::default();
11672 let _ = command_key_frame(&ctx, &mut app, egui::Key::S, None);
11673 assert_eq!(app.status, "saved model");
11674 app.status.clear();
11675 app.model
11676 .measures
11677 .get_mut(&MeasureId(101))
11678 .expect("Quantity")
11679 .description = Some("edited after the save".into());
11680 let _ = command_key_frame(&ctx, &mut app, egui::Key::S, None);
11682 assert_eq!(app.status, "", "a repeat ran Save");
11683 let reloaded = ImprovApp::load(&db).expect("reload");
11684 assert_eq!(reloaded.model.measures[&MeasureId(101)].description, None);
11685 }
11686
11687 #[test]
11691 fn selected_list_rows_have_a_white_face() {
11692 let mut app = build_app(grid_2x2_model());
11693 select(&mut app, MeasureId(101));
11694 let ctx = egui::Context::default();
11695 crate::theme::apply(&ctx);
11696 let mut shapes = Vec::new();
11697 for i in 0..6 {
11698 shapes = full_frame(
11699 &ctx,
11700 &mut app,
11701 egui::vec2(1280.0, 800.0),
11702 f64::from(i) / 60.0,
11703 )
11704 .shapes;
11705 }
11706 let text_rect = |label: &str| {
11707 shapes.iter().find_map(|c| match &c.shape {
11708 egui::Shape::Text(t) if t.galley.text() == label => {
11709 Some(t.galley.rect.translate(t.pos.to_vec2()))
11710 }
11711 _ => None,
11712 })
11713 };
11714 let fill_under = |label: &str| {
11715 let r = text_rect(label).unwrap_or_else(|| panic!("{label:?} not drawn"));
11716 shapes
11719 .iter()
11720 .filter_map(|c| match &c.shape {
11721 egui::Shape::Rect(s) if s.rect.contains_rect(r) => {
11722 Some((s.rect.area(), s.fill))
11723 }
11724 _ => None,
11725 })
11726 .min_by(|a, b| a.0.total_cmp(&b.0))
11727 .map(|(_, fill)| fill)
11728 };
11729 assert_eq!(fill_under("Quantity"), Some(crate::theme::WHITE));
11730 assert_ne!(fill_under("Price"), Some(crate::theme::WHITE));
11731 assert!(text_rect("Worksheet").is_some(), "the menu is drawn");
11732 }
11733
11734 fn temp_db(tag: &str) -> String {
11736 std::env::temp_dir()
11737 .join(format!(
11738 "improv_gui_{tag}_{}_{}.db",
11739 std::process::id(),
11740 std::time::SystemTime::now()
11741 .duration_since(std::time::UNIX_EPOCH)
11742 .expect("clock")
11743 .as_nanos()
11744 ))
11745 .to_string_lossy()
11746 .into_owned()
11747 }
11748
11749 struct Cleanup(String);
11751 impl Drop for Cleanup {
11752 fn drop(&mut self) {
11753 let _ = std::fs::remove_file(&self.0);
11754 }
11755 }
11756
11757 fn focused_frame(ctx: &egui::Context, app: &mut ImprovApp, id: &str, events: Vec<egui::Event>) {
11759 ctx.memory_mut(|m| m.request_focus(egui::Id::new(id)));
11760 let _ = full_frame_with(ctx, app, egui::vec2(1200.0, 800.0), 0.0, events, |_| ());
11761 }
11762
11763 fn enter() -> egui::Event {
11764 egui::Event::Key {
11765 key: egui::Key::Enter,
11766 physical_key: None,
11767 pressed: true,
11768 repeat: false,
11769 modifiers: egui::Modifiers::NONE,
11770 }
11771 }
11772
11773 #[test]
11775 fn typing_name_equals_formula_adds_a_measure() {
11776 let mut app = build_app(revenue_model());
11777 let ctx = egui::Context::default();
11778 crate::theme::apply(&ctx);
11779 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
11780 let text = egui::Event::Text("Margin = Revenue - 1".into());
11781 focused_frame(&ctx, &mut app, "formula_new", vec![text]);
11782 focused_frame(&ctx, &mut app, "formula_new", vec![enter()]);
11783 let (&id, _) = app
11784 .model
11785 .measures
11786 .iter()
11787 .find(|(_, m)| m.name.0 == "Margin")
11788 .unwrap_or_else(|| panic!("Margin not added: {}", app.status));
11789 assert_eq!(app.status, "added Margin");
11790 assert_eq!(app.selected(), Some(id));
11791 assert!(app.new_formula_line.is_empty());
11792 assert_eq!(app.values_for(id).get(&vec![(1, 10), (2, 20)]), Some(&69.0));
11793 }
11794
11795 fn key_event(key: egui::Key) -> egui::Event {
11796 egui::Event::Key {
11797 key,
11798 physical_key: None,
11799 pressed: true,
11800 repeat: false,
11801 modifiers: egui::Modifiers::NONE,
11802 }
11803 }
11804
11805 #[test]
11809 fn the_function_help_inserts_at_the_caret() {
11810 let mut app = build_app(revenue_model());
11811 select(&mut app, MeasureId(102));
11812 let ctx = egui::Context::default();
11813 crate::theme::apply(&ctx);
11814 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
11815 app.formula_buf = "Price * ".into();
11816 set_caret(&ctx, egui::Id::new("formula_edit"), &app.formula_buf, 8);
11817 focused_frame(
11818 &ctx,
11819 &mut app,
11820 "formula_edit",
11821 vec![key_event(egui::Key::F1)],
11822 );
11823 assert!(app.fn_help.open, "F1 opened it");
11824 assert_eq!(app.formula_target, FormulaEditor::Row);
11825 let size = egui::vec2(1200.0, 800.0);
11827 let _ = full_frame(&ctx, &mut app, size, 0.1);
11828 let out = full_frame_with(
11829 &ctx,
11830 &mut app,
11831 size,
11832 0.2,
11833 vec![egui::Event::Text("pmt".into())],
11834 |_| (),
11835 );
11836 assert_eq!(app.fn_help.query, "pmt");
11837 assert_eq!(app.fn_help.selected.map(|d| d.name), Some("PMT"));
11838 let texts = texts_of(&out);
11839 assert!(texts.iter().any(|t| t == "Financial"), "{texts:?}");
11840 assert!(texts
11841 .iter()
11842 .any(|t| t == "PMT(rate, nper, pv, [fv], [type])"));
11843 let insert = painted(&out.shapes, "Insert")
11844 .expect("Insert shown")
11845 .center();
11846 let mut t = 0.3;
11847 for e in [
11848 vec![egui::Event::PointerMoved(insert)],
11849 vec![press(insert, true)],
11850 vec![press(insert, false)],
11851 ] {
11852 let _ = full_frame_with(&ctx, &mut app, size, t, e, |_| ());
11853 t += 0.1;
11854 }
11855 assert_eq!(app.formula_buf, "Price * PMT(");
11856 let id = egui::Id::new("formula_edit");
11857 assert_eq!(caret_byte(&ctx, id, &app.formula_buf), 12);
11858 let _ = full_frame(&ctx, &mut app, size, t);
11859 assert!(ctx.memory(|m| m.has_focus(id)), "the edit has the keyboard");
11860 app.run_command(&ctx, Command::FunctionHelp);
11862 assert!(!app.fn_help.open);
11863 }
11864
11865 fn edit_frame(
11868 ctx: &egui::Context,
11869 app: &mut ImprovApp,
11870 id: &str,
11871 t: &mut f64,
11872 events: Vec<egui::Event>,
11873 ) -> egui::FullOutput {
11874 *t += 0.05;
11875 let id = egui::Id::new(id);
11878 if !ctx.memory(|m| m.has_focus(id)) {
11879 ctx.memory_mut(|m| m.request_focus(id));
11880 }
11881 full_frame_with(ctx, app, egui::vec2(1200.0, 800.0), *t, events, |_| ())
11882 }
11883
11884 #[test]
11890 fn typing_pm_then_tab_completes_pmt() {
11891 let mut app = build_app(revenue_model());
11892 let ctx = egui::Context::default();
11893 crate::theme::apply(&ctx);
11894 let mut t = 0.0;
11895 for id in ["formula_new", "formula_edit"] {
11896 select(&mut app, MeasureId(102));
11897 let _ = edit_frame(&ctx, &mut app, id, &mut t, Vec::new());
11898 let prefix = if id == "formula_new" { "X = " } else { "" };
11899 let text = format!("{prefix}PM");
11900 let buf = |app: &ImprovApp| {
11901 if id == "formula_new" {
11902 app.new_formula_line.clone()
11903 } else {
11904 app.formula_buf.clone()
11905 }
11906 };
11907 if id == "formula_edit" {
11908 app.formula_buf.clear();
11909 }
11910 let _ = edit_frame(
11911 &ctx,
11912 &mut app,
11913 id,
11914 &mut t,
11915 vec![egui::Event::Text(text.clone())],
11916 );
11917 let out = edit_frame(&ctx, &mut app, id, &mut t, Vec::new());
11918 let texts = texts_of(&out);
11919 assert_eq!(buf(&app), text);
11920 for want in [
11921 "PMT(rate, nper, pv, [fv], [type])",
11922 "T(rate, nper, pv, [fv], [type])",
11923 ] {
11924 assert!(
11925 texts.iter().any(|s| s == want),
11926 "{id}: no {want:?} in {texts:?}"
11927 );
11928 }
11929 let _ = edit_frame(&ctx, &mut app, id, &mut t, vec![key_event(egui::Key::Tab)]);
11930 assert_eq!(buf(&app), format!("{prefix}PMT("), "{id}");
11931 let eid = egui::Id::new(id);
11932 assert_eq!(caret_byte(&ctx, eid, &buf(&app)), prefix.len() + 4);
11933 assert!(ctx.memory(|m| m.has_focus(eid)), "{id}: Tab kept the focus");
11934 let typed = egui::Event::Text("0.05, ".into());
11936 let _ = edit_frame(&ctx, &mut app, id, &mut t, vec![typed]);
11937 let out = edit_frame(&ctx, &mut app, id, &mut t, Vec::new());
11938 let texts = texts_of(&out);
11939 assert!(
11940 texts.iter().any(|s| s == "nper, pv, [fv], [type])"),
11941 "{texts:?}"
11942 );
11943 assert!(texts
11944 .iter()
11945 .any(|s| s == "PMT(rate, nper, pv, [fv], [type])"));
11946 }
11947
11948 select(&mut app, MeasureId(102));
11950 app.formula_buf.clear();
11951 let id = "formula_edit";
11952 let _ = edit_frame(
11953 &ctx,
11954 &mut app,
11955 id,
11956 &mut t,
11957 vec![egui::Event::Text("Qua".into())],
11958 );
11959 let _ = edit_frame(
11960 &ctx,
11961 &mut app,
11962 id,
11963 &mut t,
11964 vec![key_event(egui::Key::Enter)],
11965 );
11966 assert_eq!(
11967 app.formula_buf, "Quantity",
11968 "Enter accepts while the list is up"
11969 );
11970 assert_eq!(app.status, "", "and does not commit");
11971 let typed = egui::Event::Text(" * S".into());
11972 let _ = edit_frame(&ctx, &mut app, id, &mut t, vec![typed]);
11973 let _ = edit_frame(
11974 &ctx,
11975 &mut app,
11976 id,
11977 &mut t,
11978 vec![key_event(egui::Key::ArrowDown)],
11979 );
11980 let _ = edit_frame(
11981 &ctx,
11982 &mut app,
11983 id,
11984 &mut t,
11985 vec![key_event(egui::Key::ArrowUp)],
11986 );
11987 let _ = edit_frame(
11988 &ctx,
11989 &mut app,
11990 id,
11991 &mut t,
11992 vec![key_event(egui::Key::ArrowDown)],
11993 );
11994 let _ = edit_frame(
11995 &ctx,
11996 &mut app,
11997 id,
11998 &mut t,
11999 vec![key_event(egui::Key::Escape)],
12000 );
12001 assert_eq!(app.formula_buf, "Quantity * S", "Esc only closed the list");
12002 let second = crate::completion::offer(&app.model, "Quantity * S", 12)[1]
12003 .insert
12004 .clone();
12005 let _ = edit_frame(
12006 &ctx,
12007 &mut app,
12008 id,
12009 &mut t,
12010 vec![key_event(egui::Key::Backspace)],
12011 );
12012 let typed = egui::Event::Text("S".into());
12013 let _ = edit_frame(&ctx, &mut app, id, &mut t, vec![typed]);
12014 let _ = edit_frame(
12015 &ctx,
12016 &mut app,
12017 id,
12018 &mut t,
12019 vec![key_event(egui::Key::ArrowDown)],
12020 );
12021 let _ = edit_frame(&ctx, &mut app, id, &mut t, vec![key_event(egui::Key::Tab)]);
12022 assert_eq!(
12023 app.formula_buf,
12024 format!("Quantity * {second}"),
12025 "Down chose the second"
12026 );
12027 app.formula_buf = "Quantity * 2".into();
12028 let _ = edit_frame(
12029 &ctx,
12030 &mut app,
12031 id,
12032 &mut t,
12033 vec![key_event(egui::Key::Enter)],
12034 );
12035 assert_eq!(app.status, "formula updated");
12036 }
12037
12038 #[test]
12041 fn the_new_formula_line_errors_inline() {
12042 let mut app = build_app(revenue_model());
12043 let ctx = egui::Context::default();
12044 crate::theme::apply(&ctx);
12045 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12046 let line = "Margin = Prise - 1";
12047 let text = egui::Event::Text(line.into());
12048 focused_frame(&ctx, &mut app, "formula_new", vec![text]);
12049 focused_frame(&ctx, &mut app, "formula_new", vec![enter()]);
12050 let d = app.new_formula_error.clone().expect("an inline error");
12051 assert_eq!(d.span.map(|r| &line[r]), Some("Prise"));
12052 assert_eq!(d.hint.as_deref(), Some("did you mean Price?"));
12053 assert_eq!(app.new_formula_line, line, "the text is kept");
12054 let out = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12055 let texts = texts_of(&out);
12056 assert!(
12057 texts.iter().any(|t| t == "did you mean Price?"),
12058 "{texts:?}"
12059 );
12060 assert!(texts.iter().any(|t| t == "⚠ unknown measure: Prise"));
12061 app.new_formula_line = "Margin = Quantity + LEN(Price)".into();
12063 focused_frame(&ctx, &mut app, "formula_new", vec![enter()]);
12064 let d = app.new_formula_error.clone().expect("an inline error");
12065 assert_eq!(d.span.map(|r| &app.new_formula_line[r]), Some("LEN"));
12066 let x = egui::Event::Text("x".into());
12067 focused_frame(&ctx, &mut app, "formula_new", vec![x]);
12068 assert_eq!(app.new_formula_error, None);
12069 }
12070
12071 #[test]
12074 fn editing_the_selected_row_commits_and_errors_inline() {
12075 let mut app = build_app(revenue_model());
12076 select(&mut app, MeasureId(102));
12077 let ctx = egui::Context::default();
12078 crate::theme::apply(&ctx);
12079 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12080 assert_eq!(app.formula_buf, "Price * Quantity");
12081 app.formula_buf = "Price + Quantity".into();
12082 focused_frame(&ctx, &mut app, "formula_edit", vec![enter()]);
12083 assert_eq!(app.status, "formula updated");
12084 let key = vec![(1, 10), (2, 20)];
12085 assert_eq!(app.values_for(MeasureId(102)).get(&key), Some(&17.0));
12086 let before = app.model.measures[&MeasureId(102)].kind.clone();
12087 app.formula_buf = "Price *".into();
12088 focused_frame(&ctx, &mut app, "formula_edit", vec![enter()]);
12089 assert!(app.formula_error.is_some(), "{}", app.status);
12090 assert_eq!(app.model.measures[&MeasureId(102)].kind, before);
12091 }
12092
12093 #[test]
12095 fn no_top_formula_bar() {
12096 let mut app = build_app(revenue_model());
12097 let ctx = egui::Context::default();
12098 crate::theme::apply(&ctx);
12099 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12100 assert!(
12101 egui::containers::panel::PanelState::load(&ctx, egui::Id::new("formula_bar")).is_none()
12102 );
12103 }
12104
12105 #[test]
12108 fn from_placement_restores_valid_page_pins_and_ignores_stale_ones() {
12109 let model = sales_3d_model();
12110 let (region, south) = (CategoryId(3), ItemId(31));
12111 let mut p = MatrixPlacement::new(MeasureId(200));
12112 p.page_items = vec![(region, south)];
12113 let m = Matrix::from_placement(&model, &p).expect("measure exists");
12114 assert_eq!(m.axis_order, vec![CategoryId(1), CategoryId(2), region]);
12116 assert_eq!(m.page_idx, vec![1], "South is the second Region item");
12117 for stale in [(region, ItemId(999)), (CategoryId(9), south)] {
12118 p.page_items = vec![stale];
12119 let m = Matrix::from_placement(&model, &p).unwrap();
12120 assert_eq!(m.page_idx, vec![0], "{stale:?} falls back to the first");
12121 }
12122 p.axis_order = vec![region, CategoryId(2), CategoryId(1)];
12123 let m = Matrix::from_placement(&model, &p).unwrap();
12124 assert_eq!(m.page_cats(&model), vec![CategoryId(1)]);
12125 assert!(Matrix::from_placement(&model, &MatrixPlacement::new(MeasureId(9))).is_none());
12126 }
12127
12128 #[test]
12129 fn axis_and_page_ops_ignore_what_the_matrix_cannot_address() {
12130 let mut app = build_app(sales_3d_model());
12131 select(&mut app, MeasureId(200));
12132 app.sync_axis_state();
12133 let before = app.focused().axis_order.clone();
12134 app.set_axis(CategoryId(77), Axis::Rows);
12135 assert_eq!(app.focused().axis_order, before, "not a dimension: no-op");
12136
12137 app.set_page(5, 0); assert_eq!(app.focused().page_idx, vec![0]);
12139 app.focused_mut().page_idx.clear(); app.set_page(0, 1);
12141 assert_eq!(app.focused().page_idx, vec![1]);
12142
12143 hide_all_items(&mut app, CategoryId(3)); app.set_page(0, 0);
12145 app.page_first(1);
12146 assert_eq!(app.focused().page_idx, vec![1], "nothing to page to");
12147 assert!(app.focused().selection_keys(&app.model).is_empty());
12148
12149 let model = Model::new();
12151 let mut m = Matrix::new(&model, None);
12152 m.pivot_rotate(&model);
12153 m.page_first(&model, 1);
12154 assert!(m.axis_order.is_empty() && m.page_idx.is_empty());
12155 }
12156
12157 #[test]
12158 fn a_filter_holding_a_stale_item_is_kept_even_at_full_length() {
12159 let mut app = build_app(sales_3d_model());
12160 select(&mut app, MeasureId(200));
12161 let region = CategoryId(3);
12162 app.focused_mut().filters = vec![Filter {
12163 category: region,
12164 items: vec![ItemId(999)],
12165 }];
12166 app.toggle_filter_item(region, ItemId(30));
12167 assert_eq!(
12169 app.focused().filters[0].items,
12170 vec![ItemId(999), ItemId(30)]
12171 );
12172 }
12173
12174 #[test]
12175 fn a_scalar_row_axis_still_addresses_the_cursor_cell() {
12176 let mut app = build_app(revenue_model());
12177 select(&mut app, MeasureId(100)); app.sync_axis_state();
12179 app.set_axis(CategoryId(2), Axis::Columns);
12180 assert!(app.row_cats().is_empty());
12181 assert_eq!(app.cursor_key(), Some(vec![(2, 20)]));
12182 }
12183
12184 #[test]
12185 fn matrix_list_guards_and_an_empty_canvas() {
12186 let mut app = ImprovApp::load("").expect("in-memory model");
12187 assert!(app.model.measures.is_empty());
12188 assert_eq!(app.save_view("v"), None, "no measure: nothing to save");
12189 app.begin_edit_cursor();
12190 app.cycle_measure(1);
12191 assert_eq!(
12192 (app.selected(), app.focused().editing.clone()),
12193 (None, None)
12194 );
12195
12196 let mut app = build_app(revenue_model());
12197 app.add_matrix(Some(MeasureId(100)));
12198 assert!(!app.remove_matrix(5));
12199 assert_eq!(app.matrix_count(), 2);
12200 app.matrices.clear();
12201 app.sync_axis_state();
12202 assert_eq!(app.matrix_count(), 1, "the canvas is never empty");
12203 }
12204
12205 #[test]
12206 fn blank_view_name_and_no_op_formula_toggles_leave_no_undo_point() {
12207 let mut app = build_app(revenue_model());
12208 assert_eq!(app.save_view(" "), None);
12209 assert_eq!(app.status, "view name is required");
12210 app.set_formula_enabled(MeasureId(100), false).unwrap(); app.set_formula_enabled(MeasureId(102), true).unwrap(); assert!(!app.can_undo());
12213 app.undo_with_status();
12214 assert_eq!(app.status, "nothing to undo");
12215 app.redo_with_status();
12216 assert_eq!(app.status, "nothing to redo");
12217 }
12218
12219 #[test]
12220 fn csv_wizard_keeps_a_typed_measure_id() {
12221 let mut app = build_app(revenue_model());
12222 app.import_form.measure_id = "42".into();
12223 app.toggle_csv_wizard();
12224 assert!(app.show_csv_wizard);
12225 assert_eq!(app.import_form.measure_id, "42");
12226 app.toggle_csv_wizard();
12227 assert!(!app.show_csv_wizard);
12228 }
12229
12230 #[test]
12231 fn quit_warns_once_only_for_an_edited_in_memory_model() {
12232 let mut app = build_app(revenue_model());
12233 assert!(app.quit_allowed(), "nothing to lose");
12234 app.undo_stack.push(app.model.clone());
12235 app.db = "/some/file.db".into();
12236 assert!(app.quit_allowed(), "a file-backed model is saved");
12237 app.db.clear();
12238 assert!(!app.quit_allowed());
12239 assert!(app.status.contains("Quit again"));
12240 assert!(app.quit_allowed(), "the second Quit closes");
12241 }
12242
12243 #[test]
12244 fn literals_without_a_dsl_token_are_refused() {
12245 assert_eq!(literal_dsl(&Value::Number(f64::INFINITY)), None);
12246 assert_eq!(
12247 literal_dsl(&Value::Boolean(false)).as_deref(),
12248 Some("FALSE")
12249 );
12250 assert_eq!(literal_dsl(&Value::Text("a\"b".into())), None);
12251 assert_eq!(literal_dsl(&Value::Text("a\\b".into())), None);
12252 assert_eq!(
12253 literal_dsl(&Value::Text("ok".into())).as_deref(),
12254 Some("\"ok\"")
12255 );
12256 assert!(parse_typed(&Model::new(), ValueType::Boolean, "1").is_err());
12258 }
12259
12260 #[test]
12261 fn view_descriptions_name_rows_columns_and_pages() {
12262 let app = build_app(revenue_model());
12263 let mut v = View::from_matrices(
12264 ViewId(1),
12265 Name("v".into()),
12266 MatrixPlacement::new(MeasureId(101)),
12267 vec![],
12268 );
12269 assert_eq!(app.view_description(&v), "Quantity by Time \u{d7} Product");
12270 v.n_rows = 0;
12271 v.n_cols = 0;
12272 assert_eq!(app.view_description(&v), "Quantity; pages: Time, Product");
12273 v.measure = MeasureId(999);
12274 assert_eq!(app.view_description(&v), "v: measure no longer exists");
12275 }
12276
12277 fn grid_key(app: &mut ImprovApp, key: egui::Key, modifiers: egui::Modifiers) {
12282 let ctx = egui::Context::default();
12283 let raw = egui::RawInput {
12284 modifiers,
12285 events: vec![egui::Event::Key {
12286 key,
12287 physical_key: None,
12288 pressed: true,
12289 repeat: false,
12290 modifiers,
12291 }],
12292 ..Default::default()
12293 };
12294 let mi = app.focus_index();
12295 let _ = ctx.run(raw, |ctx| {
12296 egui::CentralPanel::default().show(ctx, |ui| app.handle_grid_keys(ui, mi));
12297 });
12298 }
12299
12300 #[test]
12301 fn every_grid_key_binding_moves_edits_pages_or_cycles() {
12302 use egui::{Key, Modifiers as M};
12303 let mut app = build_app(grid_2x2_model());
12304 select(&mut app, MeasureId(101));
12305 app.sync_axis_state();
12306 let cur = |app: &ImprovApp| (app.focused().cursor_row, app.focused().cursor_col);
12307 for (k, want) in [
12308 (Key::J, (1, 0)),
12309 (Key::K, (0, 0)),
12310 (Key::L, (0, 1)),
12311 (Key::H, (0, 0)),
12312 (Key::ArrowDown, (1, 0)),
12313 (Key::ArrowRight, (1, 1)),
12314 (Key::ArrowUp, (0, 1)),
12315 (Key::ArrowLeft, (0, 0)),
12316 ] {
12317 grid_key(&mut app, k, M::NONE);
12318 assert_eq!(cur(&app), want, "{k:?}");
12319 }
12320 grid_key(&mut app, Key::J, M::COMMAND);
12321 assert_eq!(cur(&app), (0, 0), "a chord is not a grid key");
12322 grid_key(&mut app, Key::ArrowDown, M::SHIFT);
12323 assert_eq!(app.focused().selection(), (0..=1, 0..=0));
12324 grid_key(&mut app, Key::F2, M::NONE);
12325 assert!(app.focused().editing.is_some(), "F2 edits");
12326 app.focused_mut().editing = None;
12327 grid_key(&mut app, Key::N, M::NONE);
12328 assert_eq!(app.selected(), Some(MeasureId(102)));
12329 grid_key(&mut app, Key::N, M::SHIFT);
12330 assert_eq!(app.selected(), Some(MeasureId(101)));
12331
12332 let mut app = build_app(sales_3d_model());
12333 select(&mut app, MeasureId(200));
12334 app.sync_axis_state();
12335 let page = |app: &ImprovApp| app.focused().page_idx[0];
12336 grid_key(&mut app, Key::PageDown, M::NONE);
12337 assert_eq!(page(&app), 1);
12338 grid_key(&mut app, Key::PageUp, M::NONE);
12339 assert_eq!(page(&app), 0);
12340 grid_key(&mut app, Key::OpenBracket, M::NONE);
12341 assert_eq!(page(&app), 1, "paging wraps");
12342 }
12343
12344 #[test]
12345 fn a_cell_edit_cancels_on_escape_and_commits_on_enter() {
12346 use egui::{Key, Modifiers as M};
12347 let (mut app, mut desk) = grid_desk();
12348 let at = cell_rect(&desk, &app, 1, 1).center();
12349 desk.click(&mut app, at);
12350 desk.click(&mut app, at);
12351 assert!(app.matrices[0].editing.is_some());
12352 key(&mut desk, &mut app, Key::Escape, M::NONE);
12353 assert!(app.matrices[0].editing.is_none());
12354 assert_eq!(app.status, "edit cancelled");
12355
12356 desk.time += 1.0; desk.click(&mut app, at);
12358 desk.click(&mut app, at);
12359 assert!(app.matrices[0].editing.is_some());
12360 app.matrices[0].edit_buf = "9".into();
12361 key(&mut desk, &mut app, Key::Enter, M::NONE);
12362 assert!(app.matrices[0].editing.is_none());
12363 assert_eq!(app.status, "cell updated");
12364 let k = vec![(1, 11), (2, 21)];
12365 assert_eq!(app.cell_text(MeasureId(101), &k).as_deref(), Some("9"));
12366 }
12367
12368 #[test]
12369 fn clicking_a_derived_cell_says_it_is_computed() {
12370 let (mut app, mut desk) = grid_desk();
12371 app.matrices[0].measure = Some(MeasureId(102));
12372 desk.settle(&mut app);
12373 let at = cell_rect(&desk, &app, 0, 0).center();
12374 desk.click(&mut app, at);
12375 assert_eq!(app.status, "derived cells are computed, not editable");
12376 assert!(app.matrices[0].editing.is_none());
12377 }
12378
12379 #[test]
12380 fn a_page_with_no_pinnable_item_renders_no_cells() {
12381 let mut app = build_app(sales_3d_model());
12382 select(&mut app, MeasureId(200));
12383 app.sync_axis_state();
12384 hide_all_items(&mut app, CategoryId(3));
12385 let mut desk = Desk::new();
12386 desk.settle(&mut app);
12387 let id = egui::Id::new(("cell", app.matrices[0].uid, 0usize, 0usize));
12388 assert!(desk.ctx.viewport(|v| v.prev_pass.widgets.get(id).is_none()));
12389 }
12390
12391 fn texts_of(out: &egui::FullOutput) -> Vec<String> {
12392 fn walk(s: &egui::Shape, acc: &mut Vec<String>) {
12393 match s {
12394 egui::Shape::Text(t) => acc.push(t.galley.text().to_owned()),
12395 egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, acc)),
12396 _ => {}
12397 }
12398 }
12399 let mut acc = Vec::new();
12400 out.shapes.iter().for_each(|c| walk(&c.shape, &mut acc));
12401 acc
12402 }
12403
12404 #[test]
12405 fn the_info_panel_describes_inputs_scalars_and_erroring_formulas() {
12406 let mut model = revenue_model();
12407 model.add_measure(Measure {
12408 id: MeasureId(103),
12409 name: Name("Rate".into()),
12410 value_type: ValueType::Number,
12411 categories: vec![],
12412 kind: MeasureKind::Input,
12413 description: None,
12414 });
12415 let mut app = build_app(model);
12416 app.show_inspector = true;
12417 let mut snap = (*app.snapshot).clone();
12419 let errs: improv_engine::session::MeasureValues =
12420 [(vec![(1, 10), (2, 20)], CellValue::Err(0))].into();
12421 snap.insert(MeasureId(102), std::sync::Arc::new(errs));
12422 app.snapshot = std::sync::Arc::new(snap);
12423 let mut desk = Desk::new();
12424 let mut shown = |app: &mut ImprovApp, m: u32| {
12425 select(app, MeasureId(m));
12426 desk.settle(app);
12427 texts_of(&desk.frame(app, Vec::new()))
12428 };
12429 let t = shown(&mut app, 100);
12430 assert!(t.iter().any(|t| t == "kind: input"), "{t:?}");
12431 assert!(t.iter().any(|t| t == "dimensions: Product"));
12432 let t = shown(&mut app, 103);
12433 assert!(t.iter().any(|t| t == "dimensions: (scalar)"), "{t:?}");
12434 let t = shown(&mut app, 102);
12435 assert!(t.iter().any(|t| t == "kind: derived"));
12436 assert!(t.iter().any(|t| t.starts_with("depends on: ")));
12437 assert!(t.iter().any(|t| t.starts_with("formula: ")));
12438 assert!(t.iter().any(|t| t.contains("cell(s) have errors")), "{t:?}");
12439 }
12440
12441 #[test]
12442 fn an_empty_canvas_says_so_and_a_store_titles_the_window() {
12443 let mut app = ImprovApp::load("").unwrap();
12444 let ctx = egui::Context::default();
12445 crate::theme::apply(&ctx);
12446 let out = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12447 assert!(texts_of(&out).iter().any(|t| t.starts_with("No measures.")));
12448
12449 let mut app = build_app(revenue_model());
12450 app.db = "/nowhere/sales.db".into();
12451 app.undo_stack.push(app.model.clone());
12452 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 1.0);
12453 assert!(!app.dirty, "a store-backed model is not dirtied by history");
12454 assert_eq!(app.last_title, "Improv \u{2014} sales");
12455 }
12456
12457 #[test]
12458 fn the_chart_panel_shows_when_toggled_on() {
12459 let mut app = build_app(revenue_model());
12460 app.show_chart = true;
12461 let mut desk = Desk::new();
12462 desk.settle(&mut app);
12463 assert!(texts_of(&desk.frame(&mut app, Vec::new()))
12464 .iter()
12465 .any(|t| t == "Chart"));
12466 }
12467
12468 #[test]
12469 fn the_csv_forms_add_and_remove_rows_and_report_errors() {
12470 let mut app = build_app(revenue_model());
12471 let ctx = egui::Context::default();
12472 crate::theme::apply(&ctx);
12473 let mut time = 0.0;
12474 let mut frame = |app: &mut ImprovApp, events: Vec<egui::Event>| {
12475 time += 1.0;
12476 let raw = egui::RawInput {
12477 time: Some(time),
12478 screen_rect: Some(egui::Rect::from_min_size(
12479 egui::Pos2::ZERO,
12480 egui::vec2(1200.0, 800.0),
12481 )),
12482 events,
12483 ..Default::default()
12484 };
12485 ctx.run(raw, |ctx| {
12486 egui::CentralPanel::default().show(ctx, |ui| {
12487 app.csv_import_form(ui);
12488 app.csv_export_form(ui);
12489 });
12490 })
12491 };
12492 let mut click = |app: &mut ImprovApp, label: &str| {
12493 let shapes = frame(app, Vec::new()).shapes;
12494 let at = last_text_at(&shapes, label)
12495 .unwrap_or_else(|| panic!("{label} not painted"))
12496 .center();
12497 let _ = frame(app, vec![egui::Event::PointerMoved(at)]);
12498 let _ = frame(app, vec![press(at, true), press(at, false)]);
12499 };
12500 click(&mut app, "+ dimension");
12501 assert_eq!(app.import_form.dimensions.len(), 2);
12502 click(&mut app, "-");
12503 assert_eq!(app.import_form.dimensions.len(), 1);
12504 click(&mut app, "Import");
12505 assert!(app.status.starts_with("import error"), "{}", app.status);
12506 click(&mut app, "Export");
12507 assert_eq!(app.status, "export error: select a measure to export");
12508 }
12509
12510 #[test]
12511 fn closing_the_csv_window_hides_it() {
12512 let mut app = build_app(revenue_model());
12513 app.show_csv_wizard = true;
12514 let mut desk = Desk::new();
12515 desk.settle(&mut app);
12516 let win = desk
12519 .ctx
12520 .memory(|m| m.area_rect(egui::Id::new("Import / Export CSV")))
12521 .expect("window shown");
12522 let icon = desk.ctx.style().spacing.icon_width;
12523 let at = desk
12524 .ctx
12525 .viewport(|v| {
12526 v.prev_pass.widgets.layers().find_map(|(_, ws)| {
12527 ws.iter().find_map(|w| {
12528 let r = w.rect;
12529 ((r.width() - icon).abs() < 0.5
12530 && (r.height() - icon).abs() < 0.5
12531 && win.contains_rect(r)
12532 && r.right() > win.right() - 2.0 * icon)
12533 .then(|| r.center())
12534 })
12535 })
12536 })
12537 .expect("close button laid out");
12538 desk.click(&mut app, at);
12539 assert!(!app.show_csv_wizard);
12540 }
12541
12542 #[test]
12543 fn calc_recalculates_and_a_disabled_formula_is_struck_through() {
12544 let (mut app, mut desk) = grid_desk();
12545 app.set_formula_enabled(MeasureId(102), false).unwrap();
12546 desk.settle(&mut app);
12547 let out = desk.frame(&mut app, Vec::new());
12548 let struck = out.shapes.iter().any(|c| match &c.shape {
12549 egui::Shape::Text(t) => {
12550 t.galley.text() == "Revenue = Price * Quantity"
12551 && t.galley
12552 .job
12553 .sections
12554 .iter()
12555 .any(|s| s.format.strikethrough.width > 0.0)
12556 }
12557 _ => false,
12558 });
12559 assert!(struck, "{:?}", texts_of(&out));
12560 let calc = painted(&out.shapes, "Calc").expect("Calc shown").center();
12561 desk.click(&mut app, calc);
12562 assert_eq!(app.status, "recalculated");
12563 }
12564
12565 #[test]
12566 fn typing_clears_a_stale_formula_error_and_escape_reverts() {
12567 let mut app = build_app(revenue_model());
12568 select(&mut app, MeasureId(102));
12569 let ctx = egui::Context::default();
12570 crate::theme::apply(&ctx);
12571 let _ = full_frame(&ctx, &mut app, egui::vec2(1200.0, 800.0), 0.0);
12572 app.formula_error = Some(crate::diagnostics::Diagnostic::plain("old"));
12573 let x = egui::Event::Text("x".into());
12574 focused_frame(&ctx, &mut app, "formula_edit", vec![x]);
12575 assert_ne!(app.formula_buf, "Price * Quantity");
12576 assert_eq!(app.formula_error, None);
12577 let esc = egui::Event::Key {
12578 key: egui::Key::Escape,
12579 physical_key: None,
12580 pressed: true,
12581 repeat: false,
12582 modifiers: egui::Modifiers::NONE,
12583 };
12584 focused_frame(&ctx, &mut app, "formula_edit", vec![esc.clone()]);
12585 assert_eq!(app.formula_buf, "Price * Quantity");
12586
12587 let n = app.model.measures.len();
12590 let t = egui::Event::Text("oops".into());
12591 focused_frame(&ctx, &mut app, "formula_new", vec![t]);
12592 focused_frame(&ctx, &mut app, "formula_new", vec![esc]);
12593 assert_eq!(
12594 (app.model.measures.len(), app.new_formula_line.as_str()),
12595 (n, "oops")
12596 );
12597 focused_frame(&ctx, &mut app, "formula_new", vec![enter()]);
12598 assert_eq!(app.status, "type Name = formula");
12599 assert_eq!(app.model.measures.len(), n);
12600 }
12601
12602 #[test]
12603 fn the_menu_runs_what_it_picks_and_plain_keys_are_no_chords() {
12604 let mut app = build_app(revenue_model());
12605 let mut desk = Desk::new();
12606 desk.settle(&mut app);
12607 let shapes = desk.frame(&mut app, Vec::new()).shapes;
12608 let quit = painted(&shapes, "Quit").expect("menu shows Quit").center();
12609 let _ = desk.frame(&mut app, vec![egui::Event::PointerMoved(quit)]);
12610 let out = desk.frame(&mut app, vec![press(quit, true), press(quit, false)]);
12611 assert!(closes(&out), "Quit with nothing to lose closes");
12612
12613 let ctx = egui::Context::default();
12614 let raw = egui::RawInput {
12615 events: vec![egui::Event::Key {
12616 key: egui::Key::Q,
12617 physical_key: None,
12618 pressed: true,
12619 repeat: false,
12620 modifiers: egui::Modifiers::NONE,
12621 }],
12622 ..Default::default()
12623 };
12624 let out = ctx.run(raw, |ctx| app.handle_command_keys(ctx));
12625 assert!(!closes(&out), "a bare Q is not Cmd+Q");
12626 }
12627
12628 #[test]
12629 fn save_view_command_uses_the_typed_name_or_saves_nothing() {
12630 let ctx = egui::Context::default();
12631 let mut app = build_app(revenue_model());
12632 app.view_name = "Mine".into();
12633 app.run_command(&ctx, Command::SaveView);
12634 assert_eq!(app.status, "saved view 'Mine'");
12635 assert!(app.view_name.is_empty());
12636 let mut empty = ImprovApp::load("").unwrap();
12637 empty.run_command(&ctx, Command::SaveView);
12638 assert!(empty.model.views.is_empty());
12639 assert!(empty.status.is_empty());
12640 }
12641
12642 #[test]
12643 fn the_browsers_save_view_button_saves_a_named_view_only() {
12644 let (mut app, mut desk) = grid_desk();
12645 let button = |desk: &mut Desk, app: &mut ImprovApp| {
12646 let shapes = desk.frame(app, Vec::new()).shapes;
12647 painted(&shapes, "Save view")
12648 .expect("button shown")
12649 .center()
12650 };
12651 let p = button(&mut desk, &mut app);
12652 desk.click(&mut app, p);
12653 assert_eq!(app.status, "view name is required");
12654 app.view_name = "V".into();
12655 let p = button(&mut desk, &mut app);
12656 desk.click(&mut app, p);
12657 assert_eq!(app.model.views.len(), 1);
12658 assert!(app.view_name.is_empty());
12659 }
12660
12661 #[test]
12662 fn the_enabled_checkbox_toggles_a_formula_and_says_so() {
12663 let (mut app, mut desk) = grid_desk();
12664 app.matrices[0].measure = Some(MeasureId(101)); desk.settle(&mut app);
12666 let shapes = desk.frame(&mut app, Vec::new()).shapes;
12667 let num = painted(&shapes, "1.").expect("row numbered");
12669 let at = egui::pos2(num.left() - 12.0, num.center().y);
12670 desk.click(&mut app, at);
12671 assert_eq!(app.status, "formula Revenue disabled");
12672 assert!(formula_disabled(&app.model.measures[&MeasureId(102)]));
12673 desk.time += 1.0;
12674 desk.click(&mut app, at);
12675 assert_eq!(app.status, "formula Revenue enabled");
12676 }
12677
12678 #[test]
12679 fn the_browser_lists_each_categorys_items_when_expanded() {
12680 let (mut app, mut desk) = grid_desk();
12681 let open = |desk: &mut Desk, app: &mut ImprovApp, label: &str| {
12682 let shapes = desk.frame(app, Vec::new()).shapes;
12683 let at = painted(&shapes, label)
12684 .unwrap_or_else(|| panic!("{label} not shown"))
12685 .center();
12686 desk.click(app, at);
12687 desk.settle(app);
12688 };
12689 open(&mut desk, &mut app, "Categories");
12690 let shapes = desk.frame(&mut app, Vec::new()).shapes;
12691 let product = shapes
12692 .iter()
12693 .rev()
12694 .find_map(|c| match &c.shape {
12695 egui::Shape::Text(t) if t.galley.text() == "Product" => {
12696 Some(t.galley.rect.translate(t.pos.to_vec2()))
12697 }
12698 _ => None,
12699 })
12700 .expect("Product header");
12701 desk.click(&mut app, product.center());
12702 desk.settle(&mut app);
12703 let texts = texts_of(&desk.frame(&mut app, Vec::new()));
12704 assert!(
12705 texts.iter().filter(|t| *t == "WidgetB").count() >= 2,
12706 "{texts:?}"
12707 );
12708 }
12709
12710 #[test]
12711 fn pivoting_a_columns_only_layout_moves_it_to_rows() {
12712 let mut app = build_app(revenue_model());
12713 select(&mut app, MeasureId(100)); app.sync_axis_state();
12715 app.set_axis(CategoryId(2), Axis::Columns);
12716 app.pivot_rotate();
12717 assert_eq!(
12718 (app.row_cats(), app.col_cats()),
12719 (vec![CategoryId(2)], vec![])
12720 );
12721 }
12722
12723 #[test]
12724 fn a_new_measure_needs_a_name_and_ignores_unknown_refs_for_categories() {
12725 let mut app = build_app(revenue_model());
12726 assert_eq!(
12727 app.add_derived_measure(" ", "Price").unwrap_err(),
12728 "measure name is required"
12729 );
12730 let id = app.add_derived_measure("Two", "1 + 1").expect("adds");
12732 assert!(app.model.measures[&id].categories.is_empty());
12733 }
12734
12735 #[test]
12736 fn a_text_cell_edits_left_aligned_and_commits() {
12737 let mut app = build_app(typed_model());
12738 app.matrices[0].measure = Some(MeasureId(2)); let mut desk = Desk::new();
12740 desk.settle(&mut app);
12741 let at = cell_rect(&desk, &app, 0, 0).center();
12742 desk.click(&mut app, at);
12743 desk.click(&mut app, at);
12744 assert!(app.matrices[0].editing.is_some());
12745 app.matrices[0].edit_buf = "bye".into();
12746 key(&mut desk, &mut app, egui::Key::Enter, egui::Modifiers::NONE);
12747 assert_eq!(
12748 app.cell_text(MeasureId(2), &typed_key()).as_deref(),
12749 Some("bye")
12750 );
12751 }
12752
12753 #[test]
12757 fn loads_and_saves_a_postgres_model() {
12758 let Ok(dsn) = std::env::var("IMPROV_TEST_PG") else {
12759 println!("skipped: IMPROV_TEST_PG not set");
12760 return;
12761 };
12762 let base = if improv_store::is_postgres(&dsn) {
12763 dsn
12764 } else {
12765 let kv: HashMap<_, _> = dsn
12766 .split_whitespace()
12767 .filter_map(|p| p.split_once('='))
12768 .collect();
12769 let get = |k| kv.get(k).copied().unwrap_or_default();
12770 let (u, pw, h, d) = (get("user"), get("password"), get("host"), get("dbname"));
12771 format!("postgres://{u}:{pw}@{h}/{d}")
12772 };
12773 let url = format!("{base}?model=gui_{}", std::process::id());
12774 let mut store = improv_store::Store::open(&url).unwrap();
12775 store.save_model(&revenue_model()).unwrap();
12776
12777 let mut app = ImprovApp::load(&url).expect("load postgres model");
12778 assert_eq!(app.model, revenue_model());
12779 assert_eq!(app.file_name(), format!("gui_{}", std::process::id()));
12780 let revenue = app.model.measure_by_name("Revenue").unwrap().id;
12781 assert!(app.formula_source(revenue).is_some());
12782 app.model.items.get_mut(&ItemId(10)).unwrap().name = Name("Q1x".into());
12783 app.save().expect("save to postgres");
12784 let back = store.reload_model().unwrap();
12785 assert_eq!(back.items[&ItemId(10)].name.0, "Q1x");
12786 }
12787
12788 #[test]
12794 fn gui_save_keeps_concurrent_sql_edits_to_bound_cells() {
12795 let Ok(dsn) = std::env::var("IMPROV_TEST_PG") else {
12796 println!("skipped: IMPROV_TEST_PG not set");
12797 return;
12798 };
12799 let pid = std::process::id();
12800 let (model, table) = (format!("guig2_{pid}"), format!("guig2_sales_{pid}"));
12801 let kv: HashMap<_, _> = dsn
12804 .split_whitespace()
12805 .filter_map(|p| p.split_once('='))
12806 .collect();
12807 let get = |k| kv.get(k).copied().unwrap_or_default();
12808 let url = format!(
12809 "postgres://{}:{}@{}/{}?model={model}",
12810 get("user"),
12811 get("password"),
12812 get("host"),
12813 get("dbname")
12814 );
12815 let mut m = Model::new();
12818 m.add_category(CategoryId(1), "Time");
12819 m.add_category(CategoryId(2), "Product");
12820 m.add_measure(Measure {
12821 id: MeasureId(100),
12822 name: Name("Price".into()),
12823 value_type: ValueType::Number,
12824 categories: vec![CategoryId(1), CategoryId(2)],
12825 kind: MeasureKind::Input,
12826 description: None,
12827 });
12828 let mut store = improv_store::Store::open(&url).unwrap();
12829 store.save_model(&m).unwrap();
12830 let mut sql = postgres::Client::connect(&dsn, postgres::NoTls).unwrap();
12831 sql.batch_execute(&format!(
12832 "DROP TABLE IF EXISTS {table};
12833 CREATE TABLE {table} (quarter text, product text, price float8,
12834 PRIMARY KEY (quarter, product));
12835 INSERT INTO {table} VALUES ('Q1','Widget',10), ('Q2','Widget',20);
12836 INSERT INTO improv.binding (model_id, measure_id, rel, value_col, dim_cols)
12837 SELECT id, 100, '{table}'::regclass, 'price',
12838 '{{\"Time\":\"quarter\",\"Product\":\"product\"}}'
12839 FROM improv.model WHERE name = '{model}';"
12840 ))
12841 .unwrap();
12842 let price = |sql: &mut postgres::Client, q: &str| -> f64 {
12843 sql.query_one(
12844 &format!("SELECT price FROM {table} WHERE quarter = $1"),
12845 &[&q],
12846 )
12847 .unwrap()
12848 .get(0)
12849 };
12850
12851 let mut app = ImprovApp::load(&url).expect("load");
12852 let item = |app: &ImprovApp, n: &str| {
12853 app.model
12854 .items
12855 .values()
12856 .find(|i| i.name.0 == n)
12857 .map(|i| i.id)
12858 .unwrap()
12859 };
12860 let (q1, w) = (item(&app, "Q1"), item(&app, "Widget"));
12861 let at =
12862 |t| improv_core_model::Coordinate::from_pairs([(CategoryId(1), t), (CategoryId(2), w)]);
12863
12864 sql.execute(
12866 &format!("UPDATE {table} SET price = 25 WHERE quarter = 'Q2'"),
12867 &[],
12868 )
12869 .unwrap();
12870 app.model
12871 .set_input(MeasureId(100), at(q1), Value::Number(11.0));
12872 app.save().expect("saving an unrelated cell succeeds");
12873 assert_eq!(price(&mut sql, "Q1"), 11.0, "the GUI's edit was written");
12874 assert_eq!(
12875 price(&mut sql, "Q2"),
12876 25.0,
12877 "the SQL edit survived the GUI save"
12878 );
12879
12880 sql.execute(
12882 &format!("UPDATE {table} SET price = 99 WHERE quarter = 'Q1'"),
12883 &[],
12884 )
12885 .unwrap();
12886 app.model
12887 .set_input(MeasureId(100), at(q1), Value::Number(12.0));
12888 let err = app
12889 .save()
12890 .expect_err("overwriting a concurrent SQL edit is a conflict");
12891 assert!(err.contains("conflict"), "{err}");
12892 assert_eq!(price(&mut sql, "Q1"), 99.0, "the SQL value is untouched");
12893
12894 assert!(app.command_enabled(Command::RevertToSaved));
12896 app.revert_to_saved().expect("revert");
12897 assert_eq!(
12898 app.model.input(MeasureId(100), &at(q1)),
12899 Some(&Value::Number(99.0))
12900 );
12901 assert!(!app.can_undo() && !app.dirty);
12902 app.model
12903 .set_input(MeasureId(100), at(q1), Value::Number(12.0));
12904 app.save().expect("the retried edit saves after the revert");
12905 assert_eq!(price(&mut sql, "Q1"), 12.0);
12906 sql.batch_execute(&format!(
12907 "DROP TABLE {table}; DELETE FROM improv.model WHERE name = '{model}'"
12908 ))
12909 .unwrap();
12910 }
12911}