Skip to main content

improv_gui/
app.rs

1//! GUI application state and rendering.
2//!
3//! State: the loaded `Model`, a live `session::Engine` (built over all derived
4//! measures), the current derived-measure snapshot, and the selected measure.
5//! The panels and windows (model browser, pivot grid, formula editor, Info
6//! panel) are all
7//! rendered each frame from this state. The GUI is a pure *view/controller*
8//! over `improv_engine` + the model; it adds no modeling semantics (see
9//! `.agent/steering/AGENT_GUI_STEERING.md` §9.3).
10//!
11//! The engine is rebuilt (a structural rebuild) only when the measure structure
12//! changes — a formula edit or a new derived measure. Plain cell-value edits go
13//! through `engine.set` incrementally.
14
15use 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/// One matrix on the canvas: a measure, that matrix's OWN pivot / filter /
34/// cursor / cell-editor state, and where it sits (`rect`).
35///
36/// This is the GUI-side twin of [`improv_core_model::MatrixPlacement`] (which is
37/// the persisted form): a placement plus the transient bits a placement has no
38/// business storing — the keyboard cursor, the open cell editor, and the
39/// measured gutter geometry. Several of these live on one canvas
40/// (`ImprovApp::matrices`), each independently pivotable, which is plan Step 3
41/// (`docs/reviews/2026-09-22-gui-reconstruction-plan.md`).
42///
43/// Every method here takes `&Model` rather than holding one: a matrix is pure
44/// presentation over the shared model, exactly as the per-view state was before
45/// it became a list.
46#[derive(Debug, Clone, Default)]
47pub(crate) struct Matrix {
48    /// Keys this matrix's window and widget state for its lifetime, so they
49    /// stay with it when an earlier matrix is removed (an index would shift).
50    /// Unique per process ([`next_matrix_uid`]); a clone keeps it.
51    uid: u64,
52    /// The measure this matrix shows. `None` only for an empty model.
53    measure: Option<MeasureId>,
54    /// A permutation of `measure`'s categories: the first `n_rows` are stacked
55    /// on rows (outer→inner), the next `n_cols` on columns, the rest are pages.
56    /// Pivoting reorders this without touching formulas. Reset to the measure's
57    /// natural order when this matrix's measure changes.
58    axis_order: Vec<CategoryId>,
59    /// How many leading `axis_order` categories are stacked on the ROW axis,
60    /// and how many (after those) on the COLUMN axis. The rest are pages.
61    /// Default 1/1 (one category per axis); increasing them stacks categories
62    /// on an axis (nested group headers over the Cartesian product of items).
63    n_rows: usize,
64    n_cols: usize,
65    /// Selected item index for each page (extra) dimension, positionally by
66    /// page dim (i.e. `axis_order[n_rows + n_cols + i]`).
67    page_idx: Vec<usize>,
68    /// The measure `axis_order`/`page_idx` currently describe (so the pivot
69    /// state resets when this matrix's measure changes).
70    axis_for: Option<MeasureId>,
71    /// Active per-category display filters for this matrix. Presentation only
72    /// (hides items from the grid; never touches data). Captured when saving a
73    /// view; reset on measure switch.
74    filters: Vec<Filter>,
75    /// Keyboard cell cursor into this matrix's grid (row/col indices), clamped
76    /// to its dimensions.
77    cursor_row: usize,
78    cursor_col: usize,
79    /// The fixed corner of a range selection (Shift+arrow, Shift+click,
80    /// drag); `None` selects just the cursor cell. Transient, never persisted.
81    anchor: Option<(usize, usize)>,
82    /// The cell currently being edited in THIS matrix, and its text buffer.
83    editing: Option<(MeasureId, CoordKey)>,
84    edit_buf: String,
85    /// Its worksheet window's outer rect, relative to the workspace's top-left
86    /// (persisted in the view).
87    rect: CanvasRect,
88    /// Where THIS matrix's margin gutters and table ended up in the last
89    /// laid-out frame (see [`GutterRects`]). `None` until it has rendered once.
90    /// Layout output, not state.
91    gutters: Option<GutterRects>,
92    /// The row and column category stacks THIS matrix's grid was last rendered
93    /// with, as `(rows, columns)`. `None` until it has rendered once.
94    ///
95    /// Render output, the same role `gutters` plays for the geometry — and for
96    /// the same reason: it makes "each matrix renders its OWN pivot" checkable
97    /// instead of merely intended. Asserting on `axis_order` alone cannot see a
98    /// renderer that ignores its matrix index and draws the focused matrix's
99    /// layout N times.
100    rendered_axes: Option<(Vec<CategoryId>, Vec<CategoryId>)>,
101}
102
103/// One of the formula pane's two editors.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum FormulaEditor {
106    /// The selected row's (`formula_buf`, id `formula_edit`).
107    Row,
108    /// The `Name = formula` line (`new_formula_line`, id `formula_new`).
109    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
121/// Byte offset of the caret in text edit `id` over `text` (its end when
122/// the edit has none yet).
123fn 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
133/// Put the caret of text edit `id` over `text` at byte `at`.
134fn 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
144/// The running GUI application.
145pub struct ImprovApp {
146    /// The store path (empty = in-memory scratch); used for saving.
147    db: String,
148    model: Model,
149    /// Live incremental engine over all derived measures, plus its snapshot.
150    engine: Option<Engine>,
151    snapshot: Snapshot,
152    /// The matrices on the canvas, in canvas order. **Always non-empty**: index
153    /// 0 is the view's primary matrix (see [`View`]'s flat fields), the rest are
154    /// its `placements`.
155    matrices: Vec<Matrix>,
156    /// Which matrix has the keyboard: the only one that consumes grid shortcuts
157    /// and the one every "selected measure" surface (formula editor, inspector,
158    /// chart, status readout) reads from. Always a valid index into `matrices`.
159    focus: usize,
160    /// The saved view this canvas was loaded from, if any — the highlighted
161    /// document tab (see [`ImprovApp::document_tabs`]).
162    current_view: Option<ViewId>,
163    status: String,
164
165    // --- transient UI edit buffers (view state, not model state) ---
166    /// The formula-editor text for the selected derived measure.
167    formula_buf: String,
168    /// The measure whose formula `formula_buf` currently holds (so we reload
169    /// the buffer when the selection changes).
170    formula_for: Option<MeasureId>,
171    /// The last failed `commit_formula`'s error, located in its text (the
172    /// span the editor underlines, the message and hint under the row). Set
173    /// on a failed commit; cleared on a successful one or when the buffer is
174    /// edited again.
175    formula_error: Option<crate::diagnostics::Diagnostic>,
176    /// The formula locale: how formulas are typed and shown (decimal comma,
177    /// `;`, translated names). `Locale::from_env` in the real app; English
178    /// in tests so they do not depend on the machine's locale.
179    locale: improv_core_model::Locale,
180    /// Text buffer for the formula list's last line, where `Name = formula`
181    /// adds a derived measure.
182    new_formula_line: String,
183    /// The last failed add's error, located in `new_formula_line` (as
184    /// `formula_error` is for the selected row).
185    new_formula_error: Option<crate::diagnostics::Diagnostic>,
186    /// Text buffer for the "Save view" name field.
187    view_name: String,
188    /// The model browser's selected view row (single click). A double-click
189    /// opens it; `None` or a deleted id falls back to the current view.
190    browser_sel: Option<ViewId>,
191
192    /// Whether the read-only chart panel is shown, and its bar/line toggle.
193    show_chart: bool,
194    chart_line: bool,
195
196    /// CSV/TSV import/export wizard: whether the panel is shown, and its two
197    /// forms (see `csv_wizard`).
198    show_csv_wizard: bool,
199    import_form: ImportForm,
200    /// Whether the Info panel window is shown (off until Info ▸ Info Panel…).
201    show_inspector: bool,
202    /// The function help browser (Help ▸ Functions…, F1).
203    fn_help: crate::function_help::Browser,
204    /// The formula editor last focused: where the help browser inserts.
205    formula_target: FormulaEditor,
206    /// Whether the model browser window is shown (View ▸ Model Browser).
207    show_browser: bool,
208    /// Quit was picked once on an unsaved in-memory model; the next Quit
209    /// closes. Any other command disarms it.
210    quit_armed: bool,
211    /// The model has edits that are not persisted: an in-memory model with
212    /// undo history, or a db model whose last autosave failed. Shown as the
213    /// title's ` •`. (Canvas-layout changes since the last saved view are
214    /// not tracked.)
215    dirty: bool,
216    /// The store high-water this model was loaded/last saved at, so a save
217    /// refuses to clobber another process's writes (`StoreError::Conflict`).
218    save_token: improv_store::SaveToken,
219    /// The native title last sent, so `update` only sends a change.
220    last_title: String,
221    /// The `status` the status bar last drew, and when (egui time) it first
222    /// drew it: a message clears itself [`STATUS_TTL`] seconds later.
223    status_shown: String,
224    status_at: f64,
225    /// The floating application menu's position and open submenu.
226    menu: menu::MenuState,
227    export_form: ExportForm,
228
229    /// Undo/redo history: whole-`Model` snapshots, oldest first. `undo_stack`
230    /// holds the states BEFORE each recorded mutation; `redo_stack` the states
231    /// undone away. See [`ImprovApp::undo`] and `UNDO_DEPTH`.
232    undo_stack: Vec<Model>,
233    redo_stack: Vec<Model>,
234
235    /// Where each formula-list row landed in the last laid-out frame, as
236    /// `(measure, row rect)` in display order. Recorded by
237    /// [`ImprovApp::formula_list`]; empty until the pane has rendered.
238    /// Layout output, not model state — the same role `gutters` plays for the
239    /// grid, and what lets a headless test click a row where a user would.
240    formula_row_rects: Vec<(MeasureId, egui::Rect)>,
241}
242
243/// The measured screen geometry of the grid's margin gutters and the table they
244/// frame, as laid out in the last frame.
245///
246/// The gutters are *docked to the grid's edges*, NeXT-style
247/// (`docs/reviews/refs/improv-next-workspace-1.0.png`): page tiles (each over
248/// its item field) top-left and column tiles top-right, both in the strip along
249/// the table's top edge; row tiles in the bottom-left strip below the table. Recording the
250/// rects makes that adjacency *checkable* instead of merely apparent — see
251/// [`gutters_frame_table`].
252#[derive(Clone, Copy, Debug, PartialEq)]
253pub(crate) struct GutterRects {
254    /// The top strip (pages left, columns right), spanning the table's top edge.
255    top: egui::Rect,
256    /// The table's left edge: a zero-width rect (there is no side gutter).
257    left: egui::Rect,
258    /// The table (header + cells) the two gutters frame.
259    table: egui::Rect,
260    /// The bottom strip: row-axis categories.
261    well: egui::Rect,
262    /// The horizontal scroller, inline in the bottom strip right of the row
263    /// tiles (spec §4.4).
264    hbar: egui::Rect,
265}
266
267/// Tolerance, in points, for the gutter-adjacency invariant. The edges are
268/// computed from the same panel cursor so they agree exactly; half a point of
269/// slack only guards against rounding in egui's pixel alignment.
270const EDGE_EPS: f32 = 0.5;
271
272/// Whether the gutters genuinely *frame* the table: the row gutter's right edge
273/// **is** the table's left edge, the column gutter's bottom edge **is** the
274/// table's top edge, each gutter spans the table along its other axis, and the
275/// corner well sits below the table at the row gutter's left edge.
276///
277/// This is the geometric property the old horizontal "axis shelf" failed: three
278/// drop zones side by side above the grid touch no grid edge at all. It is
279/// `debug_assert!`ed every frame (when the window has room — see
280/// [`gutters_have_room`]) and asserted on recorded rects in the headless layout
281/// test.
282fn 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        // The row gutter runs alongside the table, not merely up to a corner.
287        && g.left.min.y <= g.table.min.y + EDGE_EPS
288        && g.left.max.y + EDGE_EPS >= g.table.min.y
289        // The column gutter spans across the table.
290        && g.top.min.x <= g.table.min.x + EDGE_EPS
291        && g.top.max.x + EDGE_EPS >= g.table.min.x
292        // The well is the bottom-left corner of the framed region: below the row
293        // gutter (whose height IS the framed region's height — the table's own
294        // `min_rect` can overflow that when its content does not fit), and flush
295        // with the row gutter's left edge.
296        && g.well.min.y + EDGE_EPS >= g.left.max.y
297        && adjoins(g.well.min.x, g.left.min.x)
298}
299
300/// A fresh [`Matrix`] uid.
301fn 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
306/// The [`crate::nxwin::NxWindow`] id of the worksheet window of the matrix
307/// whose uid is `uid`.
308fn matrix_window_id(uid: u64) -> egui::Id {
309    crate::nxwin::NxWindow::new(("matrix", uid), "").id()
310}
311
312/// The rect to show NxWindow `win` at: `home` on the workspace until the user
313/// moves or resizes it, then where they left it. The workspace settles over
314/// the first frames and follows the native window's size, so a plain
315/// `default_rect` would keep the first frame's guess.
316fn 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
328/// Whether the window left the gutters enough room to be laid out at all.
329///
330/// A panel clamps its extent to what is available, so in an absurdly small
331/// window (a few dozen points of central panel) the bottom well is squeezed up
332/// past the table and [`gutters_frame_table`] cannot hold — through no fault of
333/// the layout. The per-frame `debug_assert!` is gated on this so shrinking the
334/// window can never panic a debug build; the invariant itself stays strict, and
335/// the layout test asserts it at a real window size.
336fn gutters_have_room(g: &GutterRects) -> bool {
337    g.well.min.y >= g.top.max.y && g.left.height() > 0.0
338}
339
340/// A grid's horizontal scroll area, wrapping its table's vertical one and
341/// the rect that vertical scroller was laid out in.
342type GridScroll =
343    egui::scroll_area::ScrollAreaOutput<(egui::scroll_area::ScrollAreaOutput<()>, egui::Rect)>;
344
345/// A category tile's height (the NeXT tiles are 17 px tall).
346const TILE_H: f32 = 17.0;
347
348/// How far a newly added matrix is offset from the last one, in points, so a
349/// fresh matrix never lands exactly on top of its predecessor (the classic
350/// cascade). See [`ImprovApp::add_matrix`].
351const CASCADE: f32 = 28.0;
352
353/// Seconds a status message stays up before the status bar clears it.
354const STATUS_TTL: f64 = 5.0;
355
356/// Where a fresh canvas's first matrix starts on the workspace: right of the
357/// application menu (at x 4, about 110 wide), as in the NeXT layout.
358const MENU_CLEAR: f32 = crate::menu::MENU_W + 12.0;
359
360/// Smallest a worksheet window can be resized to. Below roughly this it has no
361/// room for its gutters and a cell, so shrinking further would only hide its
362/// own chrome; [`gutters_have_room`] is the same judgement about the window.
363const MATRIX_MIN_W: f32 = 240.0;
364const MATRIX_MIN_H: f32 = 180.0;
365
366/// Height a worksheet window keeps for its gutters and grid: its formula pane
367/// grows (or defaults) no taller than what leaves this much above it.
368const GRID_MIN_H: f32 = 120.0;
369
370/// How many model states the undo (and redo) stack keeps; older entries are
371/// evicted.
372///
373/// ponytail: history is a bounded stack of FULL `Model` clones — one clone per
374/// mutation, up to 50 resident copies. That is honest and obviously correct
375/// (the model already round-trips and compares by value), but the ceiling is
376/// memory: 50 × model size. If models get big enough for that to hurt, replace
377/// the snapshots with a command/delta log (per-edit inverse operations) behind
378/// the same `undo`/`redo`/`record_history` API — the call sites do not care
379/// which it is.
380const UNDO_DEPTH: usize = 50;
381
382/// Initial window inner size: room for the menu, a default matrix and the Info
383/// panel side by side on the workspace.
384pub const INITIAL_WINDOW: [f32; 2] = [1280.0, 800.0];
385/// Minimum window inner size. Smaller windows still never overdraw the side
386/// panels (the canvas clips), but the matrix would be mostly scrolled away.
387pub const MIN_WINDOW: [f32; 2] = [960.0, 600.0];
388
389/// Which grid axis a category is assigned to.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Axis {
392    Rows,
393    Columns,
394    Pages,
395}
396
397/// A filter change asked for from a category tile's menu, applied to that
398/// tile's matrix after the gutters are laid out (like a re-pivot move).
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum FilterReq {
401    /// Show/hide one item of the category.
402    Toggle(CategoryId, ItemId),
403    /// Show every item of the category again.
404    Clear(CategoryId),
405}
406
407/// The resolved pivot for the chart: the ROW category stack, the COLUMN stack,
408/// and the pinned `(category, item)` for every PAGE dimension.
409pub(crate) type ChartAxes = (Vec<CategoryId>, Vec<CategoryId>, Vec<(CategoryId, ItemId)>);
410
411impl Matrix {
412    /// A matrix showing `measure` with its natural axis order and the default
413    /// canvas geometry.
414    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        // Fit the window to its grid (plus the formula pane), within bounds.
430        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    /// A matrix restoring a saved [`MatrixPlacement`] verbatim: its measure,
438    /// axis split, filters and geometry, with the page pins resolved
439    /// positionally by page dimension (an item that no longer exists falls back
440    /// to the first one).
441    ///
442    /// `None` when the placement's measure is gone — the caller decides whether
443    /// that is an error (a whole view) or one matrix to drop.
444    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        // Clamp the axis split to the restored order's length.
462        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        // Page selections, positionally by page dimension.
466        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    /// This matrix as a persistable [`MatrixPlacement`] — everything a saved
486    /// view needs to reproduce it, and nothing transient (no cursor, no open
487    /// editor). `None` when no measure is shown: there is no matrix to save.
488    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    /// Show `measure` in this matrix, dropping any open cell editor. The pivot
501    /// state re-homes on the next [`Matrix::sync_axis_state`].
502    fn select(&mut self, measure: Option<MeasureId>) {
503        self.measure = measure;
504        self.editing = None;
505    }
506
507    /// Reset this matrix's pivot state to its measure's natural order when its
508    /// measure has changed. Called each frame before rendering.
509    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            // Keep page_idx sized to the current page-dimension count.
522            let n = self.page_cats(model).len();
523            self.page_idx.resize(n, 0);
524        }
525        self.clamp_cursor(model);
526    }
527
528    /// This matrix's measure's own dimensions, in declared order.
529    ///
530    /// `axis_order` is not guaranteed to match them: restoring a view keeps a
531    /// saved order verbatim, and a CSV re-import can overwrite
532    /// `measure.categories`, so a stale category can linger in `axis_order`
533    /// while no longer being a dimension of the measure on screen. Such a
534    /// category must not influence this matrix's grid at all — without this
535    /// filter, a stale PAGE category filtered to zero items made
536    /// `pinned_pages_opt` return `None` and blanked a grid whose every cell was
537    /// fully specified.
538    ///
539    /// This is distinct from the case 6a4b862 fixed: a category that IS a real
540    /// dimension of the measure and is filtered to nothing still yields zero
541    /// lines, because those coordinates genuinely would be under-specified.
542    fn measure_dims(&self, model: &Model) -> Vec<CategoryId> {
543        natural_axis_order(model, self.measure)
544    }
545
546    /// `axis_order` restricted to the measure's dimensions, preserving the
547    /// user's axis placement/order. The `n_rows`/`n_cols` split indexes
548    /// `axis_order`, so the split is applied first and each slice is filtered.
549    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    /// The pinned (category, item) for each page dimension, from `page_idx`.
571    /// A page category filtered to zero items pins nothing and is simply
572    /// absent, so the result can be SHORTER than `page_cats()` — grid/cursor
573    /// code must use `pinned_pages_opt`, which rejects that case.
574    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    /// The pinned page items, or `None` if any page category is filtered to zero
593    /// items. In that case no item can be pinned for it, so every cell
594    /// coordinate would omit that category — under-specified for the measure's
595    /// dimensions. Render nothing rather than a cell at such a key.
596    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    /// The Cartesian product of `cats`' filtered items, outer category first.
602    /// Each returned element is one axis line: a tuple of `(ItemId, name)` in
603    /// `cats` order. An empty `cats` yields a single empty tuple (a 1-line
604    /// axis, i.e. a scalar in that direction). Any empty category collapses the
605    /// product to nothing (no lines).
606    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    /// The item lists (sorted, filtered) for each of `cats`, in order. Used to
627    /// virtualize the row axis: with these lists we can compute the total row
628    /// count as a product of lengths and decode the i-th row tuple on demand
629    /// (`nth_tuple`) without materializing the whole Cartesian product.
630    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    /// A category's items sorted by id, honoring THIS matrix's filters (shared
635    /// by paging and grid rendering). A category without a filter shows all
636    /// items; filtering is presentation only — it never touches model data.
637    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    /// Move `category` to `axis` in THIS matrix, appending it as the innermost
658    /// entry of that axis (so categories *stack*: dropping a second category on
659    /// Rows nests it under the first). Removes it from its previous axis. No-op
660    /// if the category is not among this measure's dimensions. Pivoting is
661    /// formula-free re-projection (the Improv/Quantrix signature move).
662    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    /// Flatten the three axis groups back into `axis_order` + `n_rows`/`n_cols`,
684    /// and resize `page_idx` to the new page count.
685    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    /// Pivot: swap this matrix's entire row stack with its entire column stack
701    /// (Rows ↔ Columns), keeping pages put. For the classic one-per-axis case
702    /// this is the familiar row/column swap; with stacked categories it swaps
703    /// the two groups. No-op if there is nothing on either of rows/columns.
704    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        // Swap: old columns become rows, old rows become columns.
712        self.rebuild_axis_order(model, cols, rows, pages);
713        self.clamp_cursor(model);
714    }
715
716    /// Set the pinned item index for page dimension `dim_index` (its position
717    /// among this matrix's page dims), clamped to that dimension's item count.
718    /// No-op if out of range.
719    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    /// Cycle this matrix's first page dimension by `delta` (wrapping).
737    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    /// Toggle whether `item` of `category` is shown in THIS matrix. On first
751    /// toggle the filter starts from all items minus this one; toggling so all
752    /// items are kept drops the filter. Presentation only.
753    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    /// Drop THIS matrix's filter on `category`, showing all its items again.
780    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    /// This matrix's (row_count, col_count). An axis with NO categories is
786    /// genuinely scalar and has ONE line; an axis whose category is filtered to
787    /// zero items has ZERO lines (an empty grid with headers — never a synthetic
788    /// line whose coordinate would omit that category). If a PAGE category is
789    /// filtered to zero items nothing is addressable at all, so both counts are
790    /// 0. Matches `render_grid`.
791    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    /// Move this matrix's cursor by `(drow, dcol)`, clamped to its grid (never
801    /// out of range). Mirrors the TUI's `move_cursor`.
802    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    /// An arrow step: `extend` (Shift held) keeps or sets the anchor at the
811    /// current cursor before moving, growing a range; otherwise it collapses.
812    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    /// The selected rectangle's (rows, cols): anchor to cursor, inclusive.
818    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    /// The `CoordKey`s of every selected cell, row-major (see
825    /// [`Self::cursor_key`] for when there are none).
826    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        // ponytail: capped at 10,000 cells so a select-all on a huge grid stays
848        // cheap; stream the sum instead if Sum must cover bigger ranges.
849        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    /// Clamp the cursor into this matrix's grid (called after a pivot / measure
861    /// switch that may have shrunk it).
862    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    /// The `CoordKey` of the cell under this matrix's cursor, or `None` when
873    /// there is no cell there: any axis (row, column, or page) category filtered
874    /// to zero items means no coordinate fully specifies the measure's
875    /// dimensions, so there is nothing to address. An axis with no categories at
876    /// all is scalar in that direction and still has one line.
877    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        // Decode only the cursor's row/col line (never the whole product).
888        // `product_len` of an empty list set is 1, the scalar axis -> empty tuple.
889        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    /// Resolved axes for this matrix: (first row cat, first col cat, pinned page
905    /// dims). Test-only: rendering and the chart use the stacked (`_cats`) form.
906    #[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    /// Load a model from the store at `db` (`""` = fresh in-memory model) and
923    /// build the live engine over its derived measures.
924    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    // -- the canvas: N matrices, one focused -------------------------------
981
982    /// The focused matrix — the one the keyboard, the formula editor, the
983    /// inspector, the chart and the selection readout all follow.
984    ///
985    /// `matrices` is never empty (an empty model still has one matrix, showing
986    /// no measure) and `focus` is clamped here rather than trusted, so removing
987    /// a matrix can never leave a dangling focus.
988    pub(crate) fn focused(&self) -> &Matrix {
989        &self.matrices[self.focus.min(self.matrices.len() - 1)]
990    }
991
992    /// The focused matrix, mutably (see [`Self::focused`]).
993    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    /// Give the keyboard to matrix `i` (clamped). Focus is what makes grid
999    /// shortcuts unambiguous with several matrices on one canvas.
1000    pub fn set_focus(&mut self, i: usize) {
1001        self.focus = i.min(self.matrices.len() - 1);
1002    }
1003
1004    /// Which matrix has the keyboard.
1005    pub fn focus_index(&self) -> usize {
1006        self.focus.min(self.matrices.len() - 1)
1007    }
1008
1009    /// How many matrices are on the canvas (always >= 1).
1010    pub fn matrix_count(&self) -> usize {
1011        self.matrices.len()
1012    }
1013
1014    /// Add a matrix showing `measure` to the canvas, cascaded down-right from
1015    /// the last one so it does not land exactly on top of it, and focus it.
1016    /// Returns its index.
1017    ///
1018    /// Canvas layout only — the model is untouched, so this is deliberately NOT
1019    /// an undo point; see [`Self::undo`].
1020    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    /// Remove matrix `i` from the canvas. The LAST matrix is never removed (a
1035    /// canvas with none has nowhere to show a measure) — that is a no-op
1036    /// returning `false`. Layout only, like [`Self::add_matrix`].
1037    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    // -- read-only accessors for the chart module (crate-internal) ---------
1047
1048    pub(crate) fn selected(&self) -> Option<MeasureId> {
1049        self.focused().measure
1050    }
1051    /// Row/column category stacks and pinned pages for the current pivot — the
1052    /// general (stacked) form the chart needs. Row/col tuples come from
1053    /// `axis_tuples_pub`; keys from `cell_key_multi_pub`.
1054    ///
1055    /// `None` when a PAGE category is filtered to zero items: nothing can be
1056    /// pinned for it, so every cell key would omit that category. Same rule as
1057    /// the grid (`grid_dims`/`cursor_key`) — chart nothing rather than plot
1058    /// under-specified keys.
1059    ///
1060    /// Reads the FOCUSED matrix: with several matrices on the canvas the chart
1061    /// follows the keyboard, as every other single-measure surface does.
1062    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    /// The Cartesian product of `cats`' filtered items (see `axis_tuples`),
1071    /// exposed for the chart. Empty `cats` -> one empty tuple.
1072    pub(crate) fn axis_tuples_pub(&self, cats: &[CategoryId]) -> Vec<Vec<(ItemId, String)>> {
1073        self.focused().axis_tuples(&self.model, cats)
1074    }
1075    /// The sorted `CoordKey` for a stacked cell (see `cell_key_multi`).
1076    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    // -- pure state logic (unit-tested; no rendering) ----------------------
1094
1095    /// The numeric value map for a measure (input cells, or the derived
1096    /// snapshot), projected to numbers for the CHART, which can only plot
1097    /// numbers. Non-numeric cells are absent (a gap).
1098    ///
1099    /// The GRID must not use this: a Text/Boolean/DateTime cell would render
1100    /// blank. Display goes through [`Self::cell_text`], which is type-aware.
1101    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    /// The typed value of a cell: the engine snapshot for a derived measure,
1127    /// the model's stored `Value` (mapped to a `CellValue`) for an input one.
1128    /// `None` = the cell genuinely holds nothing.
1129    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    /// The display text for any cell — derived OR input — via `CellValue`'s
1148    /// `Display`: numbers as numbers, text as text, booleans as `true`/`false`,
1149    /// dates as RFC3339, error cells as `#ERR`. A cell is blank only when it
1150    /// holds no value, never merely because its value is not a number.
1151    fn cell_text(&self, measure: MeasureId, key: &CoordKey) -> Option<String> {
1152        self.cell_value(measure, key).map(|v| v.to_string())
1153    }
1154
1155    /// Set an input cell to a TYPED value and push the edit through the live
1156    /// engine, refreshing the snapshot.
1157    ///
1158    /// `value`'s type must match the measure's DECLARED `value_type`: a Text
1159    /// measure refuses a `Value::Number` (and vice versa) rather than silently
1160    /// replacing a typed cell with a number. Editing UI must parse by declared
1161    /// type — see [`Self::commit_cell_text`].
1162    ///
1163    /// Returns an error string on failure (derived cell, type mismatch, or a
1164    /// failed autosave); on failure the model, engine, and snapshot are left as
1165    /// they were.
1166    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    /// Remove an input cell's value (the empty-commit path) and push the
1176    /// retraction through the live engine. Same atomicity as [`Self::set_cell`].
1177    pub fn clear_cell(&mut self, measure: MeasureId, coord: CoordKey) -> Result<(), String> {
1178        self.write_cell(measure, coord, None)
1179    }
1180
1181    /// The one write path for input cells: `Some(v)` sets, `None` clears.
1182    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        // The declared type is the contract: never coerce, never overwrite a
1197        // typed cell with a value of another type.
1198        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        // Precise rollback rather than publish()'s clone-and-swap: a single cell
1207        // edit goes through the INCREMENTAL engine (`engine.set`), so cloning the
1208        // whole model and rebuilding the graph per keystroke would throw away the
1209        // very thing that makes editing cheap. Instead remember the prior value
1210        // and undo both the model and the engine if anything downstream fails, so
1211        // a reported failure never leaves memory diverged from the store.
1212        let key = decode(&coord);
1213        let prior = self.model.input(measure, &key).cloned();
1214        let prior_snapshot = self.snapshot.clone();
1215        // The undo point: a clone of the model as it is right now. Taken before
1216        // the write, recorded only once the write (engine + save) succeeded.
1217        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            // Restore the model, then push the restored value back through the
1232            // engine so its graph and our snapshot agree with the model again.
1233            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    /// Push one input-cell edit through the live engine and adopt the recomputed
1249    /// snapshot. No engine (no derived measures) -> nothing to do.
1250    ///
1251    /// A value of the measure's type (number, boolean, text, date) enters the
1252    /// engine as that cell; anything else (text in a number measure, an
1253    /// error) CLEARS it, exactly what a rebuild would seed
1254    /// ([`improv_engine::session::input_cell`]). Without that, retyping a
1255    /// numeric cell as text would leave derived measures computing with a
1256    /// number the cell no longer holds.
1257    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    /// Commit a grid edit buffer for `measure[coord]`, interpreting the text by
1282    /// the measure's DECLARED `value_type`. Returns the status message.
1283    ///
1284    /// * An EMPTY (or whitespace-only) buffer **clears the cell**. Escape
1285    ///   already cancels an edit, so an empty commit is the GUI's only way to
1286    ///   delete a value; treating it as a second cancel would make cells
1287    ///   un-clearable.
1288    /// * Otherwise the declared type wins over the text's shape: `"42"` typed
1289    ///   into a Text measure stores `Value::Text("42")`, never a number.
1290    /// * Text that does not parse as the declared type is REJECTED (`Err`); the
1291    ///   prior typed value stays exactly as it was.
1292    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    // -- pivot / page state: delegated to the FOCUSED matrix ---------------
1315    //
1316    // Each matrix on the canvas owns its own axis order, filters and cursor
1317    // (see [`Matrix`]). These wrappers keep the app-level API the panels and
1318    // tests use and route it at the focused matrix; the per-matrix rendering
1319    // path calls the `Matrix` methods directly with its own index.
1320
1321    /// Run `f` on the focused matrix with the model alongside it. Two disjoint
1322    /// field borrows (`matrices` mutably, `model` immutably), which is why the
1323    /// per-matrix methods take `&Model` instead of holding one.
1324    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    /// Re-home every matrix's pivot state whose measure changed, and clamp the
1330    /// focus. Called each frame before rendering.
1331    fn sync_axis_state(&mut self) {
1332        // `matrices` is never empty, so `focus` always has something to point at.
1333        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    /// Pivot the focused matrix: swap its row and column stacks (the toolbar's
1344    /// and the grid header's `Pivot` button).
1345    pub fn pivot_rotate(&mut self) {
1346        self.with_focused(|m, model| m.pivot_rotate(model));
1347    }
1348
1349    // The rest of the focused matrix's pivot surface reads the same way, and is
1350    // reached per-matrix-index from the rendering path (a click drives THAT
1351    // matrix, never the focused one). These focused-matrix wrappers exist for
1352    // the tests, which assert app-level behavior; production code calls the
1353    // [`Matrix`] methods with the index it is rendering.
1354    #[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    /// Move `category` to `axis` in the FOCUSED matrix (see
1389    /// [`Matrix::set_axis`]). Pivoting is formula-free re-projection.
1390    #[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    /// Set the focused matrix's pinned item for page dimension `dim_index`.
1395    #[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    // -- views & filters (pure; unit-tested without egui) ------------------
1401
1402    /// Build a `View` capturing the WHOLE canvas: every matrix's measure, axis
1403    /// order, pinned page items, filters and geometry. Presentation only.
1404    ///
1405    /// Matrix 0 becomes the view's *primary* matrix (its flat fields), the rest
1406    /// its `placements` — which is exactly what makes a one-matrix canvas save
1407    /// as a view a pre-canvas Improv would also understand. A matrix with no
1408    /// measure (an empty model) contributes nothing; if that leaves no matrices
1409    /// at all there is no view to build (`None`).
1410    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    /// The smallest unused view id (>= 1).
1429    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    /// Save the current canvas as a named view: mint an id, add it to the
1442    /// model, and autosave. Returns the id, or `None` when nothing was saved
1443    /// (no measure on the canvas, blank name, or a failed store write — in which
1444    /// case the view is rolled back and `status` holds the error).
1445    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    /// Apply a saved `View` to the canvas: rebuild the matrices from its
1467    /// placements (measure, axis order, page items, filters, geometry), focus
1468    /// the first, and mark it the current document tab. Presentation only —
1469    /// measures and data untouched.
1470    ///
1471    /// **Legacy views load unchanged.** A view written before canvases has no
1472    /// `placements`, so `View::matrices()` yields exactly one matrix — its flat
1473    /// fields — and the canvas is the single-matrix canvas it always was, down
1474    /// to the default geometry.
1475    ///
1476    /// A placement whose measure no longer exists is dropped rather than shown
1477    /// blank; if that leaves nothing the view is refused and the canvas is left
1478    /// exactly as it was.
1479    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    /// The saved views as document tabs, in id order: `(id, name, is current)`.
1504    /// The reference's `Welcome / Concepts / P&L Canvas` strip — switching tabs
1505    /// switches canvases via [`Self::apply_view`].
1506    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    /// Switch to the saved view `id` (a document-tab click). No-op on an
1521    /// unknown id.
1522    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    /// Toggle whether `item` of `category` is shown in the FOCUSED matrix. On
1529    /// first toggle the filter starts from all items minus this one; toggling so
1530    /// all items are kept drops the filter. Presentation only. (The filter shelf
1531    /// drives [`Matrix::toggle_filter_item`] on the matrix it belongs to.)
1532    #[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    // -- CSV/TSV import/export wizard (pure orchestration; unit-tested) ----
1538
1539    /// Toggle the CSV/TSV wizard panel. On first open, prefill the import
1540    /// form's measure id with the next free one (mirrors `next_view_id`'s
1541    /// auto-assign pattern) if the field is still blank.
1542    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    /// Run the import wizard: build an `ImportSpec` from `self.import_form`,
1550    /// import it into the live model, rebuild the engine (structure changed),
1551    /// autosave, and report the cell count (or the error) in `self.status`.
1552    /// Never panics — every failure path (bad form, bad file, `CsvError`)
1553    /// lands in `self.status` as a clear message.
1554    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        // `import_csv` is read-only on the model until it commits, so a failed
1563        // import leaves nothing to undo; the snapshot is kept only on success.
1564        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                // The model changed whether or not the store write worked, so
1570                // the import is undoable either way.
1571                self.record_history(prior_model);
1572                // A failed autosave is reported as the failure it is; the
1573                // imported cells are already in the live model.
1574                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    /// Run the export wizard: resolve `self.export_form` and write the
1587    /// selected measure's cells to disk, reporting the row count (or the
1588    /// error) in `self.status`. Never panics.
1589    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    /// Rebuild the live engine after a structural change (formula edit / new
1606    /// derived measure) and refresh the snapshot.
1607    fn rebuild_engine(&mut self) {
1608        let (engine, snapshot) = build_engine(&self.model);
1609        self.engine = engine;
1610        self.snapshot = snapshot;
1611    }
1612
1613    // -- keyboard cell cursor: delegated to the FOCUSED matrix -------------
1614
1615    /// The focused matrix's (row_count, col_count) (see [`Matrix::grid_dims`]).
1616    #[cfg(test)]
1617    fn grid_dims(&self) -> (usize, usize) {
1618        self.focused().grid_dims(&self.model)
1619    }
1620
1621    /// Move the focused matrix's cursor by `(drow, dcol)`, clamped to its grid.
1622    /// Mirrors the TUI's `move_cursor`; collapses any range selection.
1623    #[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    /// The `CoordKey` of the cell under the focused matrix's cursor (see
1629    /// [`Matrix::cursor_key`]).
1630    pub fn cursor_key(&self) -> Option<CoordKey> {
1631        self.focused().cursor_key(&self.model)
1632    }
1633
1634    /// True if the focused matrix's cursor cell is an editable input cell: its
1635    /// measure is an input measure AND the cursor addresses a real,
1636    /// fully-specified coordinate (see `cursor_key`). Derived measures are
1637    /// read-only.
1638    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    /// Begin editing the focused matrix's cursor cell if it is editable, seeding
1646    /// its buffer with the current value. On a derived cell, sets the status
1647    /// message instead. Mirrors the TUI's `begin_edit`.
1648    fn begin_edit_cursor(&mut self) {
1649        let Some(measure) = self.selected() else {
1650            return;
1651        };
1652        // Order matters: report a missing cell (empty filtered axis) before the
1653        // derived check, so the message names the real reason.
1654        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    /// The editable source text the formula editor shows for `measure`: symbolic
1669    /// DSL (`Price * Quantity`) whenever the v1 grammar can spell the formula
1670    /// exactly, else the controlled-English description (`Price times
1671    /// Quantity`).
1672    ///
1673    /// `None` when there is nothing editable to show: `measure` is unknown or
1674    /// an input measure, or *neither* surface language can spell the formula.
1675    /// Since plan Step 4b the printer quotes awkward names (`'Unit Price'`), so
1676    /// a CSV-imported measure name is no longer a reason to go read-only; what
1677    /// remains unspellable is a formula *shape* the v1 grammar has no form for
1678    /// (see [`formula_dsl`]) whose controlled-English rendering also fails to
1679    /// round-trip. The bar renders that read-only instead of inviting a commit
1680    /// of text that no parser accepts.
1681    ///
1682    /// Whatever this returns, [`Self::commit_formula`] accepts unchanged and
1683    /// leaves the identical AST — the invariant this pair exists to keep. It is
1684    /// *checked* here, by reparsing the candidate text through the very same
1685    /// [`Self::parse_formula_text`] `commit_formula` uses, so no printer/parser
1686    /// drift can quietly violate it.
1687    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    /// Parse `text` as the RHS expression for an existing measure and make it
1700    /// derived (replacing any prior formula/input kind). Rebuilds the engine
1701    /// (structure changed), refreshes the snapshot, and autosaves.
1702    ///
1703    /// **Atomic:** the change is validated on a *candidate* model (parse, then
1704    /// a real engine build) and only published once the engine is known good
1705    /// and the store write succeeded. On any failure the previous model,
1706    /// engine, and snapshot are all left intact and the error is returned; on a
1707    /// parse or compile error `formula_error` is also set (located in `text`,
1708    /// for the editor's underline and the explanation under the row). It is
1709    /// cleared the moment `text` parses — a later save failure is a save
1710    /// error, not a fault at some place in text that has none.
1711    ///
1712    /// `text` may be either the symbolic DSL (`Price * Quantity`) or the
1713    /// controlled English the formula editor displays (`Price times Quantity`);
1714    /// see [`Self::formula_source`].
1715    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        // `text` parsed: any previous inline parse error no longer describes
1724        // it. Clear BEFORE publishing, so a failed save does not leave an
1725        // underline at an offset where this text is perfectly fine.
1726        self.formula_error = None;
1727        // Validate on a candidate copy: a formula that parses can still fail to
1728        // build (cycle, type/dimension error), which would otherwise leave the
1729        // app with a saved-but-broken model and a dead engine.
1730        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    /// Whether `measure`'s formula is currently enabled (the reference's `✓`).
1742    /// True for an input measure too — it has no formula to disable, so there is
1743    /// no unchecked state for it to be in.
1744    #[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    /// Enable or disable `measure`'s formula — the reference's per-formula
1753    /// checkmark, as a real **model** operation (see [`DISABLED_MARKER`]).
1754    ///
1755    /// Disabled, the measure keeps its formula but computes nothing, and every
1756    /// dependent recomputes without it. Because the flag lives on the measure's
1757    /// `description`, it survives save/reload unchanged.
1758    ///
1759    /// Atomic and undoable on exactly the same terms as [`Self::commit_formula`]
1760    /// — it goes through [`Self::publish`], so a toggle that cannot build or
1761    /// cannot save leaves the model, engine and snapshot untouched.
1762    ///
1763    /// A no-op (still `Ok`) on an input measure or an unknown id: neither has a
1764    /// formula, so there is nothing to toggle and nothing to report.
1765    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        // Nothing to toggle: an input measure has no formula, and a formula
1770        // already in the requested state needs no rebuild (and must not become
1771        // a spurious undo point).
1772        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        // Preserve the user's own description text across the toggle.
1781        let (_, user) = split_marker(m.description.as_deref());
1782        m.description = join_marker(!enabled, user);
1783        self.publish(candidate)
1784    }
1785
1786    /// Every formula in the model, in id order, as the formula pane lists them
1787    /// (see [`FormulaRow`] and [`Self::formula_list`]). Numbering is the
1788    /// caller's: row `i` is displayed as `i + 1`, matching the reference's
1789    /// 1-based list.
1790    ///
1791    /// Derived measures only — an input measure has no formula, so it has no row.
1792    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                // Prefer the editable spelling the bar would show; fall back to
1808                // the controlled English so a row is never blank.
1809                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    /// The inline error for one formula row, or `None` if the formula is sound.
1824    ///
1825    /// Two distinct failures, both of which the reference's list shows in place
1826    /// rather than hiding:
1827    ///
1828    /// * It does not **build** — a type/dimension error or a cycle. Found by
1829    ///   compiling this one formula, not by rebuilding the whole engine, so
1830    ///   listing N formulas costs N compiles and no dataflow graphs.
1831    /// * It does not **round-trip** — no surface spelling of it parses back to
1832    ///   the identical AST, so the text shown is a description, not source. Said
1833    ///   plainly, because that row is the read-only one in the editor above.
1834    ///
1835    /// A DISABLED formula is still checked: an unchecked row with a broken
1836    /// formula should say so, not look clean because it is not running.
1837    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    /// Parse formula text in either supported surface language: the symbolic
1848    /// DSL first (the primary language, and the one whose error positions the
1849    /// formula bar highlights), then the controlled English of
1850    /// `improv_nl_formula`. The DSL error is what surfaces if both fail.
1851    fn parse_formula_text(&self, text: &str) -> Result<improv_core_model::Formula, ParseError> {
1852        // Typed in the user's locale (`IMPROV_LOCALE` / the OS locale):
1853        // decimal comma, `;` separators and translated names are accepted.
1854        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    /// Replace the model with `candidate`, but only if it builds a working
1864    /// engine and persists: build first, save second, publish third. On failure
1865    /// nothing is touched (`self` keeps its model, engine, and snapshot). On
1866    /// success the replaced model becomes an undo point.
1867    ///
1868    /// ponytail: validation is a full model clone + a full engine rebuild per
1869    /// structural edit (briefly two live engines). That is the price of
1870    /// atomicity at GUI edit rates; if formula editing ever needs to be
1871    /// interactive on huge models, validate against a compile-only pass
1872    /// (`engine::compiler::compile_formula` + `derived_build_order`) instead of
1873    /// a real `Engine::new`.
1874    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    /// The atomic model swap behind [`Self::publish`] and undo/redo: build an
1881    /// engine for `candidate`, save it, and only then adopt it (model, engine,
1882    /// snapshot together). Returns the model it replaced. Records no history —
1883    /// callers decide (an undo must not become an undo point of its own).
1884    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    // -- undo / redo -------------------------------------------------------
1897
1898    /// Record `prior` (the model as it was *before* the mutation that just
1899    /// succeeded) as an undo point, and drop the redo stack — a fresh mutation
1900    /// makes the undone future unreachable (standard semantics).
1901    fn record_history(&mut self, prior: Model) {
1902        push_bounded(&mut self.undo_stack, prior);
1903        self.redo_stack.clear();
1904    }
1905
1906    /// True when there is a recorded state to undo (for enabling UI).
1907    pub fn can_undo(&self) -> bool {
1908        !self.undo_stack.is_empty()
1909    }
1910
1911    /// True when there is an undone state to redo.
1912    pub fn can_redo(&self) -> bool {
1913        !self.redo_stack.is_empty()
1914    }
1915
1916    /// Undo the last model mutation: restore the previous model, rebuild the
1917    /// engine and snapshot from it, and persist it (so undo-then-quit does not
1918    /// resurrect the undone state). The current model moves to the redo stack.
1919    ///
1920    /// An empty history is a no-op, not an error. A failed restore (a store
1921    /// write failure) leaves everything as it was and keeps the undo point, so
1922    /// the step is not lost; the error is returned for the caller to surface.
1923    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                // Nothing was swapped, so the undo point still applies.
1936                self.undo_stack.push(current);
1937                Err(e)
1938            }
1939        }
1940    }
1941
1942    /// Redo the last undone mutation (the mirror of [`Self::undo`]).
1943    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    /// [`Self::undo`], with the outcome reported in the status line (the UI
1962    /// path). A store-write failure during undo is surfaced, never swallowed;
1963    /// an empty history says so rather than looking like a broken key.
1964    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    /// [`Self::redo`], with the outcome reported in the status line.
1974    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    /// Re-point the transient UI state at the restored model: a measure the
1984    /// restored model no longer has (an undone CSV import, an undone new
1985    /// derived measure) cannot stay on a matrix or half-edited. Applied to
1986    /// EVERY matrix on the canvas, not just the focused one — an undo that
1987    /// removes a measure must not leave another matrix pointing at it.
1988    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        // Force the formula editor to reload from the restored model.
2000        self.formula_for = None;
2001        self.formula_error = None;
2002        self.sync_axis_state();
2003    }
2004
2005    /// Create a new derived measure named `name` with RHS `text`. Categories
2006    /// are inferred as the union of the referenced measures' categories (same
2007    /// rule as the CLI's `add-derived`). Rebuilds the engine and autosaves. On
2008    /// any failure (parse error, duplicate name, engine build failure, failed
2009    /// store write) the model, engine, and snapshot are unchanged.
2010    ///
2011    /// A parse or compile error is also left in `new_formula_error`, located
2012    /// in `text`.
2013    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        // A new measure may refer to itself (a PREV recurrence).
2024        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        // The engine's shape: an aggregation drops what it reduces, `@ITEM`
2035        // is text, and so on.
2036        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    /// The smallest unused measure id (>= 1).
2060    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    /// Autosave the model to the store when `db` is set. In-memory (`""`)
2071    /// models skip saving. Returns the failure message so callers can report
2072    /// it — never overwrite a save failure with a success message.
2073    #[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    /// File ▸ Revert to Saved: reload the model from the store (with a fresh
2091    /// save token) and rebuild the engine. After a save conflict this is the
2092    /// way forward: the edit that conflicted was already rolled back, and the
2093    /// reload picks up the other writer's changes. Undo history is dropped
2094    /// (it describes a model that no longer matches the store). On failure
2095    /// nothing changes.
2096    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    /// Read-only inspector facts for `measure` (see `inspector` panel).
2117    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
2173/// Push `state` onto a bounded history stack, evicting the oldest entry once
2174/// [`UNDO_DEPTH`] is reached.
2175fn 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
2182/// Whether the grid's single-key bindings (cursor motion, paging, measure
2183/// cycling, undo/redo) should act this frame.
2184///
2185/// * `editing` — a GRID CELL editor is open: the cell's own text field owns the
2186///   keyboard (its Enter/Esc are handled where it is rendered).
2187/// * `other_focus` — some egui widget owns keyboard focus. While a cell is
2188///   being edited that widget IS the cell editor, so the two flags overlap;
2189///   either one suppresses the bindings. Any *other* focused widget (the
2190///   formula editor, the new-measure name/formula fields, the CSV wizard's text
2191///   fields, the view-name box) must get the keystroke instead of the grid —
2192///   otherwise typing `n` cycles measures and `h`/`j`/`k`/`l` moves the cursor
2193///   mid-word.
2194///
2195/// A pure predicate so the decision is unit-testable without a window; the
2196/// focus flag itself comes from `egui::Memory::focused()`, which is `None`
2197/// again as soon as focus is released (so the gate is never sticky).
2198fn grid_keys_enabled(editing: bool, other_focus: bool) -> bool {
2199    !editing && !other_focus
2200}
2201
2202/// Give `ui`'s selected rows a white face. The palette makes egui's selection
2203/// fill and hover fill the same gray as the panel, so a selected
2204/// `selectable_label` would look merely hovered. Call inside a `ui.scope` (or a
2205/// child ui) so text-edit selection keeps the theme's color.
2206fn white_selection(ui: &mut egui::Ui) {
2207    ui.visuals_mut().selection.bg_fill = crate::theme::WHITE;
2208}
2209
2210/// Read-only facts about a measure, assembled for the Info panel.
2211#[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/// One row of the formula pane: a single derived measure's formula as the
2224/// reference's numbered, checkable list shows it (`✓ 1. Gross Margin = Sum of
2225/// Revenue - Sum of Cost of Sales`).
2226///
2227/// Built by [`ImprovApp::formula_rows`] in measure-id order; the display number
2228/// is the row's 1-based position, not an identity, so inserting a formula
2229/// renumbers the list exactly as the reference does.
2230#[derive(Debug, Clone, PartialEq)]
2231pub struct FormulaRow {
2232    /// The measure this formula defines (clicking the row selects it).
2233    pub measure: MeasureId,
2234    /// The target measure's name — the left side of the displayed `Target = …`.
2235    pub target: String,
2236    /// The formula's source text: the editable spelling when one exists, else
2237    /// the controlled-English description (`error` then says so).
2238    pub text: String,
2239    /// Whether the formula is enabled (the reference's `✓`). See
2240    /// [`DISABLED_MARKER`].
2241    pub enabled: bool,
2242    /// Why this row is not sound, shown inline beneath it. `None` when the
2243    /// formula compiles and has an editable spelling.
2244    pub error: Option<String>,
2245}
2246
2247/// Swallow this frame's Enter once a text field has acted on it, so the
2248/// grid (drawn later, and now seeing no focus) doesn't start a cell edit.
2249fn consume_enter(ui: &egui::Ui) {
2250    ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
2251}
2252
2253/// An engine-build failure for formula `text`, located in it; `None` for a
2254/// failed save, which is no fault of the text.
2255fn 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
2259/// A formula editor with autocomplete ([`crate::completion`]): while a
2260/// name is typed (at or after byte `from`), a list of what may complete it
2261/// drops under the edit (Up/Down select, Tab or Enter accept, Esc closes;
2262/// a click accepts too), the rest of the selected name and its
2263/// parameters trail the caret in light gray, and a tooltip shows the
2264/// signature of the function being completed or called, the current
2265/// argument in bold. With the list closed every key reaches the edit, so
2266/// Enter still commits.
2267fn 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    // Keys first, on what the user saw last frame, before the edit can.
2292    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            // A press on the list picks that row (it took the focus away).
2310            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    // While the list is up the edit keeps Tab and Esc (egui reads the
2332    // filter at the start of the next frame, so set it every frame).
2333    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            // After the last glyph; the ghost is never part of the galley.
2354            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            // Over the edit while the list is under it.
2386            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
2393/// The completion list under (or, without room, over) the edit `resp`,
2394/// `sel` highlighted; its rect is kept under `rid` so a press on it can
2395/// pick a row. Returns whether it went below.
2396fn 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    // Wide enough for the longest hint and its description, on screen.
2407    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
2461/// The signature tooltip over (`above`) or under the edit `resp`: `name`'s
2462/// parameters with argument `arg` bold and underlined, then `d`'s
2463/// description and category.
2464fn 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        &current,
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            // A NeXT help balloon: white, ruled in black.
2508            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
2520/// A formula editor: a single-line edit of `text` with id `id`, highlighted,
2521/// the error `span` underlined, filling the row. `keep_tab`: Tab stays in
2522/// the edit rather than moving the focus.
2523fn 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
2546/// A formula error under its row, indented past the checkbox and number:
2547/// the message, and under it the hint, dimmed.
2548fn 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
2564/// Build a live engine over all derived measures in `model`, plus its initial
2565/// snapshot. Falls back to no engine (inputs still render) on build failure.
2566fn 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
2576/// The reserved leading marker in [`Measure::description`] that flags a derived
2577/// measure's formula as **disabled** — the reference's cleared `✓` (see
2578/// `docs/reviews/2026-09-22-gui-reconstruction-plan.md` Step 2).
2579///
2580/// Disabling is a *model-level* fact, not GUI state: it must survive save and
2581/// reload, and the engine must honor it. Two properties chose this
2582/// representation over the alternative (flipping the measure to `Input` and
2583/// stashing its formula somewhere):
2584///
2585/// * **The formula is never moved or re-encoded.** A disabled measure stays
2586///   `MeasureKind::Derived(f)`, so its formula persists through the store's
2587///   existing `:measure/formula` attribute, and re-enabling is exact. Stashing
2588///   it as *text* instead would have to survive [`formula_dsl`] returning
2589///   `None` for shapes the v1 grammar cannot spell — those formulas would be
2590///   destroyed by a disable/enable cycle.
2591/// * **One bit, on the measure it describes.** The marker rides
2592///   `:measure/description`, which the store already reads and writes, so it
2593///   round-trips with no schema change; and because it lives *on the measure*,
2594///   deleting or renaming that measure carries the flag with it (a side table
2595///   keyed by `MeasureId` would need garbage collection).
2596///
2597/// A user-written description is preserved verbatim after the marker; see
2598/// [`split_marker`] / [`join_marker`].
2599///
2600/// The engine honors it via [`engine_model`], which flattens disabled measures
2601/// to `Input` in the model handed to `Engine::new`, so `derived_build_order`
2602/// stops traversing them and every dependent recomputes *without* them — which
2603/// is exactly what clearing the checkmark means.
2604///
2605/// ponytail: a reserved prefix in `description` is a sidecar in a user-visible
2606/// field (the CLI prints descriptions), and it is what a GUI-only change can
2607/// persist. Promote it to a real `Measure.enabled: bool` plus a
2608/// `:measure/enabled` store attribute (serde/schema default true, so existing
2609/// databases keep loading) as soon as `core_model`/`storage_mentat` can be
2610/// touched in the same change; [`split_marker`], [`join_marker`] and
2611/// [`formula_disabled`] are the only three places that would move.
2612const DISABLED_MARKER: &str = "[improv:formula-disabled]";
2613
2614/// Separator between the marker and the user's own description text.
2615///
2616/// A single space, not a newline: `storage_mentat`'s EDN writer emits `\n` as a
2617/// two-character escape that Mentat's reader does not interpret, so a newline
2618/// does not survive a save/reload at all (it comes back as a bare `n`, silently
2619/// corrupting the text). A space round-trips exactly. The marker is
2620/// bracket-delimited so the boundary is unambiguous without needing a character
2621/// the store cannot carry.
2622const MARKER_SEP: char = ' ';
2623
2624/// Split a measure description into `(formula disabled, the user's own text)`.
2625///
2626/// The marker counts only when the description IS it, or begins with it
2627/// followed by [`MARKER_SEP`] — so a user description that merely starts with
2628/// the same characters (`"[improv:formula-disabled]ish"`) is left strictly
2629/// alone.
2630fn 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
2641/// Rebuild a description from the two parts [`split_marker`] takes apart — its
2642/// inverse for every input `split_marker` can produce.
2643fn 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
2651/// Whether `m`'s formula is disabled (see [`DISABLED_MARKER`]). Always false
2652/// for an input measure, which has no formula to disable.
2653fn formula_disabled(m: &Measure) -> bool {
2654    m.is_derived() && split_marker(m.description.as_deref()).0
2655}
2656
2657/// `model` as the ENGINE must see it: every measure whose formula is disabled
2658/// flattened to `MeasureKind::Input`, keeping its (still-stored) formula out of
2659/// the dataflow entirely.
2660///
2661/// This is the single point that makes disabling real rather than cosmetic. A
2662/// flattened measure leaves `derived_build_order`, so it computes nothing (its
2663/// cells go blank) *and* every dependent recomputes against its absence instead
2664/// of through it.
2665///
2666/// Borrows when nothing is disabled — the overwhelmingly common case — so the
2667/// projection costs nothing on models that never touch the feature.
2668fn 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
2681/// Build a live engine over every *enabled* derived measure in `model`,
2682/// propagating a build failure (cycle, type/dimension error) instead of
2683/// swallowing it. A measure whose formula is disabled ([`DISABLED_MARKER`]) is
2684/// excluded, via [`engine_model`].
2685///
2686/// `Ok(None)` (no engine, successfully) when nothing is left to compute — a
2687/// model with no derived measures, or one whose formulas are all disabled.
2688fn 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
2703/// Render `formula` as symbolic-DSL source text that `parser::parse_expr` parses
2704/// back to the *identical* AST, or `None` when the v1 grammar has no exact
2705/// spelling for the shape (a bare ref carrying `over`/`except`, a
2706/// multi-category `OVER`, an empty measure/category name, an
2707/// enum/error/negative/exponent literal, an unknown function id). Callers fall
2708/// back to the controlled-English description for those, which
2709/// `commit_formula` also accepts.
2710///
2711/// Awkward *names* are no longer a refusal reason: [`ident`] quotes them
2712/// (`'Unit Price'`, `'Price/Unit'`, `'Over'`) per plan Step 4b.
2713fn formula_dsl(model: &Model, formula: &improv_core_model::Formula) -> Option<String> {
2714    expr_dsl(model, &formula.expr)
2715}
2716
2717/// Binding tightness, mirroring `parser`'s grammar levels (higher binds
2718/// tighter). Used to emit exactly the parentheses needed to round-trip.
2719fn 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        // NOT sits between AND and the comparisons; unary minus between
2736        // `* /` and `^` (so `-2^2` is `-(2^2)`).
2737        Expr::UnaryOp(UnaryOp::Not, _) => 3,
2738        Expr::UnaryOp(UnaryOp::Neg, _) => 8,
2739        _ => 10,
2740    }
2741}
2742
2743/// `expr_dsl`, parenthesized when `e` binds more loosely than its context.
2744fn 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        // A fold's LAMBDA parameters (see `call_dsl`).
2757        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            // `over`/`except` have no bare-ref spelling: `OVER` exists only
2763            // inside an aggregation (handled below) and `except` not at all.
2764            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            // NOT takes a NotExpr (a comparison or looser-binding NOT); unary
2771            // minus takes a Primary.
2772            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            // Left-associative levels keep a same-level LEFT child bare and
2797            // parenthesize a same-level RIGHT child; comparisons are
2798            // non-associative, so a same-level child needs parens on either
2799            // side (`a < b < c` does not parse).
2800            // `^` is right-associative: its base must bind tighter than `^`
2801            // (a Postfix), its exponent may be any Unary.
2802            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    // A named LAMBDA prints as its name and its arguments (a category
2820    // parameter's argument is an `Expr::Category`, printed as its name).
2821    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    // SCAN/REDUCE(init, x OVER T, body): the body's placeholders print as
2829    // the LAMBDA's parameters.
2830    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    // FIXPOINT(init, body, [max_iter], [tolerance]): r is placeholder (0, 0),
2846    // printed as the LAMBDA's parameter.
2847    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    // Aggregation: `FUNC(MeasureRef OVER Category)` is the ONLY spelling the v1
2862    // grammar has for an aggregating func id, and `parse_aggregation` demands
2863    // it (a ref arg, exactly one OVER category, no `except`). Anything else
2864    // must NOT fall through to the generic call printer: `SUM(Quantity)` and
2865    // `SUM(Price * Quantity)` are rejected by `parse_expr`. Return `None` so
2866    // the caller falls back to the controlled-English description.
2867    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
2874/// `AGG(args…)` with its `OVER` clause after the first reduced argument (the
2875/// parser accepts it after any), the window or `WHERE` there too. `None`
2876/// when the shape has no spelling (a reduced argument that is not a plain
2877/// reference, reduced arguments with different `OVER` lists, `EXCEPT`).
2878fn 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    // OVER follows the first reduced argument, except for Over::Leading,
2895    // where it must follow the last leading measure (the parser reduces the
2896    // references before it).
2897    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
2950/// A window bound literal as written: `FIRST`, `LAST`, `THIS`, `THIS-2`.
2951fn 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
2963/// Whether `func` aggregates (`SUM(x OVER T)`), per the function registry.
2964fn is_aggregation(func: FuncId) -> bool {
2965    improv_core_model::functions::is_aggregation(func)
2966}
2967
2968/// The DSL name of a built-in function id, from the function registry.
2969fn 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
2989/// The DSL spelling of `name` as an identifier: the **bare** form when the
2990/// tokenizer reads it back as a single non-keyword identifier, else the
2991/// **quoted** form (`'Unit Price'`) the grammar gained in plan Step 4a.
2992///
2993/// Bare is preferred whenever it works, so ordinary formulas keep printing as
2994/// `Price * Quantity` rather than `'Price' * 'Quantity'`.
2995///
2996/// `None` only for the **empty** name: `''` is an explicit parse error ("empty
2997/// quoted name: '' names nothing"), so an unnamed measure/category is the one
2998/// name this grammar genuinely cannot spell. Everything else — spaces,
2999/// slashes, hyphens, parentheses, digits-first, non-ASCII, and the keywords
3000/// (`Over`, `AND`) — is spellable quoted, which is what makes a CSV-derived
3001/// measure like `Unit Price` editable instead of read-only.
3002///
3003/// A literal `'` inside the name is escaped by **doubling** it (`Bob''s Rate`),
3004/// matching `parser`'s tokenizer exactly. There is no other escape to get
3005/// wrong: the quoted form takes every other character verbatim.
3006pub(crate) fn ident(name: &str) -> Option<String> {
3007    if is_bare_ident(name) {
3008        return Some(name.to_string());
3009    }
3010    // `''` names nothing (a parse error), so an empty name stays unspellable.
3011    if name.is_empty() {
3012        return None;
3013    }
3014    Some(format!("'{}'", name.replace('\'', "''")))
3015}
3016
3017/// Whether `name` survives a round trip through the tokenizer as one *bare*
3018/// identifier that the grammar will not mistake for a keyword.
3019fn 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    // A bare keyword is consumed as grammar, never as a name. (`SUM`/`AVG` and
3026    // the scalar-function names are NOT keywords here: the parser only treats
3027    // them as functions when directly followed by `(`, which a printed
3028    // measure reference never is.)
3029    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        // The number tokenizer reads digits and '.' only: no sign, no exponent.
3036        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        // The v1 string literal has no escapes, so only text that survives
3043        // verbatim between quotes is expressible.
3044        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
3051/// Parse cell text as `declared`, the measure's declared type. The declared
3052/// type WINS over the text's shape: `"42"` for a Text measure is text, not a
3053/// number. Unparsable text is an error (never a coerced value), so a bad edit
3054/// leaves the prior typed cell untouched.
3055///
3056/// Dates and booleans reuse the formula grammar's own literal forms (via
3057/// `parser::parse_expr` on `#...#` / `TRUE`|`FALSE`), so a date a formula
3058/// accepts is a date a cell accepts. Numbers use `f64::from_str` instead: the
3059/// formula tokenizer reads digits and `.` only, and a cell must accept `-3.5`
3060/// and `1e9`.
3061fn 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        // Text takes the buffer verbatim (trimmed by the caller) — no parsing,
3075        // so nothing a user can type is rejected.
3076        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
3107/// The measure's categories in natural (declared) order (empty if none/absent).
3108fn 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        // Panel order IS layout order: a top/bottom panel claims its strip from
3121        // the outside in. The status bar goes below everything and the canvas
3122        // takes what is left.
3123
3124        self.status_bar(ctx);
3125        self.chart_panel(ctx);
3126        self.csv_wizard_panel(ctx);
3127        let ws = self.canvas_panel(ctx);
3128        // After the worksheets, so they start on top of them.
3129        self.model_browser(ctx, ws);
3130        self.info_panel(ctx, ws);
3131        self.function_help(ctx, ws);
3132        // Last, so it floats over every panel.
3133        self.menu_bar(ctx, ws);
3134    }
3135}
3136
3137impl ImprovApp {
3138    /// App-wide key equivalents ([`menu::key_command`]): Ctrl/Cmd+S saves,
3139    /// Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y redo, Ctrl/Cmd+H
3140    /// hides, Ctrl/Cmd+Q quits. Gated by the same [`grid_keys_enabled`]
3141    /// predicate the grid uses, so the chords never fire while a cell editor or
3142    /// any other text field owns the keyboard. Handled here (not in
3143    /// `handle_grid_keys`) because the grid isn't rendered when no measure is
3144    /// selected — which is exactly the state an undone import can leave behind.
3145    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        // Auto-repeat is ignored: holding Ctrl+S saves once, and a held
3151        // Ctrl+Q can't run straight through the quit confirmation.
3152        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    /// Draw the application menu ([`menu::show`]) and run what it returns.
3175    /// It starts at the top-left of the workspace `ws`, clear of the panels.
3176    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    /// Whether `c` can run now: the menu draws the rest disabled.
3192    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            // In-memory models have no store: never claim "saved".
3198            Command::SaveModel | Command::RevertToSaved => !self.db.is_empty(),
3199            _ => true,
3200        }
3201    }
3202
3203    /// Run a menu command or key equivalent.
3204    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                // Save under the browser's name box, or a generated name.
3238                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    /// Whether Quit may close now. Edits to an in-memory model can't be saved,
3266    /// so the first Quit only warns and arms; a second one closes.
3267    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    /// The model browser (spec §4.2): a NeXT window, `<file> - Model`, at the
3277    /// workspace's bottom left, listing Categories (with their items),
3278    /// Measures (input vs derived; clicking one selects it) and Views.
3279    /// View ▸ Model Browser shows and hides it.
3280    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                                                    // Clear the window border
3342                                                    // and the scroll bar.
3343                                                    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    /// A saved view's description for the browser: its primary measure, the
3362    /// row and column categories, then any pages — `Revenue by Time ×
3363    /// Product; pages: Scenario`. Computed (a view stores no description).
3364    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    /// Views section at the top of the model browser (spec §4.2): a white list
3394    /// of saved views as `Name - Worksheet` rows with a worksheet icon (click
3395    /// selects, double-click opens), a description of the selected view, then
3396    /// a name field + "Save view" button that captures the current CANVAS.
3397    ///
3398    /// This is the NeXTSTEP reference's model navigator
3399    /// (`docs/reviews/refs/improv-next-nested-headers.jpg`: a `Model of
3400    /// Divisional Report` window listing `Worksheet 1` / `Worksheet 2`), and the
3401    /// same set the application menu's View submenu lists. The current view is
3402    /// highlighted in both.
3403    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        // Clear the window border and the scroll bar.
3422        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                // The worksheet icon: a 10×8 sheet with two ruled lines.
3446                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            // Leave the button room in the narrow browser window.
3479            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    /// The Info panel (spec §4.7): a NeXT panel window at the workspace's top
3490    /// right showing the selected measure's facts. Info ▸ Info Panel… opens
3491    /// it; its close box closes it.
3492    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    /// Help ▸ Functions… (F1): a NeXT window at the workspace's right
3546    /// with a search line over the built-in functions, the matches grouped
3547    /// by category (each with its signature), and the selected one's
3548    /// details. Insert (or a double-click, or Enter in the search line)
3549    /// puts `NAME(` into the formula last edited, at its caret.
3550    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        // F1 toggles it from anywhere, a formula edit included.
3554        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    /// Insert `NAME(` into the formula editor last focused (the selected
3719    /// row's while it is shown, else the `Name = formula` line), at its
3720    /// caret, and give that editor the keyboard.
3721    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    /// **The Step 2 formula pane** (plan
3746    /// `docs/reviews/2026-09-22-gui-reconstruction-plan.md`): the whole model's
3747    /// logic as one persistent, numbered, individually-checkable list — the
3748    /// shape every reference shows (`✓ 1. Gross Margin = Sum of Revenue - Sum of
3749    /// Cost of Sales`), and the thing a single-line bar for the selected measure
3750    /// alone can never be.
3751    ///
3752    /// Reference-faithful in the parts that carry meaning:
3753    ///
3754    /// * a split bar across the top with a `Calc` button that recomputes;
3755    /// * one row per derived measure: a checkbox (the `✓`), a **bold 1-based
3756    ///   number**, then `Target = formula`;
3757    /// * the selected row highlighted, and clicking any row selecting that
3758    ///   measure so the grid follows;
3759    /// * inline error text under a row whose formula does not compile.
3760    ///
3761    /// The selected row *is* the editor ([`Self::formula_editor`]), and a last
3762    /// line takes `Name = formula` to add one; there is no separate bar.
3763    fn formula_list(&mut self, ui: &mut egui::Ui, mi: usize) {
3764        // Only the focused window edits, so the editor's global id is unique.
3765        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        // What the user clicked, applied after the loop so the model is
3777        // not mutated while `rows` borrows nothing from it but the UI
3778        // still reads `self.selected` per row.
3779        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                        // `1.` `2.` … bold, as the reference numbers them.
3802                        ui.strong(format!("{}.", i + 1));
3803                        // A disabled formula is dimmed, not hidden: it is
3804                        // still part of the model's logic.
3805                        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                        // Only the part the scroll area shows is clickable.
3817                        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        // Selection left the rows (an input measure, or none): re-seed the
3843        // editor next time, as the old bar did on every selection change.
3844        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    /// The selected row's in-place editor: `Name =` and a highlighted
3869    /// single-line edit over `formula_buf`. Enter commits (errors show under
3870    /// the row), Esc reverts. A formula with no editable spelling shows its
3871    /// English text read-only (see [`Self::formula_source`]).
3872    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        // Reload the buffer when the selection changes, clearing any stale
3881        // inline error from the previously selected measure.
3882        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        // A standing compile error is underlined while the text is unedited.
3903        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            // The previous error no longer describes what's on screen.
3924            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    /// The formula list's last line: type `Name = formula`, press Enter, and a
3948    /// derived measure is added and selected. `editing` (the focused window)
3949    /// gets the global id `formula_new`; other windows scope theirs.
3950    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        // Only the focused window's line shows (and owns) the error.
3957        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                // Past the checkbox and number, under the rows' text.
3965                ui.add_space(44.0);
3966                // Names are completed in the formula, after the `=`.
3967                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                // Under the last row, a fresh error may be past the pane's end.
3997                shown.scroll_to_me(None);
3998            }
3999        }
4000    }
4001
4002    /// Add the measure the new-formula line spells (`Name = formula`),
4003    /// selecting it; on failure keep the line and locate the error in it.
4004    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        // Where the formula starts in the line, to place its error there.
4011        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                    // Not the formula's fault (a duplicate name, …).
4022                    None => diagnostics::Diagnostic::plain(e.clone()),
4023                });
4024                self.status = format!("formula error: {e}");
4025            }
4026        }
4027    }
4028
4029    /// Bottom-right chart panel (shown only when the "Chart" toggle is on): a
4030    /// read-only bar chart of the selected measure with a bar/line toggle.
4031    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    /// A toggle-able window: CSV/TSV import (left) + export (right), mirroring
4050    /// the CLI's `import-csv`/`export-csv` exactly. All fields are plain text
4051    /// (no file-dialog dependency, same pattern as the "Save view" name box) —
4052    /// see `csv_wizard::build_import_spec`/`build_export_args` for the pure
4053    /// validation this panel drives.
4054    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    /// The import half of the wizard: file path, delimiter/header toggles,
4074    /// measure id (auto-assigned)/name, value column, and a repeatable list
4075    /// of dimension-mapping rows with +/- controls.
4076    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    /// The export half of the wizard: a measure combo box (from the current
4126    /// model, like the browser's measure list), a target path, and a
4127    /// delimiter toggle.
4128    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    /// The **status bar** (plan Step 5): the status message on the left and the
4158    /// selection-aggregate readout on the right, which is where Quantrix puts
4159    /// its `Sum`.
4160    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    /// The selection aggregate for the focused matrix, as the status bar shows
4187    /// it: the reference's `Sum`.
4188    ///
4189    /// Selection is ONE cell today (there is no range selection yet), so the sum
4190    /// over the selection is that cell's number. Written as a sum rather than as
4191    /// "the cell's value" because that is what it means and what a range
4192    /// selection will widen to: [`Self::selection_sum`] takes the cursor cell's
4193    /// numeric value, and a non-numeric or absent cell contributes nothing, so
4194    /// the readout says `Sum \u2014` rather than inventing a zero.
4195    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    /// The numeric sum over the focused matrix's selection, or `None` when
4203    /// nothing numeric is selected (no measure, no addressable cell, an empty
4204    /// cell, or a text/boolean/date/error cell). See [`Self::selection_readout`].
4205    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    /// The workspace: a `#555555` desktop with each matrix floating on it as
4217    /// its own NeXT worksheet window (spec §5). The workspace does not scroll;
4218    /// each window's grid scrolls itself, and `Matrix::rect` holds the window
4219    /// rect relative to the workspace's top-left, so views persist it.
4220    ///
4221    /// The focused matrix's window is made key before the windows are shown,
4222    /// so a code-driven focus change (`add_matrix`, `apply_view`) shows as the
4223    /// key title bar; a press on another window then makes it key, and that
4224    /// is read back into the focus.
4225    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        // ponytail: matrix windows own the key slot. A click on the browser or
4242        // Info panel makes it key for that frame only; let it keep the key
4243        // (and fence grid keys off) when a panel needs the keyboard.
4244        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    /// Matrix `mi` as a worksheet window on the workspace `ws`: filter shelf,
4258    /// the gutter-framed grid, and the formula list (with Calc) below it. Returns `false` when its close box was
4259    /// clicked (shown only while there is more than one matrix).
4260    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        // `m.rect` is the source of truth: passed in every frame, so a
4267        // programmatic change (apply_view) moves the window, and read back
4268        // below, so a drag or resize lands in the view.
4269        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                // Never squeeze the gutters and grid below what framing needs.
4283                .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    /// A worksheet window's title (spec §4.3): `Sales: North - Worksheet -
4307    /// demo`, the file being the store's stem or `Untitled` when in memory.
4308    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    /// Send the native title when it changed. An in-memory model is dirty
4317    /// whenever it has undo history (nothing ever persists it).
4318    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    /// The native window title: `Improv — <file>`, plus ` •` while dirty.
4330    fn native_title(&self) -> String {
4331        format!(
4332            "Improv — {}{}",
4333            self.file_name(),
4334            if self.dirty { " •" } else { "" }
4335        )
4336    }
4337
4338    /// The store's file stem, or `Untitled` when in memory.
4339    fn file_name(&self) -> &str {
4340        if improv_store::is_postgres(&self.db) {
4341            // `postgres://…?model=<name>`: the model's name, never the DSN.
4342            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    /// A matrix's title text: its measure's name, plus the pinned page items
4354    /// after a colon (`Sales: North`) \u2014 the reference's
4355    /// `Property Financials: Virginia Ave`.
4356    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    /// The grid inside its **margin gutters** — the Improv pivot surface.
4378    ///
4379    /// Reference: `docs/reviews/refs/improv-pivot-gesture.jpg`. Category tiles
4380    /// are docked in gutters that *frame* the table, and the pivot gesture IS
4381    /// dragging a tile from one gutter to another:
4382    ///
4383    /// ```text
4384    ///   +- corner --+- TOP gutter: column-axis tiles, stacked ---+
4385    ///   | (blank)   |  || Travel v                               |
4386    ///   |           |  || Hours  v                               |
4387    ///   +-----------+----------------------------------------------+
4388    ///   | LEFT      |                                              |
4389    ///   | gutter:   |    the table (ruled headers + cells)         |
4390    ///   | row-axis  |                                              |
4391    ///   | tiles     |                                              |
4392    ///   +-----------+----------------------------------------------+
4393    ///   | WELL (bottom-left, inline with the horizontal scrollbar  |
4394    ///   | in the reference): page/unplaced tiles + page selectors  |
4395    ///   +----------------------------------------------------------+
4396    /// ```
4397    ///
4398    /// Adjacency is **constructed, not hoped for**: the gutters are nested
4399    /// [`egui::SidePanel`]/[`egui::TopBottomPanel`]s inside this `Ui`, and a
4400    /// panel's contract is that it consumes its strip and moves the parent
4401    /// cursor to its far edge, so whatever is laid out next starts exactly at
4402    /// that edge. Each panel's own rect and the table's `min_rect` are recorded
4403    /// in that MATRIX's own `gutters` field, `debug_assert!`ed against
4404    /// [`gutters_frame_table`] every frame, and asserted in the headless layout
4405    /// test — per matrix, so every matrix on the canvas frames its own table.
4406    ///
4407    /// Gutter *thickness* along the docked axis is left to the panels: they size
4408    /// to their content (a panel re-reads its extent from the previous frame's
4409    /// `PanelState`), so stacking another tile makes the gutter grow rather than
4410    /// clip it against a hard-coded height. NeXT layout: page tiles (each
4411    /// over its item field) top-left, column tiles top-right, row tiles in the
4412    /// bottom strip; there is no gutter beside the table.
4413    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        // Each page category with its item names and the current item's index.
4427        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        // Each gutter panel's `show_inside` REPLACES its clip with the panel
4448        // rect instead of intersecting it, so tiles escaped the canvas and drew
4449        // over the side panels. The panels get no frame; each body first
4450        // narrows its clip back to the canvas, then paints the gutter frame
4451        // itself, so the frame and everything in it stay clipped.
4452        let outer_clip = ui.clip_rect();
4453        let mut hbar = egui::Rect::NOTHING;
4454
4455        // The row gutter: the bottom strip, row tiles left to right (the
4456        // reference puts them inline with the horizontal scrollbar).
4457        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                            // §4.4: the horizontal scroller runs inline, right
4469                            // of the row tiles (painted once the grid is laid
4470                            // out and its scroll extent known).
4471                            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        // The top strip: page tiles (each over its item field) at the left,
4486        // column tiles stacked top to bottom at the right.
4487        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        // No row gutter beside the table any more (render_grid draws the row
4538        // header stubs); `left` is the table's zero-width left edge, so the
4539        // framing invariants still describe the layout.
4540        let body = ui.available_rect_before_wrap();
4541        let left = egui::Rect::from_min_max(body.left_top(), body.left_bottom());
4542
4543        // Whatever is left is the table, between the two strips.
4544        // On a white field: NeXT grid cells are white, not panel gray.
4545        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        // A drop (or a tile-menu filter) changes THIS matrix, not the focused
4582        // one: a tile in one matrix must not touch another matrix.
4583        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    /// The NeXT-groove frame a gutter is painted with.
4598    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    /// Make the whole of `ui` a drop target for category tiles: a category
4606    /// released here is recorded as a move to `axis`, which
4607    /// [`Matrix::set_axis`] applies (appending to that axis' stack). The zone is
4608    /// at least one tile tall so an empty axis is still droppable; `fill`
4609    /// stretches it across the rest of the strip (never along a self-sizing
4610    /// panel's own axis, which would ratchet the panel bigger every frame).
4611    ///
4612    /// The drop highlight is painted only while a tile is in flight; at rest
4613    /// the zone is just its tiles, and a non-filling empty zone (Pages) takes
4614    /// no room until a drag needs a target.
4615    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    /// A page category's item field: a white drop-down showing the current
4644    /// item. Returns the index picked this frame, if any.
4645    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    /// One category tile: the NeXT chip — a fixed 17 px dark-gray box with the
4673    /// category name in white. The whole chip is a drag source, so dragging it
4674    /// into another gutter re-pivots (the drop is handled by
4675    /// [`ImprovApp::gutter_drop_zone`]). Right-click opens a "Move to …" menu,
4676    /// the drag-free (and accessible) way to re-pivot, and below it a checkbox
4677    /// per item filtering `c` in matrix `m` (plus "Show all"). A filtered
4678    /// category's tile reads `Name*`.
4679    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        // The full (unfiltered) item list, so hidden items can be re-shown.
4696        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        // The id is per MATRIX as well as per category: two matrices showing
4706        // the same category are two distinct drag sources.
4707        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                        // 17 px outer = 15 inner + 2 x 1 px margin.
4715                        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    /// Handle keyboard navigation for the grid. Arrow keys (and h/j/k/l) move
4746    /// the cursor; Enter/F2 begin editing the cursor cell; `[`/`]` and
4747    /// PageUp/PageDown page the first page dimension; `n`/`N` cycle the
4748    /// selected measure; Ctrl/Cmd+Z undoes and Ctrl/Cmd+Shift+Z (or Ctrl+Y)
4749    /// redoes. Swallowed while a cell text field is open, or while ANY other
4750    /// egui widget owns keyboard focus (so typing in the formula editor, the
4751    /// new-measure form, the CSV wizard, or the view-name box never also drives
4752    /// the grid). `n`/`N` (not Tab) drive measure cycling because egui reserves
4753    /// Tab for widget focus.
4754    ///
4755    /// **Only the FOCUSED matrix consumes them** (plan Step 3): `mi` is the
4756    /// matrix being rendered, and a matrix that does not have the keyboard
4757    /// returns immediately — otherwise one arrow key would move every matrix's
4758    /// cursor at once.
4759    fn handle_grid_keys(&mut self, ui: &egui::Ui, mi: usize) {
4760        if mi != self.focus_index() {
4761            return;
4762        }
4763        // `memory().focused()` is egui 0.29's "which widget owns the keyboard";
4764        // it is `None` again as soon as focus is released, so this gate is
4765        // transient, never sticky. While a CELL is being edited the focused
4766        // widget is the grid's own editor, which `editing` already covers
4767        // (Enter/Esc are handled in the cell rendering below).
4768        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        // Undo/redo (Ctrl/Cmd chords) are handled app-wide in
4777        // `handle_command_keys`, not here — the grid is not rendered at all when
4778        // no measure is selected, and undoing must still work there. Leaving
4779        // every modified chord alone also keeps e.g. Ctrl+N from cycling
4780        // measures.
4781        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        // Page the first page dimension, if any.
4801        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        // Cycle measures with n / N (Tab is taken by egui focus).
4808        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    /// Cycle the focused matrix's first page dimension by `delta` (wrapping).
4815    fn page_first(&mut self, delta: isize) {
4816        self.with_focused(|m, model| m.page_first(model, delta));
4817    }
4818
4819    /// Cycle the focused matrix's measure by `delta` (wrapping) in id order.
4820    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    /// Render matrix `mi`'s `measure` as a 2-D pivot grid using THAT MATRIX's
4835    /// axis order: its row stack on rows, its column stack on columns, the rest
4836    /// pinned to its selected page items. Input cells are editable; derived
4837    /// read-only.
4838    ///
4839    /// Every read here goes through matrix `mi`, so two matrices showing the same
4840    /// measure with different pivots render differently, and only the focused one
4841    /// takes keystrokes (see [`Self::handle_grid_keys`]).
4842    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        // Cartesian product of stacked categories per axis. Columns are
4868        // materialized up front (they become egui table columns, which the
4869        // TableBuilder needs before the body, and are few in practice). Rows
4870        // are VIRTUALIZED: we hold only the per-category item lists and decode
4871        // the i-th row tuple on demand (see `nth_tuple`), so a grid with
4872        // millions of row lines never allocates them all.
4873        let row_cats = m.row_cats(&self.model);
4874        let col_cats = m.col_cats(&self.model);
4875        // Record what this matrix is being drawn with (see `Matrix::rendered_axes`).
4876        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        // A page category filtered to zero items pins nothing, so no coordinate
4881        // would fully specify the measure's dimensions: render no cells at all.
4882        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        // Total row lines: product of the row categories' filtered item counts.
4887        // No row categories -> 1 (a genuinely scalar axis). Any row category
4888        // filtered to zero items -> 0 lines: an empty grid with headers, never a
4889        // synthetic line (that line's coordinate would omit the category, and
4890        // `nth_tuple` would divide by a zero radix).
4891        let total_rows = if pages_ok { product_len(&row_lists) } else { 0 };
4892        // Column lines, materialized. `axis_tuples` already yields one empty
4893        // tuple for a scalar axis (no col categories) and nothing at all for a
4894        // category filtered to zero items — keep both as-is.
4895        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); // stub columns (one per row cat)
4901        let n_col_hdr = col_cats.len().max(1); // header rows (one per col cat)
4902
4903        // Decode the i-th row tuple on demand (empty when there are no row
4904        // categories -> the single scalar row).
4905        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        // Edits collected during rendering, applied after the table closure so
4914        // we don't borrow `self` mutably inside it.
4915        let mut commit: Option<(CoordKey, String)> = None;
4916        let mut clicked_derived = false;
4917        let mut cancel = false;
4918        // The cursor cell a click chose, applied after the closure.
4919        let mut clicked_cell: Option<(usize, usize)> = None;
4920        let mut begin_edit: Option<(CoordKey, String)> = None;
4921        // Drag-select: where a drag began, whether one is live, and the visible
4922        // cells' rects to find the one under the pointer.
4923        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        // An item header: plain text in a ruled band, as in Improv. Only the
4933        // category tiles look like draggable controls, so a header is a padded
4934        // label, not a beveled chip. It rules its right edge, and its top edge
4935        // when it starts a group (non-empty text), so blank outer row stubs
4936        // read as one outlined group. Rules span what is left of the cell, so
4937        // stacked column headers are ruled level by level.
4938        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        // The rule closing the header band from the body.
4958        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        // Salt the table id with the matrix uid: two matrices on one canvas are
4972        // two tables, and sharing an id would make them fight over column widths
4973        // and scroll offsets.
4974        // Cells abut: the rules, not item spacing, separate them.
4975        let spacing = ui.spacing().item_spacing;
4976        ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
4977        // §4.8: the trough is `extreme_bg_color`; restored after the table.
4978        ui.visuals_mut().extreme_bg_color = crate::theme::TROUGH;
4979        // A grid wider than its window scrolls sideways: the table sits in a
4980        // horizontal area whose own bar is hidden, because the NeXT scroller
4981        // runs in the bottom strip beside the row tiles (`gutter_frame`).
4982        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                // One stub column per stacked row category, then one column per col line.
4992                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                        // Corner stub: the row category names stacked, then a header
5002                        // block spanning the column-category rows. With egui_extras we
5003                        // render the stacked column-category labels inside one tall
5004                        // header cell per column line (outer→inner, top→bottom).
5005                        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                        // Virtualized rows: `body.rows` only invokes the closure for the
5036                        // rows currently visible in the viewport. Because rows are not
5037                        // built contiguously, group outlining can't rely on a running
5038                        // `prev` tracker — for row `ri` we decode row `ri-1`'s tuple on
5039                        // demand and blank an outer stub cell when it matches.
5040                        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                            // Stub columns: one per stacked row category.
5049                            let row_selected = sel_rows.contains(&ri);
5050                            for si in 0..n_row_stub {
5051                                let cell = row_line.get(si);
5052                                // Centered vertically, level with the values.
5053                                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                                            // Show the label on the innermost stub always,
5058                                            // and on an outer stub only when this level or
5059                                            // any enclosing outer level changed from the
5060                                            // row above — group outlining.
5061                                            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                                    // NeXT cell: white (light gray when selected), ruled
5088                                    // right and bottom in black. No per-cell chrome.
5089                                    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                                            // Numbers right-align so their digits line up,
5113                                            // as in Improv; text, booleans and dates stay left.
5114                                            let value = self.cell_value(measure, &key);
5115                                            let numeric = matches!(value, Some(CellValue::Num(_)));
5116                                            // Measure left-to-right in the table's sizing
5117                                            // pass: a right-to-left layout fills the cell,
5118                                            // which would size every auto column to its
5119                                            // fallback width instead of its content.
5120                                            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                                                    // Typed display: text, booleans, dates, and
5168                                                    // #ERR all render as themselves (never blank
5169                                                    // just because they are not numbers).
5170                                                    // Single click selects; double-click (or
5171                                                    // Enter) edits. The whole cell senses.
5172                                                    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                                                    // Modern input mark: a faint inset underline.
5184                                                    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                // §4.8 bevel + grouped arrows; one click = one 20 px row. The table's
5201                // area auto-shrinks to its content, plus the bar on the right.
5202                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        // The vertical scroller stays at the window's right edge, not the
5208        // far end of a grid scrolled sideways.
5209        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        // Apply what the closure recorded. A click in ANY matrix also focuses it,
5216        // so typing follows the mouse rather than staying on whichever matrix had
5217        // the keyboard.
5218        if let Some(m) = self.matrices.get_mut(mi) {
5219            m.edit_buf = edit_buf;
5220            if let Some((ri, ci)) = clicked_cell {
5221                // Shift+click extends from the anchor (or the old cursor).
5222                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
5266/// The sorted `CoordKey` for a cell when categories are STACKED on each axis:
5267/// `row_cats[i]` binds to `row_tuple[i]`, `col_cats[j]` to `col_tuple[j]`, plus
5268/// the pinned page dims. Tuples come from `axis_tuples` so they align with the
5269/// category list. The general form of `cell_key`.
5270fn 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
5291/// The number of axis lines the Cartesian product of `lists` produces: the
5292/// product of each list's length (empty `lists` -> 1, the scalar axis; any
5293/// empty list -> 0, no lines). Mirrors `axis_tuples(..).len()` but is O(k) in
5294/// the number of categories, never materializing the product.
5295fn product_len(lists: &[Vec<(ItemId, String)>]) -> usize {
5296    lists.iter().map(|l| l.len()).product()
5297}
5298
5299/// The i-th line of the Cartesian product of `lists` (outer category first),
5300/// by mixed-radix decoding of `i` across the list lengths — the inner (last)
5301/// category is the least-significant digit, matching `axis_tuples`' ordering
5302/// (which increments the inner category fastest). Returns the bound
5303/// `(ItemId, name)` per category.
5304///
5305/// # Panics
5306///
5307/// `i` must be `< product_len(lists)`, which implies every list is non-empty.
5308/// A list filtered to zero items means the axis has NO lines, so there is no
5309/// `i`-th line to decode; callers (`grid_dims`, `render_grid`, `cursor_key`)
5310/// render zero lines in that case rather than calling here. The check is a
5311/// hard `assert!` (not `debug_assert!`) because `i % 0` is a divide-by-zero
5312/// crash in release builds.
5313///
5314/// This is what lets the grid virtualize rows: instead of holding every row
5315/// tuple in a Vec, we decode line `i` (and, for group outlining, line `i-1`)
5316/// only when that row is actually painted.
5317fn 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    /// Every glyph the GUI draws from a literal (tile grip/menu, error marker)
5332    /// must be covered by the fonts `theme::install_fonts` installs (Arimo plus
5333    /// egui's defaults as fallback), else it renders as a tofu box. `↶`/`↷` once
5334    /// did. The application menu's glyphs are checked in `menu.rs`.
5335    #[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    /// A revenue model without the Revenue derived measure (inputs only), for
5399    /// exercising the formula-commit / add-derived flows.
5400    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        // Revenue is derived and selected by default.
5410        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)); // 10 * 7
5415    }
5416
5417    #[test]
5418    fn editing_recomputes_derived() {
5419        let mut app = build_app(revenue_model());
5420        // Set Quantity[2025,WidgetA] = 9 -> Revenue = 90.
5421        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        // Turn an input measure into a derived one via a formula string, then
5441        // confirm the snapshot recomputes.
5442        let mut app = build_app(inputs_only_model());
5443        // Add a fresh target measure to hold the formula.
5444        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)); // 10 * 7
5451                                                 // The measure is derived and its categories are the union {Time, Product}.
5452        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        // Redefine Revenue = Price + Quantity -> 10 + 7 = 17.
5461        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        // Snapshot still computes the original Revenue.
5477        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        // A failing commit locates the error at the token the parser names.
5489        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        // A compile error has no position: the measure it names is marked.
5497        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        // A successful commit clears any leftover error state too.
5504        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()); // dup name
5517        assert_eq!(app.model, before);
5518    }
5519
5520    // -- BUG 2: the displayed formula must commit unchanged ----------------
5521
5522    /// Every formula shape the formula editor can display, for the round-trip.
5523    /// `SQL(...)`/`CALL(...)` are measure *definitions* whose measures stay
5524    /// `MeasureKind::Input` (see `cli::cmd_define`), so the formula editor shows
5525    /// them the input hint, never editable formula text — nothing to round-trip.
5526    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), // SUM
5556                    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()])), // ABS
5569            ),
5570            (
5571                "two-arg scalar call",
5572                Formula::new(Expr::Call(FuncId(20), vec![price(), qty()])), // MIN2
5573            ),
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                // `-(2^2)` prints as `-Price ^ 2`; `(-Price)^2` keeps its parens.
5591                "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                // `NOT a == b` parses as NOT (a == b), so a NOT that is itself
5624                // compared must be parenthesized.
5625                "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                    // Built through the parser so the test needs no chrono dep.
5670                    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    /// The invariant: the text the formula editor puts in its buffer commits
5694    /// successfully UNCHANGED, leaving the AST and the computed snapshot alone.
5695    #[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            // Exactly what `formula_editor` seeds the buffer with.
5705            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        // The plain revenue model's formula shows as DSL, minimally parenthesized.
5730        let app = build_app(revenue_model());
5731        assert_eq!(
5732            app.formula_source(MeasureId(102)).as_deref(),
5733            Some("Price * Quantity")
5734        );
5735        // Input measures have no formula text.
5736        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        // `SUM(x OVER Time, Product)`: several OVER categories print as DSL
5743        // and commit back to the same formula.
5744        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        // A bare ref carrying `over` (no aggregation) and `except` have no
5772        // spelling; so does an unknown function id and an enum literal.
5773        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        // A negative literal has no token either (only unary minus does).
5801        assert_eq!(
5802            formula_dsl(&model, &Formula::new(Expr::Literal(Value::Number(-1.0)))),
5803            None
5804        );
5805    }
5806
5807    // -- BUG 1: failed saves and bad formulas ------------------------------
5808
5809    #[test]
5810    fn cell_edit_and_formula_commit_report_save_failure() {
5811        // A store path under a directory that does not exist: every save fails.
5812        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        // The view save reports the failure in the status line and rolls back.
5838        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        // A failed formula commit did not publish the new formula, so the model
5843        // still holds (and computes) the original Revenue = Price * Quantity.
5844        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        // Revenue = Revenue + Price parses fine but reads itself outside a
5858        // PREV recurrence, so the engine cannot build.
5859        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        // Same for a type error the parser accepts but the compiler rejects
5876        // (comparison result fed to arithmetic).
5877        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        // And for add_derived_measure.
5885        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        // Input measure: no deps, no formula.
5910        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    /// Build an app directly from a model (bypassing the store) for tests: one
5918    /// matrix on the canvas, showing the default measure, as a fresh
5919    /// `ImprovApp::load` would.
5920    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    /// Point the app's FOCUSED matrix at `measure` (what tests used to do by
5968    /// assigning `app.selected`). Kept as a helper so the many tests that select
5969    /// a measure read the same as before.
5970    fn select(app: &mut ImprovApp, measure: MeasureId) {
5971        app.focused_mut().select(Some(measure));
5972    }
5973
5974    // A 3-D input measure Sales[Time, Product, Region] for paging tests
5975    // (mirrors the TUI's paging fixture).
5976    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        // Quantity[Time, Product]: natural rows=Time(1), cols=Product(2).
6021        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))); // Time on rows
6026        assert_eq!(c, Some(CategoryId(2))); // Product on cols
6027
6028        // Dragging Product onto Rows STACKS it under Time (both on rows), which
6029        // is the Improv semantics; columns become empty.
6030        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        // Quantity[Time, Product] with BOTH categories stacked on rows.
6041        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); // rows = [Time, Product]
6045        assert_eq!(app.row_cats(), vec![CategoryId(1), CategoryId(2)]);
6046
6047        // The row axis is the product of Time items x Product items.
6048        let tuples = app.axis_tuples(&app.row_cats());
6049        // revenue_model has Time={2025(10)}, Product={WidgetA(20)} (1x1) here,
6050        // so a single tuple binding both categories.
6051        assert_eq!(tuples.len(), 1);
6052        assert_eq!(tuples[0].len(), 2, "tuple binds both stacked categories");
6053        // The cell key for that row (no columns) binds Time AND Product, sorted.
6054        let key = cell_key_multi(&app.row_cats(), &tuples[0], &[], &[], &[]);
6055        assert_eq!(key, vec![(1, 10), (2, 20)]);
6056        // Grid dims: 1 row line, 1 col line (no col categories).
6057        assert_eq!(app.grid_dims(), (1, 1));
6058    }
6059
6060    #[test]
6061    fn nth_tuple_decodes_mixed_radix_and_matches_axis_tuples() {
6062        // Row cats Time(4 items) x Region(2 items): 8 lines, inner (Region)
6063        // varies fastest — same order as axis_tuples.
6064        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        // line 0 = (T0, R0); line 1 = (T0, R1); line 7 = (T3, R1).
6072        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        // Iterating 0..total reproduces the full Cartesian product built by the
6086        // reference product-builder (same ordering as axis_tuples).
6087        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        // Cross-check against the app's own axis_tuples on the 2x2 model.
6108        let mut app = build_app(grid_2x2_model());
6109        select(&mut app, MeasureId(101)); // Quantity[Time, Product]
6110        app.sync_axis_state();
6111        app.set_axis(CategoryId(2), Axis::Rows); // rows = [Time, Product] stacked
6112        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        // A synthetic model with a category of a few thousand items so the row
6124        // product is large; the pure helpers must give the right count and
6125        // decode any line without materializing the whole product.
6126        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        // Stack both categories on rows -> 5000 * 3 = 15000 row lines.
6149        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        // grid_dims reports the same total (no column category -> 1 col).
6156        assert_eq!(app.grid_dims(), (total, 1));
6157
6158        // Decode the first, a middle, and the last line without panic.
6159        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        // A cursor deep in the grid resolves its key without materializing rows.
6166        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)); // Quantity[Time, Product]
6178        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(); // back to start for a 2-D measure
6187        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        // One page dim (Region), pinned to North (index 0) by default.
6197        let (_, _, pinned) = app.resolved_axes();
6198        assert_eq!(pinned, vec![(CategoryId(3), ItemId(30))]); // North
6199
6200        // Cell [2025, WidgetA, North] = 100.
6201        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        // Page to South (index 1): pinned item and the visible value change.
6207        app.set_page(0, 1);
6208        let (_, _, pinned) = app.resolved_axes();
6209        assert_eq!(pinned, vec![(CategoryId(3), ItemId(31))]); // South
6210        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        // Pivot away from natural order.
6221        app.pivot_rotate();
6222        assert_ne!(
6223            app.focused().axis_order,
6224            vec![CategoryId(1), CategoryId(2), CategoryId(3)]
6225        );
6226
6227        // Add a 1-D measure and select it: axis order resets to its natural order.
6228        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        // Back to Sales: natural order restored (not the pivoted one).
6242        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    /// A 2x2 input measure Quantity[Time(2025,2026), Product(WidgetA,WidgetB)]
6251    /// plus Revenue = Price * Quantity, for cursor navigation/edit tests.
6252    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)); // Quantity[Time, Product], 2x2
6309        app.sync_axis_state();
6310
6311        // Off the top-left: clamps to (0, 0).
6312        app.move_cursor(-5, -5);
6313        assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (0, 0));
6314        // Off the bottom-right: clamps to (1, 1).
6315        app.move_cursor(100, 100);
6316        assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (1, 1));
6317
6318        // Switch to Price[Product]: 2 rows, 1 synthetic col -> column re-clamps.
6319        select(&mut app, MeasureId(100));
6320        app.sync_axis_state(); // resets cursor to (0,0) on measure switch
6321        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        // Sales 3-D: put the cursor at the far corner, then pivot to a shape
6330        // where the cursor would be out of range; clamp_cursor must fix it.
6331        let mut app = build_app(sales_3d_model());
6332        select(&mut app, MeasureId(200));
6333        app.sync_axis_state();
6334        // Region on cols has 2 items; move to the far cell.
6335        app.pivot_rotate(); // rows=Product, cols=Region (2 cols)
6336        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        // Pivot again (rows=Region -> single-item axes elsewhere) and confirm
6341        // the cursor never goes out of range.
6342        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)); // Quantity[Time, Product]
6351        app.sync_axis_state();
6352        // [1,1] = Quantity[2026, WidgetB] = Time(11), Product(21).
6353        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        // [0,1] = Quantity[2025, WidgetB] = Time(10), Product(21).
6360        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)); // Quantity[Time, Product]
6371        app.sync_axis_state();
6372        assert!(app.cursor_is_editable());
6373
6374        // Move to Quantity[2025, WidgetA] = [0,0], set it to 200.
6375        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        // Revenue[2025, WidgetA] = Price(10) * 200 = 2000 in the snapshot.
6382        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        // Derived measure: cursor cell is not editable (status set, not enter).
6388        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)); // Quantity[Time, Product]
6400        app.sync_axis_state();
6401        // Rows = Time (2025, 2026); hide 2026 (ItemId 11).
6402        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        // Columns (unfiltered Product) still show both.
6413        assert_eq!(app.sorted_items(CategoryId(2)).len(), 2);
6414        // Re-showing 2026 drops the filter (all items kept).
6415        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    /// Filter `cat` down to zero visible items (the state the audit found
6421    /// crashing: `nth_tuple`'s `i % 0` divide-by-zero in release builds).
6422    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)); // Quantity[Time, Product]
6442        app.sync_axis_state(); // rows=Time, cols=Product
6443        assert_eq!(app.grid_dims(), (2, 2));
6444
6445        hide_all_items(&mut app, CategoryId(1)); // Time -> nothing on rows
6446        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        // No cell is addressable, so nothing is editable and no under-specified
6450        // key can be produced.
6451        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        // Showing one item back restores exactly one line, with a complete key.
6460        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(); // rows=Time, cols=Product
6471        hide_all_items(&mut app, CategoryId(2)); // Product -> nothing on cols
6472        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        // Sales[Time, Product, Region]: Region is the page dimension. With no
6484        // Region item pinnable, every key would omit Region — under-specified.
6485        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)); // Region -> nothing pinnable
6493        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        // Both categories stacked on rows; emptying the INNER one zeroes the
6512        // whole product (the mixed-radix decode has no valid line).
6513        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); // rows = [Time, Product]
6517        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        // The distinction the fix turns on: `product_len(&[]) == 1` (an axis
6526        // with NO categories is scalar in that direction — one legitimate line)
6527        // vs a category present but filtered to zero items (no lines at all).
6528        let mut app = build_app(grid_2x2_model());
6529        select(&mut app, MeasureId(100)); // Price[Product] only
6530        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        // That single scalar line has a complete key (Price's only dimension).
6538        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        // Same model, but now the ROW category is emptied: 0 rows, while the
6543        // scalar column axis stays at 1.
6544        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        // A stale cursor (left over from before the filter) must not reach
6552        // `nth_tuple` with a zero radix: that is `i % 0`, a release-mode crash.
6553        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); // bottom-right of the 2x2 grid
6557        assert_eq!((app.focused().cursor_row, app.focused().cursor_col), (1, 1));
6558        hide_all_items(&mut app, CategoryId(1));
6559        // clamp_cursor ran via toggle_filter_item: the row axis has no lines, so
6560        // the row index collapses to 0 (the column axis is unaffected).
6561        assert_eq!(app.focused().cursor_row, 0);
6562        assert_eq!(app.cursor_key(), None);
6563        // Even a forced out-of-range cursor resolves to "no cell", not a panic.
6564        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)); // Quantity[Time, Product]
6579        app.sync_axis_state();
6580        app.pivot_rotate(); // rows=Product, cols=Time
6581        app.toggle_filter_item(CategoryId(2), ItemId(21)); // hide WidgetB
6582
6583        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)]); // only WidgetA kept
6593        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        // Build a view from one app, apply it to a fresh app -> layout matches.
6599        let mut src = build_app(grid_2x2_model());
6600        select(&mut src, MeasureId(101));
6601        src.sync_axis_state();
6602        src.pivot_rotate(); // rows=Product, cols=Time
6603        src.toggle_filter_item(CategoryId(2), ItemId(21)); // hide WidgetB
6604        let v = src.build_view(ViewId(1), "L1").expect("view");
6605
6606        let mut dst = build_app(grid_2x2_model());
6607        // dst starts on Revenue (derived) with natural axes and no filters.
6608        assert_eq!(dst.selected(), Some(MeasureId(102)));
6609        dst.apply_view(&v);
6610        dst.sync_axis_state(); // must not clobber the applied layout
6611
6612        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))); // Product on rows
6617        assert_eq!(col_cat, Some(CategoryId(1))); // Time on cols
6618                                                  // Filter reflected: only WidgetA on the (row) Product axis.
6619        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        // Page Region (dim 0) to South (index 1).
6633        src.set_page(0, 1);
6634        let (_, _, pinned) = src.resolved_axes();
6635        assert_eq!(pinned, vec![(CategoryId(3), ItemId(31))]); // South
6636        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))]); // North default
6644        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    // -- chart_series (pure; no egui) --------------------------------------
6654
6655    #[test]
6656    fn chart_series_yields_labels_and_oracle_values() {
6657        // Revenue[Time, Product], natural axes: rows=Time -> x, cols=Product ->
6658        // one series each. Oracle: WidgetA = 1000/1200, WidgetB = 1000/1600.
6659        let app = build_app(grid_2x2_model());
6660        assert_eq!(app.selected(), Some(MeasureId(102))); // Revenue selected
6661        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        // y-range spans 0..1600 (0 always included).
6670        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        // Revenue[Time, Product]: stack BOTH categories on rows, leaving no
6676        // column category. x-labels become the joined row tuples
6677        // ("2025 / WidgetA", ...) and there is one unnamed series whose values
6678        // match cell_key_multi lookups.
6679        let mut app = build_app(grid_2x2_model());
6680        select(&mut app, MeasureId(102)); // Revenue
6681        app.sync_axis_state();
6682        app.set_axis(CategoryId(2), Axis::Rows); // rows = [Time, Product]
6683        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        // One series (no column categories), unnamed.
6698        assert_eq!(d.series.len(), 1);
6699        assert_eq!(d.series[0].name, "");
6700
6701        // Each point matches a direct cell_key_multi lookup on the same tuples.
6702        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        // Oracle: WidgetA prices 10/20; Quantities 100,50,120,80 ->
6713        // 1000, 1000, 1200, 1600.
6714        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        // Revenue[Time, Product]: rows=Time, columns stack both? Instead stack
6723        // Product on rows AND keep Time on cols to exercise a multi-tuple
6724        // column axis. Here: rows=Product (2), cols=Time (2) -> 2 x-labels,
6725        // 2 series named by the (single-item) column tuples.
6726        let mut app = build_app(grid_2x2_model());
6727        select(&mut app, MeasureId(102));
6728        app.sync_axis_state();
6729        app.pivot_rotate(); // rows=Product, cols=Time
6730        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        // One series per Time item (single-element column tuples).
6740        assert_eq!(d.series.len(), 2);
6741        assert_eq!(d.series[0].name, "2025");
6742        assert_eq!(d.series[1].name, "2026");
6743        // 2025: WidgetA=1000, WidgetB=1000; 2026: WidgetA=1200, WidgetB=1600.
6744        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        // Price[Product]: 1-D grid -> a single unnamed series over Product.
6751        let mut app = build_app(grid_2x2_model());
6752        select(&mut app, MeasureId(100)); // Price[Product]
6753        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        // Hide 2026 on the Time (x) axis: its label and points drop out.
6768        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)); // hide 2026
6772        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)]); // WidgetA
6775        assert_eq!(d.series[1].points, vec![Some(1000.0)]); // WidgetB
6776    }
6777
6778    #[test]
6779    fn chart_non_numeric_cell_is_a_gap_not_a_panic() {
6780        // Overwrite one Revenue-input cell so a derived cell errors, and set a
6781        // Text input on another measure: both surface as gaps, not panics.
6782        let mut app = build_app(grid_2x2_model());
6783        // Make Quantity[2026, WidgetA] a Text value -> Revenue[2026, WidgetA]
6784        // becomes an Error (type mismatch), which values_for skips (gap).
6785        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(); // must not panic
6797                                    // WidgetA series: 2025 numeric, 2026 is a gap (None).
6798        assert_eq!(d.series[0].name, "WidgetA");
6799        assert_eq!(d.series[0].points, vec![Some(1000.0), None]);
6800        // WidgetB series unaffected.
6801        assert_eq!(d.series[1].points, vec![Some(1000.0), Some(1600.0)]);
6802        // y-range still valid, 0 included.
6803        let (lo, hi) = d.y_range();
6804        assert_eq!(lo, 0.0);
6805        assert!(hi >= 1600.0);
6806    }
6807
6808    // -- regression: the displayed-text-commits-unchanged invariant ---------
6809
6810    /// Every aggregation arg shape, systematically: `over` of length 0/1/2/3,
6811    /// crossed with an empty/non-empty `except` and a `by` list, plus non-`Ref`
6812    /// args (a literal, a binary op, a nested call) — for each aggregating func
6813    /// id. Whatever `formula_source` shows as editable MUST re-commit unchanged;
6814    /// what it cannot spell must be `None`, never unparsable text.
6815    ///
6816    /// (`proptest` is not a dev-dependency of this crate, so this is the
6817    /// systematic table over the arg space rather than a generator.)
6818    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            // Ref args: every (over, except, by) subset combination.
6828            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            // Non-`Ref` args: the parser's aggregation rule accepts none of them.
6847            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            // Zero args and two args (arity the aggregation rule cannot spell).
6879            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            // The aggregation inside a larger expression (the printer recurses
6894            // through `child_dsl`, which must not leak unparsable text either).
6895            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    /// DEFECT 1: an aggregation whose arg is not `Ref`-with-exactly-one-`over`
6911    /// has no DSL spelling. `formula_dsl` must return `None` (so the CNL
6912    /// fallback runs) instead of printing `SUM(Quantity)`, which
6913    /// `parse_expr` rejects ("expected 'OVER' in aggregation").
6914    #[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            // Whatever the DSL printer emits must parse as the same AST.
6927            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            // And whatever the bar shows as editable must re-commit unchanged.
6938            let Some(shown) = app.formula_source(MeasureId(102)) else {
6939                continue; // read-only: nothing is offered for editing
6940            };
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    /// DEFECT 1, the three-keystroke user path: type controlled English that
6962    /// builds an aggregation with an empty `over`, commit, then commit the text
6963    /// the bar now shows without editing it.
6964    #[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        // The bar reloads its buffer from `formula_source`; Commit again.
6975        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    /// DEFECT 2, **as plan Step 4b resolves it**: an awkward measure name is no
6987    /// longer a reason to go read-only. The printer now spells it with a quoted
6988    /// identifier (`'Unit Price'`), the form the grammar gained in Step 4a, and
6989    /// [`ImprovApp::formula_source`]'s self-verification — reparse the candidate
6990    /// text and demand the IDENTICAL `Formula` — confirms it round-trips.
6991    ///
6992    /// The self-verification is untouched and still the gate: this test asserts
6993    /// what it now *accepts*, not that it was relaxed. Every name shown as
6994    /// editable is re-committed here and the AST compared.
6995    ///
6996    /// Exactly ONE name remains genuinely unspellable: the **empty** name. `''`
6997    /// is an explicit parse error ("empty quoted name: '' names nothing"), so
6998    /// there is no text for it in either language — and it must still be
6999    /// refused rather than shown.
7000    #[test]
7001    fn awkward_measure_names_are_spelled_with_quoted_identifiers() {
7002        // Bare where bare works (no needless quoting), quoted where it does not.
7003        let bare = ["UnitPrice", "Unit_Price", "Price2024", "_Price"];
7004        // The nine names the pre-Step-4b test listed as unspellable. Eight are
7005        // now editable; `""` is the lone holdout.
7006        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        // Plus the escape rule (`''` is one literal quote) and a dotted name,
7017        // which quoting keeps out of the deliberately-unresolved qualified form.
7018        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                // Shown as editable => it MUST commit unchanged. This is the
7037                // invariant `formula_source` self-verifies; re-check it end to
7038                // end through the real commit path.
7039                Some(shown) => {
7040                    assert!(
7041                        !unspellable.contains(name),
7042                        "name {name:?} must stay unspellable but the bar shows {shown:?}"
7043                    );
7044                    // Bare names print bare; awkward ones print quoted.
7045                    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                // Not editable => neither parser may accept the DSL/CNL text,
7068                // which is exactly why we refuse to offer it.
7069                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    /// The doubling escape, end to end: a name containing a literal `'` prints
7086    /// as `'Bob''s Rate'` and reparses to the same measure. (`parser`'s own
7087    /// tests cover the tokenizer; this pins the PRINTER's half of the pair.)
7088    #[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    /// Category names inside `[...]` and after `OVER` go through the same
7100    /// [`ident`], so an awkward CATEGORY name is spellable too — the other half
7101    /// of the read-only defect (a CSV import names categories from headers as
7102    /// well).
7103    /// The P3 forms print as DSL that commits back to the same formula.
7104    #[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    /// COALESCE, FILL, RELABEL and SWAP (category arguments) and FIXPOINT
7129    /// (its iterate prints as the LAMBDA parameter acc0) print as DSL that
7130    /// commits back to the same formula.
7131    #[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    /// A named LAMBDA call prints by name and re-parses.
7150    #[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        // A category parameter: its argument prints as the category.
7163        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    /// An array formula's INTO clause prints and re-parses.
7183    #[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                // Expanded when read: shown as the reduction it is.
7199                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    /// In a German locale the formula bar shows and accepts German names,
7207    /// a decimal comma and `;` separators; the model stores the canonical form.
7208    #[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        // Revenue = SUM(Quantity OVER 'Fiscal Year')
7233        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        // And it round-trips through the real commit path.
7251        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    /// DEFECT 5: with an empty ROW axis the grid renders zero lines; the chart
7263    /// must not invent an x line labelled `""`.
7264    #[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        // Hide every Time item: rows -> empty.
7271        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    /// DEFECT 5: an empty COLUMN axis likewise yields no series, and an
7285    /// unpinnable PAGE category yields nothing at all (the grid is (0,0)) —
7286    /// never a full chart drawn at keys that omit the page category.
7287    #[test]
7288    fn chart_with_an_empty_column_or_page_axis_has_no_data() {
7289        // Empty column axis.
7290        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        // Empty page axis (3-D measure: Region pages).
7304        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    /// DEFECT 7: a parse error sets the inline red underline; a subsequent
7321    /// VALID formula whose SAVE fails must not leave that underline pointing
7322    /// into text which parses cleanly.
7323    #[test]
7324    fn stale_parse_error_is_cleared_when_a_later_save_fails() {
7325        let mut app = build_app(revenue_model());
7326        // Step 1: a parse error sets the inline highlight.
7327        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        // Step 2: a VALID formula whose save fails (unwritable store path).
7332        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    /// A category left in `axis_order` that is NOT a dimension of the selected
7347    /// measure must not affect that measure's grid. Regression for the review's
7348    /// defect #4: a stale PAGE category filtered to zero items blanked a grid
7349    /// whose every cell was fully specified.
7350    #[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        // Sales ranges over Time x Product ONLY; Region is not a dimension.
7361        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        // What apply_view leaves behind after the measure was re-imported over
7376        // fewer dimensions: a stale Region page category.
7377        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        // Contrast, and the 6a4b862 behavior that must NOT regress: a category
7401        // that IS a real dimension, filtered to empty, still yields zero lines.
7402        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    /// Regression for the review's defect #6: a failed save must not leave the
7414    /// in-memory model diverged from the store.
7415    #[test]
7416    fn failed_cell_save_rolls_back_the_in_memory_edit() {
7417        let mut app = build_app(revenue_model());
7418        // An unwritable store path: save() must fail.
7419        app.db = "/nonexistent-dir-improv/cannot-write.db".to_string();
7420        let measure = MeasureId(101); // Quantity (input)
7421        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    // -- typed (non-numeric) cell display + editing -------------------------
7434
7435    /// A `Value::DateTime` built through the same literal grammar cell editing
7436    /// uses (so tests need no direct chrono dependency).
7437    fn date(text: &str) -> Value {
7438        parse_typed(&Model::new(), ValueType::DateTime, text).expect("fixture date")
7439    }
7440
7441    /// One input measure per declared type, all over a single category so each
7442    /// grid is 1x1 and the coordinate is trivial.
7443    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    /// A typed edit flows through the live engine: a derived measure over a
7476    /// Text input (`Label = "go"`) and one over a Boolean (`NOT Active`)
7477    /// recompute on the edit itself, not only after a rebuild. Before, a
7478    /// non-number edit cleared the engine cell, so both went blank.
7479    #[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        // Clearing the input clears what depends on it.
7528        app.commit_cell_text(MeasureId(2), key.clone(), "").unwrap();
7529        assert_eq!(shown(&app, 10), None);
7530    }
7531
7532    /// Every declared type RENDERS its value. Pre-fix the grid projected input
7533    /// cells through `f64`, so Text/Boolean/DateTime cells were blank.
7534    #[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        // A cell with no value is still blank (absence, not type).
7546        assert_eq!(app.cell_text(MeasureId(2), &vec![(1u32, 99u32)]), None);
7547    }
7548
7549    /// Each declared type round-trips through an edit: type-appropriate text in,
7550    /// the matching `Value` variant stored, the display reading it back.
7551    #[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    /// The DECLARED type wins over the text's shape: numeric-looking text typed
7593    /// into a Text measure stays Text. Pre-fix every commit parsed `f64` and
7594    /// stored `Value::Number`, corrupting the cell's type.
7595    #[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        // And the typed API refuses a mismatched variant outright.
7610        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    /// Text that does not parse as the declared type is rejected: the error is
7617    /// surfaced and the prior typed value is untouched.
7618    #[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    /// The chosen empty-commit behavior: an empty buffer CLEARS the cell
7643    /// (Escape already cancels, so this is the only way to delete a value).
7644    #[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        // Clearing an already-empty cell is a no-op success.
7657        assert!(app.commit_cell_text(MeasureId(2), key, "").is_ok());
7658    }
7659
7660    /// Clearing a numeric cell must retract it from the live engine too, so
7661    /// derived measures stop computing with a value the cell no longer holds.
7662    #[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    /// Retyping a cell as a non-numeric value must also retract it from the
7682    /// engine's numeric lane (only numbers flow there), so no derived cell keeps
7683    /// computing from a number the cell no longer holds.
7684    #[test]
7685    fn retyping_a_numeric_cell_as_text_retracts_it_from_the_engine() {
7686        // Build the engine while Quantity is numeric, so it really holds the
7687        // cell (declaring Text up front makes `Price * Quantity` fail to
7688        // compile, leaving no engine and testing nothing), THEN redeclare the
7689        // measure as Text so a text edit is the legal one.
7690        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        // The text value replaces the number in the engine (typed edits reach
7712        // it), and arithmetic over text is NaN, never the stale 70.
7713        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    /// Defect-6 atomicity on the TYPED path: a failed save rolls the in-memory
7721    /// typed edit back (set AND clear), prior value intact.
7722    #[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    /// Begin-edit seeds the buffer with the cell's TYPED display text, so a
7751    /// text/date cell is tweaked rather than retyped from blank.
7752    #[test]
7753    fn begin_edit_seeds_the_typed_display_text() {
7754        let mut app = build_app(typed_model());
7755        select(&mut app, MeasureId(2)); // Label (Text)
7756        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    // -- ITEM 1: grid shortcuts must not steal keystrokes ------------------
7766
7767    /// The gate is a pure predicate so it can be checked exhaustively: grid
7768    /// bindings run only when NOTHING owns the keyboard. Pre-fix the decision
7769    /// was `!editing` alone, so the `(false, true)` row — some *other* text
7770    /// field focused — wrongly enabled them.
7771    #[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    /// Feed one frame of key presses through `handle_grid_keys` with `focus`
7783    /// optionally held by a NON-grid widget id, and report what the grid state
7784    /// became: (selected measure, cursor, first page index).
7785    ///
7786    /// The keys are delivered to the FOCUSED matrix (index `app.focus_index()`),
7787    /// which is what the canvas does; `frame_with_keys_for` drives a specific
7788    /// matrix, for asserting that an unfocused one ignores them.
7789    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    /// [`frame_with_keys`], addressed at matrix `mi` rather than the focused one.
7799    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                // Exactly what a focused TextEdit elsewhere in the app does.
7822                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    /// Typing in the formula editor / new-measure form / CSV wizard / view-name
7836    /// box must not drive the grid: with focus elsewhere, `n`, `j`/`l` and `]`
7837    /// change nothing. Pre-fix each of them cycled the measure, moved the cell
7838    /// cursor, and paged a dimension mid-word.
7839    #[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    /// The gate is transient, not sticky: the very next frame after focus is
7863    /// released, the same keys work again.
7864    #[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)); // Sales[Time, Product, Region]
7868        app.sync_axis_state();
7869
7870        // Frame 1: focused elsewhere -> `]` must not page.
7871        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        // Frame 2: focus released -> the very same key works again (the gate is
7876        // transient, not a latch).
7877        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    /// Cell editing is untouched by the gate: `editing.is_some()` already
7883    /// suppressed the bindings, and the editor's own focus must not change that.
7884    #[test]
7885    fn cell_editing_still_works_while_editing() {
7886        let mut app = build_app(grid_2x2_model());
7887        select(&mut app, MeasureId(101)); // Quantity[Time, Product]
7888        app.sync_axis_state();
7889
7890        // Enter opens the editor (no focus held).
7891        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        // While editing, the cell editor holds focus. The grid bindings stay
7900        // out of the way and the edit buffer survives the frame.
7901        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        // And committing the buffer still writes the cell.
7912        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    /// Ctrl+Z goes through the same gate: it must not fire while a text field
7921    /// is focused (Ctrl+Z belongs to that field's own editing), and must fire
7922    /// when nothing is focused.
7923    #[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    // -- ITEM 2: undo / redo of MODEL state -------------------------------
7970
7971    /// The oracle cell + its Revenue key for the 2x2 fixture.
7972    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        // Quantity[2025, WidgetA] = 100, Price[WidgetA] = 10 -> Revenue 1000.
7983        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        // A new derived measure is undone away entirely.
8042        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    /// A CSV import creates a measure, a category, and items. Undo must take
8068    /// all of them away, not just the cells.
8069    #[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        // And after undoing the only recorded step.
8144        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        // UNDO_DEPTH + 5 edits: the five oldest undo points are evicted.
8158        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        // Undo everything we can: we land on the state after edit #5 (the
8169        // oldest still-recorded point), never the original 100.
8170        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    /// Undo must PERSIST: undo then quit must not resurrect the undone state.
8200    #[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    /// A store write that fails during undo is surfaced, and the step is not
8242    /// lost: the undo point stays, so the user can retry.
8243    #[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        // The UI path reports it instead of claiming success.
8267        app.undo_with_status();
8268        assert!(app.status.starts_with("undo failed:"), "{}", app.status);
8269    }
8270
8271    // -- STEP 1: margin gutters (docs/reviews/2026-09-22-gui-reconstruction-plan.md)
8272
8273    /// A headless egui `Context` sized like a desktop window, plus a `pass`
8274    /// closure that lays out one real frame of the whole app and returns the
8275    /// gutter/table geometry the grid recorded.
8276    ///
8277    /// This is a genuine headless frame: `Context::run` over the same panel
8278    /// sequence `eframe::App::update` drives, so the rects are the ones a user
8279    /// would see, not a reconstruction. (`eframe::Frame` cannot be built outside
8280    /// eframe, but `update` only forwards it, so the panel calls are made
8281    /// directly here.) The `Context` is handed back so a caller can run MANY
8282    /// frames on ONE context: egui carries panel extents from frame to frame, so
8283    /// single-frame geometry cannot see a gutter that ratchets.
8284    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    /// Run one whole-app frame (every panel, in `update`'s order) at `size`.
8302    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    /// [`full_frame`] delivering `events`, then calling `inspect` inside the
8312    /// pass (while the layers' shapes are still in `ctx.graphics`).
8313    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            // As a backend does: the held modifiers ride along with the events.
8325            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    /// Item headers are plain dark text in a ruled band, not beveled chips:
8352    /// no filled rect sits behind a header, and its text is the body color.
8353    #[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        // "WidgetA" is the column header, "2025" the row stub.
8370        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                // Chip-sized only: the #555555 workspace behind every
8387                // window contains every header too.
8388                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    /// Numbers right-align in their column so the digits line up (Improv's
8400    /// grid); text stays left. Two values of different widths in one column
8401    /// must share a right edge, and a text cell must not.
8402    #[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        // The on-screen [left, right] x-extent of the text drawn as `s`.
8428        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        // The column still fits its widest value: right-aligning must not
8468        // inflate the auto-sized column (the widest number starts where the
8469        // column, i.e. a left-aligned value, starts).
8470        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    /// At the minimum window size (and the old 800x600 default) nothing a
8477    /// worksheet window paints or makes interactive reaches outside its own
8478    /// window rect. Windows float over the side panels by design (NeXT
8479    /// floating windows); what must not happen is the gutter panels escaping
8480    /// the window, which `show_inside` would do by replacing their clip.
8481    #[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            // Below the minimum (as a hand-edited view could be), so the
8490            // gutters overflow the body.
8491            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    /// Lay out the whole app for several frames at a desktop window size and
8549    /// return EVERY matrix's recorded gutter geometry, in canvas order.
8550    ///
8551    /// The multi-matrix counterpart of [`layout_frame`]: a canvas holding N
8552    /// matrices must have N framed tables, not one.
8553    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    /// Lay out `app` until its geometry stops changing, and return it. A panel
8569    /// reads its extent from the previous frame's `PanelState`, so the frame
8570    /// right after a layout change still reports the old size; settling makes an
8571    /// assertion about *the* layout rather than about a transient.
8572    ///
8573    /// Panics if the geometry never settles within 20 frames — which is itself
8574    /// the check that no gutter ratchets open frame after frame.
8575    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    /// Lay out one app on a fresh context and return its settled geometry.
8592    fn layout_frame(app: &mut ImprovApp) -> GutterRects {
8593        let (ctx, mut pass) = layout_harness();
8594        settle(&ctx, app, &mut pass)
8595    }
8596
8597    /// A `Sales[Time, Product, Region]` app ready to lay out: `Time` on rows,
8598    /// `Product` on columns, `Region` in the well.
8599    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    /// Re-pivoting repeatedly on ONE long-lived context must not make any gutter
8607    /// creep: stacking a tile grows the column gutter, unstacking shrinks it back
8608    /// to exactly its old size, and the corner well returns to its old height.
8609    ///
8610    /// This is a regression the first draft of this step actually had: a drop
8611    /// zone claiming `available_size()` inside a self-sizing panel ratcheted the
8612    /// well taller every frame, because the next frame's "available" included
8613    /// what the zone claimed in the last one. A single-frame test cannot see it.
8614    #[test]
8615    fn gutters_do_not_creep_across_frames_or_repivots() {
8616        let mut app = gutter_app();
8617        // No page tiles: their item field would outgrow two column tiles and
8618        // hide the column stack's growth.
8619        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    /// Shrinking the window must never panic a debug build, and must never claim
8646    /// the gutters frame the table when they have been squeezed flat. At a real
8647    /// window size the invariant holds; below the size where the panels still fit
8648    /// [`gutters_have_room`] reports that and the per-frame `debug_assert!`
8649    /// stands down.
8650    ///
8651    /// This drives [`ImprovApp::gutter_frame`]'s own `debug_assert!` at every
8652    /// size, so a regression that breaks adjacency at a usable window size fails
8653    /// here rather than at a user's first resize.
8654    #[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            // Several frames so the panels settle at this size (and so the
8670            // per-frame debug assertion runs on the settled geometry too).
8671            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    /// **The Step 1 acceptance test.** The row gutter's rect adjoins the
8704    /// table's LEFT edge and the column gutter's adjoins its TOP edge — the
8705    /// geometric check the old three-zone horizontal shelf failed (it sat
8706    /// entirely above the grid, touching neither edge).
8707    #[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        // The gutters run ALONGSIDE the table, not merely touch a corner.
8724        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        // The top gutter is a real strip; `left` is the table's left edge.
8733        assert!(g.left.height() > 0.0, "{g:?}");
8734        assert!(g.top.width() > 0.0 && g.top.height() > 0.0, "{g:?}");
8735        // And the whole arrangement satisfies the framing invariant.
8736        assert!(gutters_frame_table(&g), "{g:?}");
8737    }
8738
8739    /// The row strip is the BOTTOM-LEFT one the reference shows (`Product` /
8740    /// `Channel` inline with the horizontal scrollbar): below the table, flush
8741    /// with its left edge.
8742    #[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    /// NeXT placement: every row tile in the bottom strip, every column tile
8760    /// in the top strip, right of every page tile.
8761    #[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    /// Stacking two categories on the column axis makes the column gutter
8786    /// TALLER (tiles stack vertically, as the reference's "Result" panel shows
8787    /// `Travel` above `Hours`) while still adjoining the table's top edge.
8788    #[test]
8789    fn stacked_column_tiles_grow_the_gutter_downward_and_stay_docked() {
8790        let mut app = gutter_app();
8791        // No page tiles (their item field is taller than two column tiles).
8792        app.set_axis(CategoryId(3), Axis::Rows);
8793        let one = layout_frame(&mut app);
8794        assert_eq!(app.col_cats().len(), 1);
8795
8796        // Stack a second category on columns (the drop a drag would produce).
8797        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        // ...and unstacking shrinks it back: the gutter must not latch at its
8814        // tallest (a drop zone sized to `available_size` inside a self-sizing
8815        // panel would ratchet the panel open forever).
8816        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    /// An axis filtered to zero lines still lays out gutters that frame the
8829    /// table (the empty-axis fix must not be regressed by the new geometry).
8830    #[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    /// Dragging a tile from one gutter to another re-pivots through `set_axis`:
8841    /// the drop is egui-internal, so drive the same payload/axis pair the drop
8842    /// zone produces and assert on the axis state.
8843    #[test]
8844    fn dropping_a_tile_in_another_gutter_repivots() {
8845        let mut app = gutter_app(); // Sales[Time, Product, Region]
8846        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        // Drag `Time` from the row gutter into the column gutter: it leaves rows
8852        // and APPENDS to the column stack (the reference's `Travel` landing
8853        // under `Hours`).
8854        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        // Drag `Region` out of the well into the row gutter.
8863        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        // And the layout still frames the table after the re-pivot.
8868        let g = layout_frame(&mut app);
8869        assert!(gutters_frame_table(&g), "{g:?}");
8870    }
8871
8872    /// `set_axis` (what a tile's drop and "Move to" menu call) round-trips a
8873    /// category rows -> columns -> pages -> rows.
8874    #[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    /// `gutters_frame_table` must REJECT the old horizontal shelf: three zones
8888    /// laid out side by side above the grid touch neither edge. Without this the
8889    /// invariant could be vacuously true.
8890    #[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        // Shelf: [Columns][Rows][Pages] in one row at y 0..30, grid below at y 40.
8894        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        // The gutter arrangement passes.
8906        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    /// Spec §4.4/§4.8: the horizontal scroller runs in the bottom strip,
8917    /// right of the row tiles, and a grid wider than its window scrolls
8918    /// sideways with it (before, the far columns could not be reached).
8919    #[test]
8920    fn a_wide_grid_scrolls_sideways_with_the_inline_scroller() {
8921        // Quantity[Time, Product] with 40 products: far wider than the window.
8922        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        // Three clicks on the right arrow move the columns left by 3 lines.
8954        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        // Dragging the knob to the far end brings the last column into view,
8961        // inside the window's right edge.
8962        // Back to the start (the left arrow), so the knob begins the track.
8963        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        // The vertical scroller stays at the window's right edge.
8980        let g2 = app.focused().gutters.expect("laid out");
8981        assert!(g2.table.right() <= win.right() + EDGE_EPS, "{g2:?} {win:?}");
8982    }
8983
8984    // -- STEP 2: the formula list pane
8985    // (docs/reviews/2026-09-22-gui-reconstruction-plan.md)
8986
8987    /// A model with FOUR derived measures, one chained onto another, so the
8988    /// formula list has several rows and disabling one has a visible dependent:
8989    ///
8990    /// * `Revenue   = Price * Quantity`      (id 102)
8991    /// * `Tax       = Revenue * Rate`        (id 103, depends on Revenue)
8992    /// * `Net       = Revenue - Tax`         (id 104, depends on both)
8993    /// * `Doubled   = Quantity + Quantity`   (id 105, independent)
8994    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        // Declared over the same dimensions as Revenue, with a cell at every
9006        // coordinate: this engine aligns a derived*input product on matching
9007        // dimensions, it does not broadcast a narrower input across them.
9008        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    /// **Step 2 acceptance, part 1:** a model with several derived measures
9060    /// lists ALL of them, numbered, each with its target measure and formula
9061    /// text — the whole model's logic visible at once, which a single-line bar
9062    /// for the selected measure cannot show.
9063    #[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        // Every derived measure, and ONLY derived measures (Price/Quantity/Rate
9069        // are inputs: they have no formula, so they have no row).
9070        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        // Each row carries its formula text, and it is the editable spelling.
9086        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        // The display number is the 1-based row position (the reference's
9094        // `1.` `2.` …): inserting a formula renumbers, it is not an identity.
9095        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    /// **Step 2 acceptance, part 2:** clicking a row selects that measure, so
9105    /// the grid follows the formula list. Driven through a real headless frame
9106    /// (a synthesized click at the row's own screen position), not by calling a
9107    /// handler — so a row that renders un-clickable fails here.
9108    #[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        // Find where row 4 ("Doubled") landed, then click it.
9115        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        // And the grid really did follow: the pivot state re-homed onto it.
9125        app.sync_axis_state();
9126        assert_eq!(app.focused().axis_for, Some(target));
9127    }
9128
9129    /// Lay out `app` in real headless frames and click the formula-list row for
9130    /// `measure`, at the position the row actually occupied. Returns whether
9131    /// such a row was laid out.
9132    ///
9133    /// Two phases, because egui hit-tests a click against the rect a widget had
9134    /// in a PREVIOUS frame: settle the panels first (recording
9135    /// `formula_row_rects`), then deliver a press+release at that point. A row
9136    /// that renders un-clickable therefore fails this, where calling a handler
9137    /// directly would not.
9138    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        // Settle the panels so the pane's rows have their final positions.
9160        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        // A real press+release at the row's center.
9169        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    /// Plan step 4a: the formula list is part of each worksheet window, under
9192    /// its grid, not an app-wide bottom panel. With two windows, the rows are
9193    /// laid out inside each window in turn (the last one drawn records them).
9194    #[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    /// **Step 2 acceptance, part 3:** toggling a formula off recomputes
9228    /// dependents. Asserted on the SNAPSHOT, which is what the grid draws.
9229    ///
9230    /// `Tax = Revenue * Rate` and `Net = Revenue - Tax`. Disabling `Tax` must
9231    /// make `Tax` compute nothing *and* change `Net`, which is the real test: a
9232    /// GUI-only "hide this formula" flag would leave `Net` sitting at its old
9233    /// value.
9234    ///
9235    /// `Net` goes *absent* rather than falling back to `Revenue - 0`, because
9236    /// this engine JOINS its operands: a measure with no cells contributes no
9237    /// coordinates, so every dependent loses those coordinates too. That is the
9238    /// same behavior a dependent of an empty input measure already has —
9239    /// disabling a formula makes it indistinguishable from an input nobody has
9240    /// filled in, which is the honest reading of "this formula is not running".
9241    #[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        // Baseline: Revenue 1000, Tax 100, Net 900.
9247        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        // Tax computes nothing now — it is no longer a dataflow output.
9255        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        // ...and the DEPENDENT recomputed rather than keeping its stale 900:
9265        // Net's operand vanished, so Net vanished with it.
9266        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        // The untouched formulas are unaffected — only the dependents moved.
9276        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        // Re-enabling restores every value exactly — the formula was kept, not
9280        // re-derived from text.
9281        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    /// **Step 2 acceptance, part 4:** the disable SURVIVES a save/reload round
9289    /// trip through `storage_mentat` — which is why it is a model-level fact and
9290    /// not a GUI flag. After reload the formula is still present, still
9291    /// disabled, and the reloaded engine still computes dependents without it.
9292    #[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        // Give Tax a real description too, so the round trip must preserve BOTH
9320        // the user's text and the disabled bit.
9321        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        // Reload from the store: a fresh app, nothing carried in memory.
9330        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        // The FORMULA itself survived (that is why the marker rides alongside it
9336        // rather than replacing it), and so did the user's own description text.
9337        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        // The reloaded ENGINE honors it: Tax blank, and Net — which depends on
9348        // Tax — recomputed away with it.
9349        assert_eq!(reloaded.values_for(MeasureId(103)).get(&qkey), None);
9350        assert_eq!(reloaded.values_for(MeasureId(104)).get(&qkey), None);
9351        // The list still shows it, unchecked rather than hidden.
9352        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        // And re-enabling in the reloaded app restores the original values.
9361        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    /// **Step 2 acceptance, part 5:** a formula that does not build shows its
9373    /// error INLINE on its own row, rather than vanishing or silently listing as
9374    /// fine. (A cell-type mismatch: `Revenue * Label` where `Label` is Text.)
9375    #[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        // Broken = Revenue * Label — a type error the compiler rejects.
9387        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        // It is LISTED, with its text, and carries an inline error.
9403        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        // The SOUND formulas do not borrow its error.
9417        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    /// A formula with no editable spelling says so inline, because the editor
9423    /// above shows that row read-only — the list must not imply it is editable.
9424    #[test]
9425    fn a_formula_with_no_editable_spelling_says_so_inline() {
9426        let mut model = four_formula_model();
9427        // An empty measure name is the one thing `ident` cannot spell (`''` is a
9428        // parse error), so Revenue's formula loses its DSL and CNL spellings.
9429        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    /// The disabled marker is a *reserved* description line, so it must not be
9445    /// confused with a user description that merely looks like it, and toggling
9446    /// must be an exact round trip in both directions.
9447    #[test]
9448    fn the_disabled_marker_never_eats_a_user_description() {
9449        // A description that starts with the marker's characters but is not it.
9450        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        // The marker alone, and the marker with text after it.
9456        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        // No description at all.
9460        assert_eq!(split_marker(None), (false, None));
9461        // join_marker inverts split_marker for each of those.
9462        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        // End to end: a measure whose description is the lookalike is NOT
9477        // treated as disabled, and toggling it keeps that text.
9478        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    /// Toggling a formula is undoable and atomic on the same terms as a formula
9492    /// edit: it goes through `publish`, so it is an undo point, and a failed
9493    /// save leaves the model untouched.
9494    #[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        // An unwritable store fails the toggle and changes nothing.
9508        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    /// Toggling is a no-op (not an error) where there is no formula to toggle,
9526    /// and disabling EVERY formula is a valid model that simply computes
9527    /// nothing — no engine, no panic, inputs still readable.
9528    #[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        // An input measure and an unknown id: both no-ops.
9532        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        // Inputs still read fine, and every row is still listed.
9547        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    /// The pane renders in a real frame without panicking, at a realistic size
9553    /// and squeezed flat — the same headless-frame discipline Step 1 established.
9554    #[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            // Still listing every formula after the frames.
9580            assert_eq!(app.formula_rows().len(), 4, "{w}x{h}");
9581        }
9582    }
9583    // -- STEP 3: N matrices on a canvas
9584    // (docs/reviews/2026-09-22-gui-reconstruction-plan.md)
9585
9586    /// A canvas with TWO matrices over `grid_2x2_model()`: matrix 0 shows
9587    /// `Quantity` (an input), matrix 1 shows `Revenue = Price * Quantity` (its
9588    /// dependent). The shape the Quantrix reference has — several matrices side
9589    /// by side, each its own pivot — and the shape that makes "an edit in one
9590    /// recomputes the other" a real assertion.
9591    fn two_matrix_app() -> ImprovApp {
9592        let mut app = build_app(grid_2x2_model());
9593        select(&mut app, MeasureId(101)); // Quantity on matrix 0
9594        let i = app.add_matrix(Some(MeasureId(102))); // Revenue on matrix 1
9595        assert_eq!(i, 1);
9596        app.set_focus(0);
9597        app.sync_axis_state();
9598        app
9599    }
9600
9601    /// **Step 3 acceptance, part 1:** two matrices exist on one canvas at once,
9602    /// and each is INDEPENDENTLY pivotable — pivoting one leaves the other's axis
9603    /// state exactly as it was.
9604    ///
9605    /// Asserted on per-matrix axis state, because that is what "independently
9606    /// pivotable" means: before the refactor there was one `axis_order` for the
9607    /// whole app, so this could not even be expressed.
9608    #[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        // Both start on their measure's natural axes: Time on rows, Product on
9615        // columns.
9616        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        // Pivot matrix 1 only (the focused matrix is 0, so this must go through
9625        // the matrix itself — exactly as a click in matrix 1 does).
9626        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        // And stacking on one matrix does not stack on the other.
9643        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        // Filters are per matrix too: hiding an item in one does not hide it in
9653        // the other.
9654        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        // ...and so are cursors.
9662        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        // Finally: the two layouts are not merely *stored* separately, they are
9670        // RENDERED separately. A real headless frame, then each matrix's own
9671        // record of what its grid was drawn with (see `Matrix::rendered_axes`).
9672        // Without this a renderer that ignored its matrix index and drew the
9673        // focused matrix's pivot twice would pass everything above.
9674        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    /// **Step 3 acceptance, part 2:** an edit in ONE matrix recomputes a
9699    /// dependent measure displayed in ANOTHER. The canvas is a view over one
9700    /// shared engine, so `Quantity` edited on matrix 0 must change what matrix 1
9701    /// (`Revenue = Price * Quantity`) draws at that coordinate.
9702    ///
9703    /// Asserted through `cell_text` at matrix 1's own cursor key — the string the
9704    /// second matrix actually renders — not through the snapshot, so a canvas
9705    /// that rendered stale values from a per-matrix cache would fail here.
9706    #[test]
9707    fn an_edit_in_one_matrix_recomputes_a_dependent_in_another() {
9708        let mut app = two_matrix_app();
9709        // Both matrices sit on [2025, WidgetA] (cursor 0,0). Quantity = 100,
9710        // Price = 10, so Revenue = 1000.
9711        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        // Edit Quantity through matrix 0's cell editor path.
9725        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        // The other coordinates of Revenue are untouched (an incremental
9739        // recompute, not a wholesale reset).
9740        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    /// **Step 3 acceptance, part 3a:** saving and reloading round-trips the whole
9750    /// multi-matrix canvas — every matrix's measure, pivot, filters, page pins
9751    /// and geometry — through `storage_mentat` and back.
9752    #[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        // Matrix 0: pivoted, its page dimension advanced one item.
9764        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        // Matrix 1: the same measure, with its column category STACKED onto its
9774        // row axis instead, placed elsewhere.
9775        //
9776        // Which category that is, is read from the matrix rather than hardcoded:
9777        // `load_measure_categories` queries datalog, which does not promise an
9778        // order, so a measure's declared category order after a reload is not
9779        // fixed. Naming a category id here made this test pass or fail depending
9780        // on which one happened to land on the column axis.
9781        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        // The view holds BOTH matrices: the primary flat fields plus one extra.
9800        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        // Reload from the store and apply the view: the canvas comes back.
9809        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(); // must not clobber the applied layout
9814
9815        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        // Spot-check the parts that are easy to lose, against what the live
9824        // canvas had rather than against absolute category ids: the store does
9825        // not promise to preserve a measure's DECLARED category order, so
9826        // "Time ends up on rows" is not a property of this round trip. That each
9827        // matrix comes back with the pivot, page pin and geometry IT had is.
9828        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        // The two matrices really do differ, so the loop above is not comparing
9847        // one layout with itself: matrix 1 has two categories stacked on rows.
9848        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    /// **Step 3 acceptance, part 3b:** a LEGACY single-matrix view — one written
9861    /// before canvases, with no `placements` and no geometry — still loads
9862    /// correctly, and loads as exactly one matrix with its saved pivot.
9863    ///
9864    /// The view here is built the pre-canvas way (flat fields only, `placements`
9865    /// empty, `rect` defaulted), which is what `serde` produces for an old stored
9866    /// blob; `storage_mentat` has its own test that such a blob deserializes, and
9867    /// this is the GUI honoring it.
9868    #[test]
9869    fn a_legacy_single_matrix_view_still_loads() {
9870        let (time, product) = (CategoryId(1), CategoryId(2));
9871        // Exactly the shape a pre-canvas save produced.
9872        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        // Apply it to a canvas that currently has TWO matrices: a one-matrix view
9893        // must reduce the canvas to one, not leave a stray matrix behind.
9894        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        // The filter really is in effect on the restored matrix.
9919        assert_eq!(
9920            m.sorted_items(&app.model, product)
9921                .iter()
9922                .map(|(id, _)| *id)
9923                .collect::<Vec<_>>(),
9924            vec![ItemId(20)]
9925        );
9926        // And re-saving it yields the same one-matrix shape (no phantom extras).
9927        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    /// A view whose measure is gone is refused and leaves the canvas alone; a
9936    /// view where only SOME matrices' measures are gone loads the rest and says
9937    /// so, rather than showing a blank matrix.
9938    #[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        // Drop the measure the EXTRA matrix shows.
9943        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        // Now drop the primary one too: nothing is applicable, so nothing moves.
9950        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    /// **Step 3 acceptance, part 4:** only the FOCUSED matrix consumes grid
9958    /// shortcuts. An arrow key must move one cursor, not every cursor on the
9959    /// canvas — driven through `handle_grid_keys` in a real frame, per matrix
9960    /// index, which is how the canvas calls it.
9961    #[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        // Deliver the keys AT matrix 0 (the focused one): its cursor moves.
9969        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        // Deliver the SAME keys at matrix 1 while matrix 0 still has the focus:
9982        // nothing happens at all (the gate is focus, not which Ui rendered).
9983        //
9984        // Matrix 0's cursor is put back to the top-left first, so this also
9985        // catches the subtler bug: a handler that ignored `mi` and simply drove
9986        // "the focused matrix" would move matrix 0 from (0,0) here. (Asserting
9987        // against (1,1) could not see that — the 2x2 grid clamps there.)
9988        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        // Put it back where the first delivery left it for the rest of the test.
10002        app.matrices[0].cursor_row = 1;
10003        app.matrices[0].cursor_col = 1;
10004
10005        // Move the focus: now matrix 1 takes them, and matrix 0 stops.
10006        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        // Enter opens the cell editor on the focused matrix ONLY. Matrix 1 shows
10020        // derived Revenue, so focus matrix 0 (input Quantity) for this.
10021        app.set_focus(0);
10022        // Enter aimed at the UNFOCUSED matrix opens nothing anywhere.
10023        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        // Aimed at the focused one it opens that one's editor, and only that one's.
10029        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    /// **Step 3 acceptance, part 5:** each matrix's margin gutters frame ITS OWN
10041    /// table — Step 1's invariant, now per matrix, asserted on the rects a real
10042    /// headless frame recorded.
10043    ///
10044    /// Two matrices means two independent framed regions: this fails if the
10045    /// canvas renders one set of gutters, or if the second matrix's gutters land
10046    /// on the first matrix's table.
10047    #[test]
10048    fn every_matrix_on_the_canvas_frames_its_own_table() {
10049        let mut app = two_matrix_app();
10050        // Place them far apart so overlapping rects cannot make the assertion
10051        // accidentally true.
10052        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            // Step 1's two headline adjacencies, spelled out per matrix.
10077            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        // The two framed regions are genuinely distinct: matrix 1's table is
10087        // below matrix 0's, not the same rect reported twice.
10088        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    /// The workspace must not creep either: with N worksheet windows, the
10096    /// geometry has to settle and stay put frame after frame (Step 1's
10097    /// `gutters_do_not_creep_across_frames_or_repivots` discipline applied to
10098    /// the windows; a single-frame assertion cannot see a ratchet).
10099    #[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        // Settle (a panel reads its extent from the previous frame), then assert
10104        // the settled geometry is genuinely stationary.
10105        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        // Every matrix's geometry is stationary, not just the focused one's.
10111        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    /// The canvas lays out at every window size without panicking, and the
10121    /// per-frame framing `debug_assert!` inside `gutter_frame` runs on every
10122    /// matrix at every size (the Step 1 discipline, extended to N matrices).
10123    #[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    /// Adding and removing matrices is canvas layout, never a model change:
10156    /// nothing becomes an undo point, and the last matrix cannot be removed.
10157    ///
10158    /// **The undo decision** (recorded here because a test is the only place it
10159    /// cannot rot): undo/redo snapshots `Model`, and canvas layout lives in the
10160    /// `View`, not the `Model` — so placing, moving, resizing or removing a
10161    /// matrix is deliberately NOT undoable. Saving the canvas as a view IS a
10162    /// model change and IS undoable, which is what makes a layout recoverable:
10163    /// save the canvas, and Ctrl+Z reaches it like any other model edit.
10164    #[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        // Moving and resizing likewise.
10177        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        // The last matrix is never removed: there would be nowhere to show a
10186        // measure, and `focused()` must always have a matrix to return.
10187        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        // Saving the canvas as a view IS a model change, so it IS undoable —
10192        // which is how a layout becomes recoverable.
10193        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    // -- NeXTSTEP spec step 3: worksheet windows on a workspace
10209
10210    /// Matrix `mi`'s window id.
10211    fn win_id(app: &ImprovApp, mi: usize) -> egui::Id {
10212        matrix_window_id(app.matrices[mi].uid)
10213    }
10214
10215    /// Matrix `mi`'s window rect, as nxwin stores it.
10216    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    /// Whole-app frames on one context at 1200x800, `events` per frame.
10231    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        /// Press at `from`, move to `to` in steps, release, settle.
10268        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    /// (a) Two matrices are two NxWindows with distinct ids, each titled
10280    /// `<matrix> - Worksheet - <file>`.
10281    #[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    /// (b) Dragging a window's title bar moves the matrix, and the move
10311    /// survives save_view -> apply_view (which in turn moves the window).
10312    #[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    /// (c) Clicking window 2 focuses matrix 1.
10345    #[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        // Its bottom-right, clear of window 0 (cascaded up-left of it).
10353        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    // -- NeXTSTEP spec step 6: ruled grid, header highlight, range selection
10359
10360    /// A one-matrix desk on Quantity (an input over Time x Product), settled.
10361    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    /// E3: the white grid field fills the worksheet's whole table area, not
10370    /// just the content-sized table (no gray right of / below the grid).
10371    #[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    /// E2: the grid's vertical scroller is always there, a 19 px TROUGH strip
10389    /// beside the data rows, inside the worksheet window (spec §4.8).
10390    #[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    /// E2: the trough color does not leak into text edits inside a scroller:
10407    /// the formula list's new-formula field is still WHITE.
10408    #[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    /// Data cell `(ri, ci)`'s rect in the last pass.
10434    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    /// Every filled (non-transparent) rect painted this frame, with its fill.
10442    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    /// Header-sized LIGHT_GRAY rects in `cell`'s column (above it) and row (left
10456    /// of it): `(column headers, row headers)`.
10457    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    /// (a) Cells are plain: the cursor cell fills LIGHT_GRAY, the others WHITE.
10480    #[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    /// (b) The cursor's row and column headers are lit; the other headers are not.
10501    #[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    /// (c) A single click moves the cursor; a double-click opens the editor.
10512    #[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    /// One key press (with `modifiers`) as a desk frame.
10527    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    /// A 1x3 Quantity grid (Product on columns, Time paged) holding 1, 2, 4.
10539    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        // Time on rows (2 lines), Product on columns (3 lines): row 0 = 2025.
10558        assert_eq!(app.matrices[0].grid_dims(&app.model), (2, 3));
10559        (app, desk)
10560    }
10561
10562    /// (d) Shift+Right twice from (0,0) selects 3 cells and Sum adds them; (e) a
10563    /// plain arrow collapses the range to one cell.
10564    #[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    /// (f) Every selected column's header is lit, and the row's header.
10589    #[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    /// Shift+click extends; a plain click collapses; a drag selects its span.
10610    #[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    /// (d) add_matrix focuses the new matrix, and its window becomes key.
10642    #[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    /// (e) With two matrices each window has a close box that removes its
10656    /// matrix; the last one has none.
10657    #[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    /// Windows are keyed by matrix, not position: closing window 0 of three
10690    /// leaves the other two showing under the ids (and at the rects) they had.
10691    #[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    /// The left third of the resize bar drags the bottom-left corner; pushed
10722    /// far right it stops at the minimum width and the right edge stays put.
10723    #[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    /// Windows stay on the workspace: a title dragged up stops at its top,
10739    /// the top of the app now that no formula bar sits above it.
10740    #[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    /// Resizing a window persists into its matrix, and never below the
10757    /// minimum the gutters need.
10758    #[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    /// Dragging a tile from the row gutter to the column gutter of a
10777    /// worksheet window re-pivots that matrix, with real pointer events.
10778    #[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        // Drop on the column zone: the top strip's right part, tile height.
10798        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    /// The browser labels each measure `input` or `formula`, to the right of
10812    /// its name.
10813    #[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    /// A model whose one measure is `rows` × `cols` items (Row × Col).
10844    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    /// E6: a fresh worksheet window is sized to its grid, within bounds.
10867    #[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    /// E1: the first worksheet window opens clear of the application menu.
10886    #[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    /// A status message is drawn, then gone once [`STATUS_TTL`] has passed.
10907    #[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    /// The screen rect of the first painted text exactly equal to `label`.
10925    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    /// A page tile's item field shows the current item, and picking another
10935    /// item from it re-slices the grid (`page_idx`).
10936    #[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    /// Gutters size to their tiles: an empty axis paints no placeholder.
10953    #[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    /// The last-frame rect of matrix 0's tile for category `c`.
10966    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    /// Tiles are the NeXT 17 px chips, and right-clicking one offers
10979    /// "Move to …" (the drag-free way to re-pivot).
10980    #[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    // -- NeXTSTEP spec step 3, second half: model browser and Info panel
11013
11014    /// Right-click matrix 0's tile for `c`, leaving its context menu open.
11015    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    /// The LAST painted text equal to `label` (a popup paints after the grid,
11028    /// so this is the menu's copy of a label the grid also shows).
11029    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    /// E4: filtering lives on the tile's menu, not a "Filters" shelf.
11039    /// Unchecking an item hides its row; "Show all" brings it back; a
11040    /// filtered tile reads `Name*`.
11041    #[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    /// The NxWindow id of the model browser (`"browser"`) or Info panel
11079    /// (`"info"`).
11080    fn panel_win(name: &str) -> egui::Id {
11081        crate::nxwin::NxWindow::new(name, "").id()
11082    }
11083
11084    /// Window `win`'s close box as laid out last frame, if it was shown.
11085    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    /// (a) With no side panels, the workspace spans the whole window width at
11090    /// 1400x900, from the top down to the bottom panels, even with the
11091    /// Info panel open.
11092    #[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    /// (b) The browser is a window titled `<file> - Model`, and clicking a
11118    /// measure in it selects that measure.
11119    #[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    /// The rect of the first painted text exactly `label`.
11152    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    /// E5 (spec §4.2): the browser lists saved views as `Name - Worksheet`
11162    /// rows; a single click selects one (shown in the description) without
11163    /// opening it, a double-click opens it.
11164    #[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    /// A view's description names its measure and categories.
11197    #[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    /// With no saved views the description reads `(no saved views)`.
11208    #[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    /// (c) Info ▸ Info Panel… opens the Info window; its close box closes it.
11218    #[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    /// (d) View ▸ Model Browser toggles the browser window.
11241    #[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    /// Removing a matrix never leaves the focus dangling: `focused()` must keep
11258    /// returning a real matrix, whichever one went away.
11259    #[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        // Remove the focused (last) one: focus falls back inside the range.
11268        app.remove_matrix(2);
11269        assert_eq!(app.focus_index(), 1);
11270        assert_eq!(app.focused().measure, app.matrices[1].measure);
11271
11272        // Remove an EARLIER one while focused on a later one.
11273        app.set_focus(1);
11274        app.remove_matrix(0);
11275        assert_eq!(app.matrix_count(), 1);
11276        assert_eq!(app.focus_index(), 0);
11277        // set_focus clamps rather than panicking on a bogus index.
11278        app.set_focus(99);
11279        assert_eq!(app.focus_index(), 0);
11280    }
11281
11282    /// The formula editor, inspector and chart all follow the FOCUSED matrix: they
11283    /// are single-measure surfaces, and on a canvas "the selected measure" can
11284    /// only mean "the one with the keyboard".
11285    #[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        // Quantity is an input measure: no chart-worthy formula, but a chart.
11295        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        // The chart followed too: different measure, different numbers.
11307        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    // -- STEP 5: chrome (title bars, selection readout, document tabs)
11315
11316    /// **Step 5:** each matrix has its own title bar naming its measure, and —
11317    /// like the reference's `Property Financials: Virginia Ave` — the slice it
11318    /// shows, i.e. its pinned page items.
11319    #[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)); // Sales[Time, Product, Region]
11323        app.sync_axis_state();
11324        // One page dim (Region) pinned to North.
11325        assert_eq!(app.matrix_title(0), "Sales: North");
11326
11327        // Page to South: the title follows the slice, which is the point of
11328        // putting it there.
11329        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        // A second matrix on the same measure, pinned differently, gets its own
11334        // title — two matrices are two slices, and the title bars say which.
11335        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        // With no page dimension there is no slice to name, so the title is just
11341        // the measure.
11342        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    /// **Step 5:** the status bar's selection-aggregate readout (the reference's
11350    /// bottom-right `Sum`), computed from the FOCUSED matrix's selection.
11351    ///
11352    /// Selection is one cell today, so the sum is that cell's number; a
11353    /// non-numeric or absent cell reads `Sum —` rather than a fabricated 0.
11354    #[test]
11355    fn the_status_bar_reads_out_the_focused_selection_sum() {
11356        let mut app = two_matrix_app();
11357        // Matrix 0 = Quantity, cursor on [2025, WidgetA] = 100.
11358        app.set_focus(0);
11359        assert_eq!(app.selection_sum(), Some(100.0));
11360        assert_eq!(app.selection_readout(), "Sum 100");
11361
11362        // Move that matrix's cursor: the readout follows it.
11363        let model = app.model.clone();
11364        app.matrices[0].move_cursor(&model, 1, 0); // [2026, WidgetA] = 120
11365        assert_eq!(app.selection_readout(), "Sum 120");
11366
11367        // Focus the OTHER matrix: the readout switches to its selection, which is
11368        // a derived cell (Revenue[2025, WidgetA] = 10 * 100 = 1000).
11369        app.set_focus(1);
11370        assert_eq!(app.selection_sum(), Some(1000.0));
11371        assert_eq!(app.selection_readout(), "Sum 1000");
11372
11373        // An empty cell has nothing to sum.
11374        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    /// A non-numeric selection reads `Sum —`: the aggregate is a SUM, and text
11383    /// does not sum. (Its cell still renders its text in the grid — see
11384    /// `typed_input_cells_render_their_values`.)
11385    #[test]
11386    fn a_non_numeric_selection_has_no_sum() {
11387        let mut app = build_app(typed_model());
11388        select(&mut app, MeasureId(2)); // Label (Text)
11389        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    /// **Step 5:** one entry per saved view (the reference's `Welcome /
11399    /// Concepts / P&L Canvas`), listed in the application menu's View submenu,
11400    /// and picking one switches CANVASES — not just a measure.
11401    #[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        // Canvas A: one matrix, Quantity.
11407        select(&mut app, MeasureId(101));
11408        app.sync_axis_state();
11409        let a = app.save_view("Canvas A").expect("saved A");
11410        // Canvas B: two matrices, the second showing Revenue pivoted.
11411        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        // Both tabs are listed, in id order, with B current (just saved).
11418        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        // Pick view A from the View menu: the canvas becomes A's — ONE matrix.
11433        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        // Pick view B: two matrices again, with the pivot B was saved with.
11446        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        // An unknown id is a no-op, not a panic or an empty canvas.
11456        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    // -- Application menu (docs/reviews/2026-10-03-nextstep-visual-spec.md §4.1)
11464
11465    #[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    /// One frame of [`ImprovApp::handle_command_keys`] with Ctrl/Cmd+`key`
11518    /// pressed, and `focus` (an egui id) holding keyboard focus if given.
11519    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    /// One frame of a Ctrl/Cmd+`key` press on `ctx` through
11528    /// `handle_command_keys`. egui flags it a repeat while `key` is still down
11529    /// from an earlier frame on the same context.
11530    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    /// Whether `out` asks the window to close.
11556    fn closes(out: &egui::FullOutput) -> bool {
11557        out.viewport_output
11558            .values()
11559            .any(|v| v.commands.contains(&egui::ViewportCommand::Close))
11560    }
11561
11562    /// An in-memory model has no store: Ctrl+S must not report "saved model".
11563    #[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    /// Quitting an edited in-memory model takes two Quits; any other command
11572    /// in between disarms the first. A clean model quits at once.
11573    #[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    /// The native title names the document; an in-memory edit adds ` •`.
11593    #[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    /// Ctrl+S saves the model to its store; a text field's focus blocks it.
11619    #[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    /// The menu opens on the workspace, clear of the first worksheet's title.
11646    #[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    /// Holding Ctrl+S saves once: the auto-repeat events that follow are
11662    /// ignored, so a later edit isn't written by them.
11663    #[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        // S is still down: egui delivers this press as an auto-repeat.
11681        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    /// The menu is drawn by a whole-app frame, over the panels, and a selected
11688    /// list row (the browser's measure) gets a white face, unlike a hovered
11689    /// one, which stays on the panel gray.
11690    #[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            // The row's own face is the tightest rect behind its text (the
11717            // panel, and the floating menu where it overlaps, are larger).
11718            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    /// A unique temp-db path for a store-backed test.
11735    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    /// Delete a temp db when the test ends, pass or panic.
11750    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    /// One whole-app frame on `ctx` with `id` focused, delivering `events`.
11758    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    /// The formula list's last line adds a measure from `Name = formula`.
11774    #[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    /// F1 opens the function help, even from a formula edit; a search
11806    /// filters it, and Insert puts `NAME(` into that edit at its caret and
11807    /// gives it the keyboard back.
11808    #[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        // The search line has focus: type into it.
11826        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        // Help ▸ Functions… closes it again.
11861        app.run_command(&ctx, Command::FunctionHelp);
11862        assert!(!app.fn_help.open);
11863    }
11864
11865    /// One frame on `ctx` with the formula editor `id` focused, delivering
11866    /// `events`; the output.
11867    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        // Only when it is not focused: a fresh request resets the edit's
11876        // key filter, as a click would, and Tab would move the focus.
11877        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    /// Typing `PM` in a formula editor offers PMT with its signature, the
11885    /// rest trails the caret as ghost text, and Tab makes it `PMT(` with
11886    /// the caret after the `(`; inside the call the tooltip marks the
11887    /// argument. Up/Down move the selection, Esc closes the list, and with
11888    /// it closed Enter commits.
11889    #[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            // In the call: the ghost of what is left and the signature.
11935            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        // Up/Down select, Esc closes and Enter then commits.
11949        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    /// A bad `Name = formula` line keeps its text, and its error is located
12039    /// in the line (past `Name = `) and explained, under it.
12040    #[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        // A compile error is placed at the measure it names; typing clears it.
12062        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    /// The selected row is the editor: Enter commits a good formula, and a
12072    /// bad one errors inline and leaves the model alone.
12073    #[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    /// No formula bar across the top: the workspace starts at the menu.
12094    #[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    // -- branch coverage: pure Matrix / app logic ---------------------------
12106
12107    #[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        // An empty saved order falls back to the measure's natural order.
12115        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); // no such page dimension
12138        assert_eq!(app.focused().page_idx, vec![0]);
12139        app.focused_mut().page_idx.clear(); // stale length is resized
12140        app.set_page(0, 1);
12141        assert_eq!(app.focused().page_idx, vec![1]);
12142
12143        hide_all_items(&mut app, CategoryId(3)); // the page has no item
12144        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        // A matrix with no categories at all has nothing to pivot or page.
12150        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        // Two items like the category, but not ITS two: still a filter.
12168        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)); // Price[Product]
12178        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(); // an input
12211        app.set_formula_enabled(MeasureId(102), true).unwrap(); // already on
12212        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        // A literal of the wrong type is no boolean.
12257        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    // -- branch coverage: UI paths ------------------------------------------
12278
12279    /// One frame delivering `key` (held with `modifiers`) to the focused
12280    /// matrix's `handle_grid_keys`.
12281    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; // a fresh double-click, not a triple
12357        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        // One #ERR cell in Revenue's snapshot.
12418        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        // egui's close button: the window layer's square, icon-sized widget
12517        // at the title bar's right end.
12518        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        // The new-formula line: Escape leaves it without adding; a line with
12588        // no `=` is refused.
12589        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)); // the row is not the editor
12665        desk.settle(&mut app);
12666        let shapes = desk.frame(&mut app, Vec::new()).shapes;
12667        // The checkbox sits left of the row's `1.` number.
12668        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)); // Price[Product]
12714        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        // A formula over a scalar literal infers no categories.
12731        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)); // Label (Text)
12739        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    /// `ImprovApp::load` opens a Postgres-hosted model (`postgres://…?model=`),
12754    /// builds its engine, and autosaves edits back. Skipped without
12755    /// `IMPROV_TEST_PG` (a libpq `key=value` DSN or a URL).
12756    #[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    /// G2 through the GUI's real save path (it reopens the store for every
12789    /// save and carries a `SaveToken`): a SQL edit to one bound cell made
12790    /// after the GUI loaded survives a GUI save of a different cell, and a
12791    /// GUI edit of a cell SQL changed meanwhile is a conflict, not a silent
12792    /// overwrite.
12793    #[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        // `Store::open` needs a URL (it dispatches on `postgres://`); the
12802        // test DSN is libpq key=value form.
12803        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        // An empty model with Time × Product, saved through the store so the
12816        // catalog rows exist; then a fact table bound to Price by SQL.
12817        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 edits Q2 after the GUI loaded; the GUI edits Q1 and saves.
12865        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 edits Q1; the GUI, still holding 11, edits Q1 too.
12881        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        // File ▸ Revert to Saved picks up the SQL edit; the retried edit saves.
12895        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}