Skip to main content

improv_tui/
app.rs

1//! App state and the pure pivot-grid logic (terminal-free, unit-tested).
2//!
3//! A measure is a tensor over N categories. We render the first two categories
4//! as the row/column axes; any remaining categories are "pages" pinned to their
5//! first item (v1: fixed, shown on a status line). Values come from the model's
6//! input cells (input measures) or from `engine::evaluate` (derived measures),
7//! both keyed by `CoordKey = Vec<(u32, u32)>`.
8
9use crate::complete;
10use crate::edit::LineEdit;
11use crossterm::event::KeyCode;
12#[cfg(test)]
13use improv_core_model::Coordinate;
14use improv_core_model::{
15    parser, CategoryId, Filter, Formula, ItemId, MeasureId, MeasureKind, Model, Name, ParseError,
16    Value, ValueType, View, ViewId,
17};
18use improv_engine::session::Engine;
19use improv_engine::{decode_coord, encode_coord, CellValue, CoordKey};
20use improv_storage_csv::{export_measure_csv, import_csv, ColumnRef, DimensionMapping, ImportSpec};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24/// Live derived-measure values, keyed by measure then coordinate. Kept in
25/// `App` and refreshed incrementally by the `Engine` on each committed edit;
26/// used to render derived measures instead of a fresh full `evaluate()`.
27pub type Snapshot = improv_engine::session::Snapshot;
28
29/// One rendered pivot grid for a single measure.
30pub struct Grid {
31    pub measure: MeasureId,
32    pub measure_name: String,
33    /// Category on the rows axis (None if the measure has 0 categories).
34    pub row_cat: Option<(CategoryId, String)>,
35    /// Category on the columns axis (None if the measure has < 2 categories).
36    pub col_cat: Option<(CategoryId, String)>,
37    /// Row header items: (id, name). Length >= 1 (a synthetic single row when
38    /// there is no row category).
39    pub rows: Vec<(ItemId, String)>,
40    /// Column header items: (id, name). Length >= 1.
41    pub cols: Vec<(ItemId, String)>,
42    /// Categories beyond the first two, pinned to a chosen item ("pages").
43    pub pages: Vec<PageDim>,
44    /// Cell values indexed `[row][col]`. `None` = empty cell. Typed
45    /// (`CellValue`) so non-numeric cells (Text/Bool/Date/#ERR) render
46    /// correctly, not just numbers.
47    pub cells: Vec<Vec<Option<CellValue>>>,
48}
49
50/// An extra dimension not on the row/column axes, pinned to one item (a
51/// "page"). `item_index`/`item_count` let the UI page through the dimension's
52/// items with `[` / `]`.
53pub struct PageDim {
54    pub cat: CategoryId,
55    pub cat_name: String,
56    pub item_name: String,
57    pub item: ItemId,
58    pub item_index: usize,
59    pub item_count: usize,
60}
61
62/// Geometry of the last-rendered grid, recorded by `ui::render` so mouse
63/// clicks (screen col/row) can be mapped back to a grid cell.
64#[derive(Clone, Copy, Debug)]
65pub struct GridGeom {
66    /// Screen coords of the top-left DATA cell (row 0, col 0).
67    pub x0: u16,
68    pub y0: u16,
69    /// Width of each data column and the inter-column spacing.
70    pub col_w: u16,
71    pub spacing: u16,
72    /// First visible data row/column (the scroll offset).
73    pub row0: usize,
74    pub col0: usize,
75    /// How many data rows/columns the window shows.
76    pub rows: usize,
77    pub cols: usize,
78}
79
80impl Grid {
81    pub fn n_rows(&self) -> usize {
82        self.rows.len()
83    }
84    pub fn n_cols(&self) -> usize {
85        self.cols.len()
86    }
87    /// The numeric value at `[row][col]`, if the cell holds a number (for the
88    /// edit-seed path). Non-numeric cells return `None`.
89    pub fn value_at(&self, row: usize, col: usize) -> Option<f64> {
90        self.cell_at(row, col).and_then(|v| v.as_num())
91    }
92    /// The typed cell value at `[row][col]`.
93    pub fn cell_at(&self, row: usize, col: usize) -> Option<&CellValue> {
94        self.cells
95            .get(row)
96            .and_then(|r| r.get(col))
97            .and_then(|c| c.as_ref())
98    }
99    /// The display string for `[row][col]` (empty for an empty cell).
100    pub fn display_at(&self, row: usize, col: usize) -> String {
101        match self.cell_at(row, col) {
102            Some(v) => v.to_string(),
103            None => String::new(),
104        }
105    }
106    /// The sorted `CoordKey` for the cell at `[row][col]`.
107    pub fn coord_key(&self, row: usize, col: usize) -> CoordKey {
108        cell_key(
109            &self.row_cat,
110            &self.col_cat,
111            &self.rows,
112            &self.cols,
113            &self.pages,
114            row,
115            col,
116        )
117    }
118}
119
120/// Item names for a category, in the category's declared item order, keeping
121/// only items that pass `filters` (a category absent from `filters` is
122/// unfiltered). Presentation-only: it hides items, it never touches data.
123fn items_of(model: &Model, cat: CategoryId, filters: &[Filter]) -> Vec<(ItemId, String)> {
124    let keep = |id: ItemId| match filters.iter().find(|f| f.category == cat) {
125        Some(f) => f.items.contains(&id),
126        None => true,
127    };
128    model
129        .categories
130        .get(&cat)
131        .map(|c| {
132            c.items
133                .iter()
134                .filter(|id| keep(**id))
135                .map(|id| {
136                    let name = model
137                        .items
138                        .get(id)
139                        .map(|it| it.name.0.clone())
140                        .unwrap_or_else(|| id.0.to_string());
141                    (*id, name)
142                })
143                .collect()
144        })
145        .unwrap_or_default()
146}
147
148/// The value map for a measure: `CoordKey -> CellValue`.
149///
150/// Input measures read straight from `model.inputs`; derived measures come from
151/// the live engine `snapshot` (empty map -> grid renders blank). Values keep
152/// their type so the grid can render numbers, text, booleans, dates, and #ERR.
153fn values_for(
154    model: &Model,
155    measure: MeasureId,
156    snapshot: &Snapshot,
157) -> HashMap<CoordKey, CellValue> {
158    let is_derived = model.measures.get(&measure).map(|m| m.is_derived());
159    match is_derived {
160        // Derived: read the live engine snapshot (already typed CellValues).
161        Some(true) => snapshot
162            .get(&measure)
163            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
164            .unwrap_or_default(),
165        // Input: read model cells, mapping each typed model Value to a CellValue.
166        _ => model
167            .inputs
168            .iter()
169            .filter(|((mid, _), _)| *mid == measure)
170            .filter_map(|((_, coord), v)| {
171                CellValue::from_model_value(v).map(|cv| (encode_coord(coord), cv))
172            })
173            .collect(),
174    }
175}
176
177/// Build the pivot grid for `measure`. Uses the measure's first two categories
178/// as the row/column axes and pins the rest to their first item.
179pub fn build_grid(model: &Model, measure: MeasureId, snapshot: &Snapshot) -> Grid {
180    build_grid_paged(model, measure, snapshot, &[])
181}
182
183/// Like [`build_grid`] but pins each extra (page) dimension to the item at the
184/// given index (clamped). `page_idx[i]` selects the item for the i-th extra
185/// dimension (categories beyond the first two); a missing/short entry defaults
186/// to 0. Enables paging through a 3+ dimensional measure.
187pub fn build_grid_paged(
188    model: &Model,
189    measure: MeasureId,
190    snapshot: &Snapshot,
191    page_idx: &[usize],
192) -> Grid {
193    build_grid_pivoted(model, measure, snapshot, page_idx, None, &[])
194}
195
196/// The most general grid builder. `axis_order`, when `Some`, is a permutation
197/// of the measure's categories that sets which category is on rows (first),
198/// columns (second), and pages (rest) — this is how the UI *pivots* without
199/// touching formulas. `None` uses the measure's natural category order.
200/// `filters` restricts which items of a category are shown (presentation only;
201/// unlisted categories show all items).
202pub fn build_grid_pivoted(
203    model: &Model,
204    measure: MeasureId,
205    snapshot: &Snapshot,
206    page_idx: &[usize],
207    axis_order: Option<&[CategoryId]>,
208    filters: &[Filter],
209) -> Grid {
210    let m = model.measures.get(&measure);
211    let measure_name = m
212        .map(|m| m.name.0.clone())
213        .unwrap_or_else(|| measure.0.to_string());
214    let natural: Vec<CategoryId> = m.map(|m| m.categories.clone()).unwrap_or_default();
215    // Use the requested axis order if it is a valid permutation of the measure's
216    // categories; otherwise fall back to natural order (defensive).
217    let cats: Vec<CategoryId> = match axis_order {
218        Some(order)
219            if order.len() == natural.len() && natural.iter().all(|c| order.contains(c)) =>
220        {
221            order.to_vec()
222        }
223        _ => natural,
224    };
225
226    let cat_name = |c: CategoryId| {
227        model
228            .categories
229            .get(&c)
230            .map(|x| x.name.0.clone())
231            .unwrap_or_else(|| c.0.to_string())
232    };
233
234    let row_cat = cats.first().map(|c| (*c, cat_name(*c)));
235    let col_cat = cats.get(1).map(|c| (*c, cat_name(*c)));
236
237    // Row / column header items (synthetic single entry when the axis is absent).
238    let rows = match row_cat {
239        Some((c, _)) => items_of(model, c, filters),
240        None => vec![(ItemId(0), String::new())],
241    };
242    let cols = match col_cat {
243        Some((c, _)) => items_of(model, c, filters),
244        None => vec![(ItemId(0), String::new())],
245    };
246
247    // Extra dims pinned to the selected page item (default first).
248    let mut pages = Vec::new();
249    for c in cats.iter().skip(2) {
250        let its = items_of(model, *c, filters);
251        if its.is_empty() {
252            continue;
253        }
254        // Indexed by rendered page (empty dims are skipped), matching how
255        // `App` fills `page_idx` from `grid.pages`.
256        let sel = page_idx
257            .get(pages.len())
258            .copied()
259            .unwrap_or(0)
260            .min(its.len() - 1);
261        let (id, name) = its[sel].clone();
262        pages.push(PageDim {
263            cat: *c,
264            cat_name: cat_name(*c),
265            item_name: name,
266            item: id,
267            item_index: sel,
268            item_count: its.len(),
269        });
270    }
271
272    let values = values_for(model, measure, snapshot);
273
274    // Fill cells by looking up each cell's coordinate key.
275    let mut cells = Vec::with_capacity(rows.len());
276    for r in 0..rows.len() {
277        let mut row_cells = Vec::with_capacity(cols.len());
278        for c in 0..cols.len() {
279            let key = cell_key(&row_cat, &col_cat, &rows, &cols, &pages, r, c);
280            row_cells.push(values.get(&key).cloned());
281        }
282        cells.push(row_cells);
283    }
284
285    Grid {
286        measure,
287        measure_name,
288        row_cat,
289        col_cat,
290        rows,
291        cols,
292        pages,
293        cells,
294    }
295}
296
297/// The sorted `CoordKey` for the cell at `[row][col]` given the axes/pages.
298/// Shared by grid fill and cursor->coordinate mapping so they can't drift.
299#[allow(clippy::too_many_arguments)]
300fn cell_key(
301    row_cat: &Option<(CategoryId, String)>,
302    col_cat: &Option<(CategoryId, String)>,
303    rows: &[(ItemId, String)],
304    cols: &[(ItemId, String)],
305    pages: &[PageDim],
306    row: usize,
307    col: usize,
308) -> CoordKey {
309    let mut key: CoordKey = Vec::new();
310    if let (Some((c, _)), Some((item, _))) = (row_cat, rows.get(row)) {
311        key.push((c.0, item.0));
312    }
313    if let (Some((c, _)), Some((item, _))) = (col_cat, cols.get(col)) {
314        key.push((c.0, item.0));
315    }
316    for p in pages {
317        key.push((p.cat.0, p.item.0));
318    }
319    key.sort();
320    key
321}
322
323/// Status shown when the cursor is not on a real cell (empty/filtered axis).
324const NO_CELL: &str = "no cell here (an axis is empty or fully filtered)";
325
326/// Which CSV command is active in the command-prompt (`self.command`).
327/// Mirrors the cell-edit buffer (`self.edit`) but for a full command line
328/// instead of a single value, and covers the two storage_csv entry points.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum CommandKind {
331    ImportCsv,
332    ExportCsv,
333}
334
335/// Parse an `import-csv>` prompt line into an [`ImportSpec`]. Mirrors the
336/// CLI's `import-csv <db> <file.csv> <measure-id> <measure-name> <value-col>
337/// <dim-col:cat-id:cat-name> [...] [--tsv] [--no-header]` syntax, minus the
338/// leading `<db>` (the TUI's already-open model plays that role). Whitespace-
339/// separated, like the CLI's own argv; no quoting support (a filename with a
340/// space isn't representable, matching the CLI's own simplicity).
341///
342/// Terminal-free -> unit-testable without a running TUI.
343pub fn parse_import_line(line: &str) -> Result<ImportSpec, String> {
344    let has_header = !line.split_whitespace().any(|t| t == "--no-header");
345    let tsv_flag = line.split_whitespace().any(|t| t == "--tsv");
346    let positional: Vec<&str> = line
347        .split_whitespace()
348        .filter(|t| *t != "--tsv" && *t != "--no-header")
349        .collect();
350    if positional.len() < 5 {
351        return Err("usage: <file.csv> <measure-id> <measure-name> <value-col> \
352             <dim-col:cat-id:cat-name> [...] [--tsv] [--no-header]"
353            .to_string());
354    }
355    let path = positional[0];
356    let measure_id = positional[1]
357        .parse::<u32>()
358        .map_err(|_| format!("<measure-id> must be a number, got '{}'", positional[1]))?;
359    let measure_name = positional[2].to_string();
360    let value_col = positional[3];
361
362    let tsv = tsv_flag || path.ends_with(".tsv");
363    let delimiter = if tsv { b'\t' } else { b',' };
364    let col_ref = |s: &str| -> ColumnRef {
365        if has_header {
366            ColumnRef::Name(s.to_string())
367        } else {
368            s.parse::<usize>()
369                .map(ColumnRef::Index)
370                .unwrap_or_else(|_| ColumnRef::Name(s.to_string()))
371        }
372    };
373
374    let mut dimensions = Vec::new();
375    for spec in &positional[4..] {
376        let parts: Vec<&str> = spec.splitn(3, ':').collect();
377        if parts.len() != 3 {
378            return Err(format!(
379                "dimension spec '{spec}' must be <col>:<cat-id>:<cat-name>"
380            ));
381        }
382        let cat_id = parts[1]
383            .parse::<u32>()
384            .map_err(|_| format!("<cat-id> must be a number, got '{}'", parts[1]))?;
385        dimensions.push(DimensionMapping {
386            column: col_ref(parts[0]),
387            category_id: CategoryId(cat_id),
388            category_name: parts[2].to_string(),
389        });
390    }
391
392    Ok(ImportSpec {
393        path: PathBuf::from(path),
394        delimiter,
395        has_header,
396        measure_id: MeasureId(measure_id),
397        measure_name,
398        value_type: ValueType::Number,
399        value_column: col_ref(value_col),
400        dimensions,
401        item_id_base: 1_000_000, // above hand-assigned ids; avoids collisions
402        max_records: None,
403    })
404}
405
406/// Parse an `export-csv>` prompt line into `(target-path, measure-id,
407/// delimiter)`. Mirrors the CLI's `export-csv <db> <target.csv>
408/// <measure-id> [--tsv]`, minus `<db>`.
409pub fn parse_export_line(line: &str) -> Result<(String, MeasureId, u8), String> {
410    let tsv_flag = line.split_whitespace().any(|t| t == "--tsv");
411    let positional: Vec<&str> = line.split_whitespace().filter(|t| *t != "--tsv").collect();
412    if positional.len() < 2 {
413        return Err("usage: <target.csv> <measure-id> [--tsv]".to_string());
414    }
415    let target = positional[0].to_string();
416    let measure_id = positional[1]
417        .parse::<u32>()
418        .map_err(|_| format!("<measure-id> must be a number, got '{}'", positional[1]))?;
419    let tsv = tsv_flag || target.ends_with(".tsv");
420    let delimiter = if tsv { b'\t' } else { b',' };
421    Ok((target, MeasureId(measure_id), delimiter))
422}
423
424/// Parse an edit buffer as a `vt`-typed value (mirrors the CLI's `set`).
425pub fn parse_typed(text: &str, vt: ValueType) -> Result<Value, String> {
426    match vt {
427        ValueType::Number => text
428            .parse()
429            .map(Value::Number)
430            .map_err(|_| format!("not a number: '{text}'")),
431        ValueType::Boolean => match text {
432            "true" => Ok(Value::Boolean(true)),
433            "false" => Ok(Value::Boolean(false)),
434            _ => Err(format!("not true|false: '{text}'")),
435        },
436        ValueType::Text => Ok(Value::Text(text.to_string())),
437        other => Err(format!("cannot edit a {other:?} cell here")),
438    }
439}
440
441/// Parse formula text as the symbolic DSL (`Price * Quantity`), else as
442/// controlled English (`Price times Quantity`, what the status line shows).
443/// If both fail, the DSL's error: it carries the position.
444pub fn parse_formula_text(model: &Model, text: &str) -> Result<Formula, ParseError> {
445    parser::parse_expr(model, text).or_else(|e| {
446        improv_nl_formula::parse_nl_formula(&improv_nl_formula::NlContext::new(model), text)
447            .map_err(|_| e)
448    })
449}
450
451/// Measures sorted for stable cycling: derived first, then by id.
452pub fn measure_order(model: &Model) -> Vec<MeasureId> {
453    let mut ids: Vec<MeasureId> = model.measures.keys().copied().collect();
454    ids.sort_by_key(|id| {
455        let derived = model
456            .measures
457            .get(id)
458            .map(|m| m.is_derived())
459            .unwrap_or(false);
460        (!derived, id.0) // derived (false sorts first) then ascending id
461    });
462    ids
463}
464
465/// The whole application state.
466///
467/// The live `Engine` tracks **all** derived measures in the model up front
468/// (built once in `new`), so switching the viewed measure never rebuilds it and
469/// there is no per-measure rebuild logic. Editing an input cell pushes a delta
470/// through the engine and the returned snapshot is kept here to render derived
471/// measures. A model with zero derived measures gets no engine (`None`); input
472/// edits still work, there is just nothing to recompute.
473pub struct App {
474    pub model: Model,
475    pub measures: Vec<MeasureId>,
476    pub selected: usize, // index into `measures`
477    pub grid: Grid,
478    pub cursor_row: usize,
479    pub cursor_col: usize,
480    pub should_quit: bool,
481    /// Live incremental engine over all derived measures (None if there are none).
482    pub engine: Option<Engine>,
483    /// Latest derived-measure values from the engine.
484    pub snapshot: Snapshot,
485    /// The last saved view applied via `cycle_view`, so cycling advances.
486    pub applied_view: Option<ViewId>,
487    /// When `Some`, edit mode is active and this holds the in-progress text:
488    /// a cell value for an input measure, the formula for a derived one.
489    pub edit: Option<LineEdit>,
490    /// Transient status/error message (e.g. "derived cells are computed").
491    pub status: Option<String>,
492    /// The store high-water the model was loaded/last saved at; saves refuse
493    /// to clobber another process's writes (`StoreError::Conflict`).
494    pub save_token: improv_store::SaveToken,
495    /// Selected item index for each extra (page) dimension of the viewed
496    /// measure; `[` / `]` cycle the first one. Reset when the measure changes.
497    pub page_idx: Vec<usize>,
498    /// Current axis order (a permutation of the viewed measure's categories):
499    /// element 0 is on rows, 1 on columns, the rest are pages. Pivoting mutates
500    /// this; it resets to the measure's natural order on measure switch.
501    pub axis_order: Vec<CategoryId>,
502    /// Active per-category display filters for the current layout (which items
503    /// of a category are shown). Presentation only — never changes model data.
504    /// Captured when saving a view; reset on measure switch.
505    pub filters: Vec<Filter>,
506    /// Geometry of the last render, for mapping mouse clicks to cells.
507    pub grid_geom: Option<GridGeom>,
508    /// Fixed corner of a Shift+arrow range selection; `None` = one cell.
509    pub anchor: Option<(usize, usize)>,
510    /// When `Some`, the CSV import/export command prompt is active: which
511    /// command and its in-progress typed line. Mirrors `edit`'s shape but for
512    /// a whole command line rather than a single cell value.
513    pub command: Option<(CommandKind, String)>,
514    /// When `Some`, the function help overlay (`?` / F1) is open.
515    pub help: Option<crate::fnhelp::FnHelp>,
516    /// The candidates a repeated Tab cycles through; reset by any other key.
517    pub tab_cycle: Option<complete::Cycle>,
518    /// Where the last formula commit failed to parse, while its text is
519    /// unchanged: the edit line underlines the span and shows the message.
520    pub diagnostic: Option<crate::diagnose::Diagnostic>,
521}
522
523impl App {
524    pub fn new(model: Model) -> Result<App, String> {
525        let measures = measure_order(&model);
526        if measures.is_empty() {
527            return Err("model has no measures to display".into());
528        }
529
530        // Track every derived measure in one engine, built once.
531        let derived: Vec<MeasureId> = measures
532            .iter()
533            .copied()
534            .filter(|id| {
535                model
536                    .measures
537                    .get(id)
538                    .map(|m| m.is_derived())
539                    .unwrap_or(false)
540            })
541            .collect();
542        let (engine, snapshot) = if derived.is_empty() {
543            (None, Snapshot::default())
544        } else {
545            let (e, s) = Engine::new(&model, &derived).map_err(|e| e.to_string())?;
546            (Some(e), s)
547        };
548
549        let selected = 0;
550        let natural_axes = |mid: MeasureId| -> Vec<CategoryId> {
551            model
552                .measures
553                .get(&mid)
554                .map(|m| m.categories.clone())
555                .unwrap_or_default()
556        };
557        let axis_order = natural_axes(measures[selected]);
558        let grid = build_grid(&model, measures[selected], &snapshot);
559        let page_idx = vec![0; grid.pages.len()];
560        Ok(App {
561            model,
562            measures,
563            selected,
564            grid,
565            cursor_row: 0,
566            cursor_col: 0,
567            should_quit: false,
568            engine,
569            snapshot,
570            applied_view: None,
571            edit: None,
572            status: None,
573            save_token: improv_store::SaveToken::default(),
574            page_idx,
575            axis_order,
576            filters: Vec::new(),
577            grid_geom: None,
578            anchor: None,
579            command: None,
580            help: None,
581            tab_cycle: None,
582            diagnostic: None,
583        })
584    }
585
586    fn reselect(&mut self) {
587        self.grid = build_grid_pivoted(
588            &self.model,
589            self.measures[self.selected],
590            &self.snapshot,
591            &self.page_idx,
592            Some(&self.axis_order),
593            &self.filters,
594        );
595        self.cursor_row = self.cursor_row.min(self.grid.n_rows().saturating_sub(1));
596        self.cursor_col = self.cursor_col.min(self.grid.n_cols().saturating_sub(1));
597        self.anchor = None;
598    }
599
600    /// The viewed measure's categories in natural (declared) order.
601    fn natural_axis_order(&self) -> Vec<CategoryId> {
602        self.model
603            .measures
604            .get(&self.measures[self.selected])
605            .map(|m| m.categories.clone())
606            .unwrap_or_default()
607    }
608
609    /// Pivot: rotate the axis order left, so rows->pages, cols->rows, first
610    /// page->cols. A single discoverable key repeatedly cycles which category
611    /// sits on each axis — the Improv/Quantrix "move a category to another
612    /// axis" gesture, without touching formulas. No-op for < 2 categories.
613    pub fn pivot(&mut self) {
614        if self.axis_order.len() < 2 {
615            self.status = Some("nothing to pivot (measure has < 2 dimensions)".into());
616            return;
617        }
618        self.axis_order.rotate_left(1);
619        // Axis roles changed; reset page selection to first items.
620        self.page_idx.clear();
621        self.reselect();
622        self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
623        let names: Vec<String> = self
624            .axis_order
625            .iter()
626            .map(|c| {
627                self.model
628                    .categories
629                    .get(c)
630                    .map(|x| x.name.0.clone())
631                    .unwrap_or_else(|| c.0.to_string())
632            })
633            .collect();
634        self.status = Some(format!(
635            "pivot: rows={} cols={}",
636            names[0],
637            names.get(1).cloned().unwrap_or_default()
638        ));
639    }
640
641    /// Cycle the first page (extra) dimension's selected item by `delta`
642    /// (wrapping). No-op if the viewed measure has no extra dimensions.
643    pub fn page(&mut self, delta: isize) {
644        let Some(pd) = self.grid.pages.first() else {
645            self.status = Some("no extra dimensions to page".into());
646            return;
647        };
648        // Resync from the grid (an empty page dim is skipped, so indices
649        // follow the rendered pages), then step the first one.
650        let next = (pd.item_index as isize + delta).rem_euclid(pd.item_count as isize) as usize;
651        self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
652        self.page_idx[0] = next;
653        self.reselect();
654    }
655
656    /// Cycle to the next measure (wraps).
657    pub fn next_measure(&mut self) {
658        self.selected = (self.selected + 1) % self.measures.len();
659        // A new measure has its own dimensions; reset axis order, paging, filters.
660        self.axis_order = self.natural_axis_order();
661        self.page_idx.clear();
662        self.filters.clear();
663        self.reselect();
664        // Size page_idx to the new grid so `[`/`]` work immediately.
665        self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
666    }
667
668    /// An arrow step: `extend` (Shift held) keeps or sets the range anchor
669    /// at the cursor before moving; a plain step collapses the range.
670    pub fn step(&mut self, drow: isize, dcol: isize, extend: bool) {
671        self.anchor = extend.then(|| self.anchor.unwrap_or((self.cursor_row, self.cursor_col)));
672        self.move_cursor(drow, dcol);
673    }
674
675    /// True if `[row][col]` is inside the selection (anchor..=cursor).
676    pub fn is_selected(&self, row: usize, col: usize) -> bool {
677        let (ar, ac) = self.anchor.unwrap_or((self.cursor_row, self.cursor_col));
678        let (cr, cc) = (self.cursor_row, self.cursor_col);
679        (ar.min(cr)..=ar.max(cr)).contains(&row) && (ac.min(cc)..=ac.max(cc)).contains(&col)
680    }
681
682    /// Sum of the numeric cells in the selection; `None` if there are none
683    /// (text/empty cells contribute nothing, as in the GUI's `Sum` readout).
684    pub fn selection_sum(&self) -> Option<f64> {
685        let g = &self.grid;
686        let nums: Vec<f64> = (0..g.n_rows())
687            .flat_map(|r| (0..g.n_cols()).map(move |c| (r, c)))
688            .filter(|&(r, c)| self.is_selected(r, c))
689            .filter_map(|(r, c)| g.value_at(r, c))
690            .collect();
691        (!nums.is_empty()).then(|| nums.iter().sum())
692    }
693
694    /// The viewed derived measure's formula as controlled English
695    /// (`Price times Quantity`); `None` for input measures.
696    pub fn formula_text(&self) -> Option<String> {
697        match &self.model.measures.get(&self.grid.measure)?.kind {
698            improv_core_model::MeasureKind::Derived(f) => {
699                Some(improv_nl_formula::describe_formula(
700                    &improv_nl_formula::NlContext::new(&self.model),
701                    f,
702                ))
703            }
704            _ => None,
705        }
706    }
707
708    /// Cursor movement, clamped to the grid (never goes out of range).
709    pub fn move_cursor(&mut self, drow: isize, dcol: isize) {
710        let max_row = self.grid.n_rows().saturating_sub(1) as isize;
711        let max_col = self.grid.n_cols().saturating_sub(1) as isize;
712        self.cursor_row = (self.cursor_row as isize + drow).clamp(0, max_row) as usize;
713        self.cursor_col = (self.cursor_col as isize + dcol).clamp(0, max_col) as usize;
714    }
715
716    /// Handle a left-click at screen `(col, row)`: if it lands on a data cell,
717    /// move the cursor there. Uses the geometry recorded by the last render.
718    /// Out-of-grid clicks are ignored. (Enables mouse-driven navigation; the
719    /// terminal must support mouse reporting, which `improv-tui` enables.)
720    pub fn click_at(&mut self, col: u16, row: u16) {
721        let Some(g) = self.grid_geom else { return };
722        if row < g.y0 || col < g.x0 {
723            return;
724        }
725        // Which window row: each grid row is one terminal line from y0.
726        let r = (row - g.y0) as usize;
727        // Which window column: columns are col_w + spacing wide, from x0.
728        let stride = (g.col_w + g.spacing) as usize;
729        let c = ((col - g.x0) as usize) / stride.max(1);
730        let (r, c) = (g.row0 + r, g.col0 + c);
731        let in_window = r < g.row0 + g.rows && c < g.col0 + g.cols;
732        if in_window && r < self.grid.n_rows() && c < self.grid.n_cols() {
733            self.anchor = None;
734            self.cursor_row = r;
735            self.cursor_col = c;
736        }
737    }
738
739    /// True if `[row][col]` names a full coordinate: one item for every
740    /// category of the measure. False when an axis or page is empty (a
741    /// category with no items, or every item filtered out); writing there
742    /// would store a cell with a missing dimension.
743    fn addressable(&self, row: usize, col: usize) -> bool {
744        let dims = self
745            .model
746            .measures
747            .get(&self.grid.measure)
748            .map_or(0, |m| m.categories.len());
749        self.grid.coord_key(row, col).len() == dims
750    }
751
752    /// True if the currently viewed measure is an input measure (editable).
753    pub fn viewed_is_input(&self) -> bool {
754        self.model
755            .measures
756            .get(&self.grid.measure)
757            .map(|m| m.is_input())
758            .unwrap_or(false)
759    }
760
761    /// True while the edit line holds a formula (the viewed measure is derived).
762    pub fn editing_formula(&self) -> bool {
763        self.edit.is_some() && !self.viewed_is_input()
764    }
765
766    /// Enter edit mode: on the cell under the cursor for an input measure, on
767    /// the formula (seeded with its controlled-English text) for a derived one.
768    pub fn begin_edit(&mut self) {
769        if !self.viewed_is_input() {
770            self.edit = Some(self.formula_text().unwrap_or_default().into());
771            self.status = None;
772            return;
773        }
774        if !self.addressable(self.cursor_row, self.cursor_col) {
775            self.status = Some(NO_CELL.into());
776            return;
777        }
778        // Seed the buffer with the current value so editing is a tweak, not a
779        // retype. Blank cell -> empty buffer.
780        let seed = match self.grid.value_at(self.cursor_row, self.cursor_col) {
781            Some(n) if n.fract() == 0.0 => format!("{n:.0}"),
782            Some(n) => format!("{n}"),
783            // Text/boolean cells seed with their display text.
784            None => self.grid.display_at(self.cursor_row, self.cursor_col),
785        };
786        self.edit = Some(seed.into());
787        self.status = None;
788    }
789
790    /// A key in the edit line. In a formula, Tab completes the name before
791    /// the caret ([`complete::tab`]); any other key edits, and a change
792    /// clears the message about the old text.
793    pub fn edit_key(&mut self, code: KeyCode) {
794        let formula = !self.viewed_is_input();
795        let Some(e) = &mut self.edit else { return };
796        if code == KeyCode::Tab {
797            if formula {
798                let before = e.text.clone();
799                self.status = complete::tab(&self.model, e, &mut self.tab_cycle);
800                if e.text != before {
801                    self.diagnostic = None;
802                }
803            }
804            return;
805        }
806        self.tab_cycle = None;
807        if e.key(code) {
808            self.status = None;
809            self.diagnostic = None;
810        }
811    }
812
813    /// Cancel edit mode without committing.
814    pub fn cancel_edit(&mut self) {
815        self.edit = None;
816        self.tab_cycle = None;
817        self.diagnostic = None;
818        self.status = None;
819    }
820
821    /// Commit the in-progress edit buffer. Parses it per the measure's value
822    /// type (or as a formula for a derived measure), applies it to the model +
823    /// engine, refreshes the grid, and exits edit mode. On a parse error the
824    /// buffer stays open with a status message.
825    pub fn commit_edit(&mut self) {
826        let Some(buf) = self.edit.as_ref().map(|e| e.text.clone()) else {
827            return;
828        };
829        if !self.viewed_is_input() {
830            self.diagnostic = None;
831            match parse_formula_text(&self.model, &buf) {
832                Err(e) => {
833                    self.status = None;
834                    self.diagnostic = Some(crate::diagnose::diagnose(&self.model, &buf, &e));
835                }
836                Ok(f) => match self.set_formula(f) {
837                    Ok(()) => {
838                        self.edit = None;
839                        self.tab_cycle = None;
840                        self.status = None;
841                    }
842                    Err(e) => self.status = Some(e),
843                },
844            }
845            return;
846        }
847        let vt = self
848            .model
849            .measures
850            .get(&self.grid.measure)
851            .map_or(ValueType::Number, |m| m.value_type);
852        let value = match parse_typed(buf.trim(), vt) {
853            Ok(v) => v,
854            Err(e) => {
855                self.status = Some(e);
856                return;
857            }
858        };
859        match self.apply_value(self.cursor_row, self.cursor_col, value) {
860            Ok(()) => {
861                self.edit = None;
862                self.status = None;
863            }
864            Err(e) => self.status = Some(e),
865        }
866    }
867
868    /// Pure edit-apply: set input `[row][col]` to `value`, updating both the
869    /// in-memory model and the live engine, then rebuild the grid from the new
870    /// snapshot. Returns `Err` (no state change committed) if the viewed
871    /// measure is derived. Terminal-free -> unit-testable.
872    #[cfg(test)]
873    pub fn apply_edit(&mut self, row: usize, col: usize, value: f64) -> Result<(), String> {
874        self.apply_value(row, col, Value::Number(value))
875    }
876
877    /// Set input `[row][col]` to a typed `value`, pushed through the live
878    /// engine as one incremental edit. A value of the measure's type enters
879    /// the engine as that cell; anything else clears it, exactly what a
880    /// rebuild would seed ([`improv_engine::session::input_cell`]).
881    pub fn apply_value(&mut self, row: usize, col: usize, value: Value) -> Result<(), String> {
882        if !self.viewed_is_input() {
883            return Err("derived cells are computed, not editable".into());
884        }
885        let measure = self.grid.measure;
886        if !self.addressable(row, col) {
887            return Err(NO_CELL.into());
888        }
889        let key = self.grid.coord_key(row, col);
890        let coord = decode_coord(&key);
891
892        let cell = self
893            .model
894            .measures
895            .get(&measure)
896            .and_then(|m| improv_engine::session::input_cell(m.value_type, &value));
897        self.model.set_input(measure, coord, value);
898
899        // Push the delta through the engine (if any derived measures exist) and
900        // keep the recomputed snapshot for rendering.
901        if let Some(engine) = &mut self.engine {
902            self.snapshot = engine
903                .apply(vec![improv_engine::session::Edit {
904                    measure,
905                    coord: key,
906                    value: cell,
907                }])
908                .map_err(|e| e.to_string())?;
909        }
910        self.reselect();
911        Ok(())
912    }
913
914    /// Replace the viewed derived measure's formula. Validated on a candidate
915    /// model (a real engine build, which catches cycles and type errors) and
916    /// only then published; on failure nothing changes and the error is
917    /// returned.
918    pub fn set_formula(&mut self, formula: Formula) -> Result<(), String> {
919        let mut candidate = self.model.clone();
920        let m = candidate
921            .measures
922            .get_mut(&self.grid.measure)
923            .ok_or("the measure no longer exists")?;
924        m.kind = MeasureKind::Derived(formula);
925        let derived: Vec<MeasureId> = self
926            .measures
927            .iter()
928            .copied()
929            .filter(|id| candidate.measures.get(id).is_some_and(|m| m.is_derived()))
930            .collect();
931        let (engine, snapshot) = Engine::new(&candidate, &derived).map_err(|e| e.to_string())?;
932        self.model = candidate;
933        self.engine = Some(engine);
934        self.snapshot = snapshot;
935        self.reselect();
936        Ok(())
937    }
938
939    // -- CSV import/export command prompt (pure; terminal-free -> unit-testable) --
940
941    /// Enter import-csv command-prompt mode (bound to `I`). Mirrors
942    /// `begin_edit`: starts an empty typed-line buffer.
943    pub fn begin_import_csv(&mut self) {
944        self.command = Some((CommandKind::ImportCsv, String::new()));
945        self.status = None;
946    }
947
948    /// Enter export-csv command-prompt mode (bound to `E`).
949    pub fn begin_export_csv(&mut self) {
950        self.command = Some((CommandKind::ExportCsv, String::new()));
951        self.status = None;
952    }
953
954    /// Cancel command-prompt mode without side effects.
955    pub fn cancel_command(&mut self) {
956        self.command = None;
957        self.status = None;
958    }
959
960    /// Commit the in-progress command line: parse it per the active
961    /// `CommandKind`, run the storage_csv import/export, and set a status
962    /// message (success with a count, or the parse/IO error). Always exits
963    /// command mode — a malformed line reports the error rather than looping
964    /// (matching the TUI's existing "generated view name" simplicity).
965    pub fn commit_command(&mut self) {
966        let Some((kind, line)) = self.command.take() else {
967            return;
968        };
969        self.status = Some(match kind {
970            CommandKind::ImportCsv => self.run_import_csv(&line),
971            CommandKind::ExportCsv => self.run_export_csv(&line),
972        });
973    }
974
975    /// Parse and run an import-csv command line against `self.model`, and
976    /// rebuild the engine/grid so the new measure shows up immediately
977    /// (mirrors `next_measure`'s structural-rebuild path, since importing
978    /// adds a new measure — the engine's fixed structure must be rebuilt).
979    fn run_import_csv(&mut self, line: &str) -> String {
980        let spec = match parse_import_line(line) {
981            Ok(s) => s,
982            Err(e) => return format!("import-csv: {e}"),
983        };
984        let measure_name = spec.measure_name.clone();
985        match import_csv(&mut self.model, &spec) {
986            Ok(n) => {
987                let msg = format!("imported {n} cell(s) into measure '{measure_name}'");
988                match self.rebuild_after_structural_change() {
989                    Ok(()) => msg,
990                    Err(e) => format!("{msg}; formulas not recomputed: {e}"),
991                }
992            }
993            Err(e) => format!("import-csv: {e}"),
994        }
995    }
996
997    /// Parse and run an export-csv command line against `self.model`.
998    fn run_export_csv(&mut self, line: &str) -> String {
999        let (target, measure_id, delimiter) = match parse_export_line(line) {
1000            Ok(t) => t,
1001            Err(e) => return format!("export-csv: {e}"),
1002        };
1003        match export_measure_csv(&self.model, measure_id, Path::new(&target), delimiter) {
1004            Ok(n) => format!(
1005                "exported {n} row(s) from measure {} to {target}",
1006                measure_id.0
1007            ),
1008            Err(e) => format!("export-csv: {e}"),
1009        }
1010    }
1011
1012    /// Rebuild `measures`/`engine`/`grid` from the current `self.model` after a
1013    /// structural change (a new measure/category/item), keeping the viewed
1014    /// measure selected when it still exists. Mirrors the fresh-build logic in
1015    /// `App::new`, since the live `Engine`'s tracked structure is fixed at
1016    /// construction and a new measure needs a new engine. If the formulas no
1017    /// longer build (an import changed a measure they use), the rest is still
1018    /// rebuilt, with no engine, and the error is returned for the status.
1019    fn rebuild_after_structural_change(&mut self) -> Result<(), String> {
1020        let viewed = self.measures.get(self.selected).copied();
1021        self.measures = measure_order(&self.model);
1022        let derived: Vec<MeasureId> = self
1023            .measures
1024            .iter()
1025            .copied()
1026            .filter(|id| {
1027                self.model
1028                    .measures
1029                    .get(id)
1030                    .map(|m| m.is_derived())
1031                    .unwrap_or(false)
1032            })
1033            .collect();
1034        let mut built = Ok(());
1035        let (engine, snapshot) = if derived.is_empty() {
1036            (None, Snapshot::default())
1037        } else {
1038            match Engine::new(&self.model, &derived) {
1039                Ok((e, s)) => (Some(e), s),
1040                Err(e) => {
1041                    built = Err(e.to_string());
1042                    (None, Snapshot::default())
1043                }
1044            }
1045        };
1046        self.engine = engine;
1047        self.snapshot = snapshot;
1048        self.selected = viewed
1049            .and_then(|v| self.measures.iter().position(|m| *m == v))
1050            .unwrap_or(0);
1051        self.axis_order = self.natural_axis_order();
1052        self.page_idx.clear();
1053        self.filters.clear();
1054        self.reselect();
1055        self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
1056        built
1057    }
1058
1059    // -- views & filters (pure; terminal-free -> unit-testable) ------------
1060
1061    /// The viewed measure's id.
1062    fn viewed_measure(&self) -> MeasureId {
1063        self.measures[self.selected]
1064    }
1065
1066    /// Build a `View` capturing the current layout: viewed measure, axis order,
1067    /// pinned page items, and active filters. Presentation only — no data.
1068    pub fn build_view(&self, id: ViewId, name: &str) -> View {
1069        let page_items = self.grid.pages.iter().map(|p| (p.cat, p.item)).collect();
1070        View {
1071            id,
1072            name: Name(name.to_string()),
1073            measure: self.viewed_measure(),
1074            axis_order: self.axis_order.clone(),
1075            n_rows: 1,
1076            n_cols: 1,
1077            page_items,
1078            filters: self.filters.clone(),
1079            // Canvas geometry/extra matrices are GUI-canvas concepts (plan
1080            // Step 3); the TUI always saves a single-matrix view.
1081            rect: Default::default(),
1082            placements: vec![],
1083        }
1084    }
1085
1086    /// The smallest unused view id (>= 1).
1087    fn next_view_id(&self) -> ViewId {
1088        ViewId(
1089            self.model
1090                .views
1091                .keys()
1092                .map(|v| v.0)
1093                .max()
1094                .map(|m| m + 1)
1095                .unwrap_or(1),
1096        )
1097    }
1098
1099    /// Save the current layout as a named view: mint an id, add it to the
1100    /// model, and (when `path` is set) persist immediately. Returns the id.
1101    pub fn save_view(&mut self, name: &str, path: Option<&str>) -> ViewId {
1102        let id = self.next_view_id();
1103        let view = self.build_view(id, name);
1104        self.model.add_view(view);
1105        if let Some(path) = path {
1106            match improv_store::Store::open(path).and_then(|mut s| {
1107                s.set_save_token(&self.save_token);
1108                s.save_model(&self.model)?;
1109                self.save_token = s.save_token();
1110                Ok(())
1111            }) {
1112                Ok(()) => self.status = Some(format!("saved view '{name}'")),
1113                Err(e) => self.status = Some(format!("save failed: {e}")),
1114            }
1115        } else {
1116            self.status = Some(format!("saved view '{name}'"));
1117        }
1118        id
1119    }
1120
1121    /// Apply a saved `View` to the live layout: select its measure, restore the
1122    /// axis order, page items, and filters, then re-render. Presentation only
1123    /// — measures and data are untouched. No-op if the measure is gone.
1124    pub fn apply_view(&mut self, view: &View) {
1125        let Some(idx) = self.measures.iter().position(|m| *m == view.measure) else {
1126            self.status = Some("view's measure no longer exists".into());
1127            return;
1128        };
1129        self.selected = idx;
1130        self.axis_order = if view.axis_order.is_empty() {
1131            self.natural_axis_order()
1132        } else {
1133            view.axis_order.clone()
1134        };
1135        self.filters = view.filters.clone();
1136        self.reselect();
1137        // Restore pinned page items positionally by page dimension.
1138        self.page_idx = self
1139            .grid
1140            .pages
1141            .iter()
1142            .map(|p| {
1143                view.page_items
1144                    .iter()
1145                    .find(|(c, _)| *c == p.cat)
1146                    .and_then(|(_, it)| {
1147                        items_of(&self.model, p.cat, &self.filters)
1148                            .iter()
1149                            .position(|(id, _)| id == it)
1150                    })
1151                    .unwrap_or(p.item_index)
1152            })
1153            .collect();
1154        self.reselect();
1155    }
1156
1157    /// Cycle to the next saved view (by id order) and apply it. No-op if there
1158    /// are no saved views.
1159    pub fn cycle_view(&mut self) {
1160        let mut ids: Vec<ViewId> = self.model.views.keys().copied().collect();
1161        if ids.is_empty() {
1162            self.status = Some("no saved views (press S to save one)".into());
1163            return;
1164        }
1165        ids.sort_by_key(|v| v.0);
1166        // Advance past the last-applied view if it is still present.
1167        let start = self
1168            .applied_view
1169            .and_then(|cur| ids.iter().position(|v| *v == cur))
1170            .map(|p| (p + 1) % ids.len())
1171            .unwrap_or(0);
1172        let id = ids[start];
1173        self.applied_view = Some(id);
1174        let view = self.model.views[&id].clone();
1175        self.apply_view(&view);
1176        self.status = Some(format!("view: {}", view.name.0));
1177    }
1178
1179    /// Toggle whether `item` of `category` is shown. Building the filter from
1180    /// the full item set on first toggle, then removing/re-adding the item.
1181    /// Removing the last shown item leaves an explicit empty filter (nothing
1182    /// shown for that category). Presentation only.
1183    pub fn toggle_filter_item(&mut self, category: CategoryId, item: ItemId) {
1184        let all: Vec<ItemId> = self
1185            .model
1186            .categories
1187            .get(&category)
1188            .map(|c| c.items.clone())
1189            .unwrap_or_default();
1190        let pos = self.filters.iter().position(|f| f.category == category);
1191        match pos {
1192            None => {
1193                // First toggle: start from all items, then hide this one.
1194                let items: Vec<ItemId> = all.into_iter().filter(|i| *i != item).collect();
1195                self.filters.push(Filter { category, items });
1196            }
1197            Some(i) => {
1198                let f = &mut self.filters[i];
1199                if let Some(p) = f.items.iter().position(|x| *x == item) {
1200                    f.items.remove(p);
1201                } else {
1202                    f.items.push(item);
1203                }
1204                // If the filter now keeps everything, drop it (unfiltered).
1205                if f.items.len() == all.len() && all.iter().all(|i| f.items.contains(i)) {
1206                    self.filters.remove(i);
1207                }
1208            }
1209        }
1210        self.reselect();
1211    }
1212
1213    /// Clear all active filters, showing every item again.
1214    pub fn clear_filters(&mut self) {
1215        self.filters.clear();
1216        self.reselect();
1217    }
1218    /// The `(measure, Coordinate)` of the cell under the cursor.
1219    #[cfg(test)]
1220    pub fn cursor_coord(&self) -> (MeasureId, Coordinate) {
1221        let key = self.grid.coord_key(self.cursor_row, self.cursor_col);
1222        (self.grid.measure, decode_coord(&key))
1223    }
1224}
1225
1226#[cfg(test)]
1227pub(crate) mod tests {
1228    use super::*;
1229    use improv_core_model::{
1230        BinaryOp, DimensionSpec, Expr, Formula, Measure, MeasureKind, Name, ValueType,
1231    };
1232
1233    // Canonical Time x Product revenue model (mirrors engine's fixture).
1234    pub(crate) fn revenue_model() -> Model {
1235        let mut m = Model::new();
1236        let (time, product) = (CategoryId(1), CategoryId(2));
1237        m.add_category(time, "Time");
1238        m.add_category(product, "Product");
1239        m.add_item(ItemId(10), time, "2025");
1240        m.add_item(ItemId(11), time, "2026");
1241        m.add_item(ItemId(20), product, "WidgetA");
1242        m.add_item(ItemId(21), product, "WidgetB");
1243
1244        m.add_measure(Measure {
1245            id: MeasureId(100),
1246            name: Name("Price".into()),
1247            value_type: ValueType::Number,
1248            categories: vec![product],
1249            kind: MeasureKind::Input,
1250            description: None,
1251        });
1252        m.add_measure(Measure {
1253            id: MeasureId(101),
1254            name: Name("Quantity".into()),
1255            value_type: ValueType::Number,
1256            categories: vec![time, product],
1257            kind: MeasureKind::Input,
1258            description: None,
1259        });
1260        m.add_measure(Measure {
1261            id: MeasureId(102),
1262            name: Name("Revenue".into()),
1263            value_type: ValueType::Number,
1264            categories: vec![time, product],
1265            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1266                BinaryOp::Mul,
1267                Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
1268                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
1269            ))),
1270            description: None,
1271        });
1272
1273        let coord = |p: &[(CategoryId, ItemId)]| Coordinate::from_pairs(p.iter().copied());
1274        m.set_input(
1275            MeasureId(100),
1276            coord(&[(product, ItemId(20))]),
1277            Value::Number(10.0),
1278        );
1279        m.set_input(
1280            MeasureId(100),
1281            coord(&[(product, ItemId(21))]),
1282            Value::Number(20.0),
1283        );
1284        for (t, p, q) in [
1285            (ItemId(10), ItemId(20), 100.0),
1286            (ItemId(10), ItemId(21), 50.0),
1287            (ItemId(11), ItemId(20), 120.0),
1288            (ItemId(11), ItemId(21), 80.0),
1289        ] {
1290            m.set_input(
1291                MeasureId(101),
1292                coord(&[(time, t), (product, p)]),
1293                Value::Number(q),
1294            );
1295        }
1296        m
1297    }
1298
1299    #[test]
1300    fn derived_grid_has_right_dims_and_values() {
1301        let model = revenue_model();
1302        let app = App::new(model).unwrap();
1303        let grid = build_grid(&app.model, MeasureId(102), &app.snapshot); // Revenue[Time, Product]
1304        assert_eq!(grid.n_rows(), 2, "two Time items");
1305        assert_eq!(grid.n_cols(), 2, "two Product items");
1306        assert_eq!(grid.row_cat.as_ref().unwrap().1, "Time");
1307        assert_eq!(grid.col_cat.as_ref().unwrap().1, "Product");
1308
1309        // Rows follow declared item order: 2025, 2026. Cols: WidgetA, WidgetB.
1310        // Revenue[2025, WidgetA] = 10 * 100 = 1000.
1311        assert_eq!(grid.value_at(0, 0), Some(1000.0));
1312        // Revenue[2026, WidgetB] = 20 * 80 = 1600.
1313        assert_eq!(grid.value_at(1, 1), Some(1600.0));
1314    }
1315
1316    #[test]
1317    fn text_input_cell_renders_typed_not_nan() {
1318        // A Text input measure must render its text (not NaN / blank).
1319        use improv_core_model::{Measure, MeasureKind, Name, ValueType};
1320        let mut model = Model::new();
1321        let p = CategoryId(2);
1322        model.add_category(p, "Product");
1323        model.add_item(ItemId(20), p, "WidgetA");
1324        model.add_measure(Measure {
1325            id: MeasureId(300),
1326            name: Name("Label".into()),
1327            value_type: ValueType::Text,
1328            categories: vec![p],
1329            kind: MeasureKind::Input,
1330            description: None,
1331        });
1332        model.set_input(
1333            MeasureId(300),
1334            Coordinate::from_pairs([(p, ItemId(20))]),
1335            Value::Text("hello".into()),
1336        );
1337        let app = App::new(model).unwrap();
1338        let grid = build_grid(&app.model, MeasureId(300), &app.snapshot);
1339        assert_eq!(grid.display_at(0, 0), "hello");
1340        assert_eq!(grid.value_at(0, 0), None, "text cell has no numeric value");
1341    }
1342
1343    #[test]
1344    fn input_grid_single_axis() {
1345        let model = revenue_model();
1346        let grid = build_grid(&model, MeasureId(100), &Snapshot::default()); // Price[Product]
1347        assert_eq!(
1348            grid.n_rows(),
1349            2,
1350            "two Product items on the single (row) axis"
1351        );
1352        assert_eq!(
1353            grid.n_cols(),
1354            1,
1355            "no column axis => single synthetic column"
1356        );
1357        assert!(grid.col_cat.is_none());
1358        assert_eq!(grid.value_at(0, 0), Some(10.0)); // WidgetA
1359        assert_eq!(grid.value_at(1, 0), Some(20.0)); // WidgetB
1360    }
1361
1362    #[test]
1363    fn measure_cycling_wraps_and_derived_first() {
1364        let model = revenue_model();
1365        let mut app = App::new(model).unwrap();
1366        // Derived (Revenue, 102) sorts first.
1367        assert_eq!(app.grid.measure, MeasureId(102));
1368        app.next_measure();
1369        assert_eq!(app.grid.measure, MeasureId(100)); // then Price (id 100)
1370        app.next_measure();
1371        assert_eq!(app.grid.measure, MeasureId(101)); // then Quantity (id 101)
1372        app.next_measure();
1373        assert_eq!(app.grid.measure, MeasureId(102)); // wraps back
1374    }
1375
1376    #[test]
1377    fn cursor_stays_in_bounds() {
1378        let model = revenue_model();
1379        let mut app = App::new(model).unwrap();
1380        // Revenue grid is 2x2. Push cursor past every edge.
1381        app.move_cursor(-5, -5);
1382        assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
1383        app.move_cursor(100, 100);
1384        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1385
1386        // Switching to Price (2 rows, 1 col) must re-clamp the column.
1387        app.selected = app
1388            .measures
1389            .iter()
1390            .position(|m| *m == MeasureId(100))
1391            .unwrap();
1392        app.reselect();
1393        assert_eq!(app.cursor_col, 0, "column re-clamped to the narrower grid");
1394        assert!(app.cursor_row <= 1);
1395    }
1396
1397    // Position the app on a specific measure by id.
1398    pub(crate) fn view(app: &mut App, measure: MeasureId) {
1399        app.selected = app.measures.iter().position(|m| *m == measure).unwrap();
1400        app.reselect();
1401    }
1402
1403    #[test]
1404    fn editing_input_recomputes_derived_via_engine() {
1405        let mut app = App::new(revenue_model()).unwrap();
1406        // View Quantity[Time, Product]; rows=Time(2025,2026), cols=Product(WidgetA,WidgetB).
1407        view(&mut app, MeasureId(101));
1408        // Cell [0,0] = Quantity[2025, WidgetA]; confirm the mapping first.
1409        let (m, coord) = {
1410            app.cursor_row = 0;
1411            app.cursor_col = 0;
1412            app.cursor_coord()
1413        };
1414        assert_eq!(m, MeasureId(101));
1415        assert_eq!(coord.get(CategoryId(1)), Some(ItemId(10))); // Time=2025
1416        assert_eq!(coord.get(CategoryId(2)), Some(ItemId(20))); // Product=WidgetA
1417
1418        // Set Quantity[2025, WidgetA] = 200 (was 100).
1419        app.apply_edit(0, 0, 200.0).unwrap();
1420        assert_eq!(
1421            app.model.input(MeasureId(101), &coord),
1422            Some(&Value::Number(200.0)),
1423            "model input updated"
1424        );
1425
1426        // Revenue[2025, WidgetA] = Price(10) * 200 = 2000 in the live snapshot.
1427        let rev_key = {
1428            let mut k = vec![(1u32, 10u32), (2, 20)];
1429            k.sort();
1430            k
1431        };
1432        assert_eq!(
1433            app.snapshot
1434                .get(&MeasureId(102))
1435                .unwrap()
1436                .get(&rev_key)
1437                .and_then(|v| v.as_num()),
1438            Some(2000.0),
1439            "derived Revenue recomputed incrementally"
1440        );
1441    }
1442
1443    #[test]
1444    fn editing_derived_cell_is_rejected() {
1445        let mut app = App::new(revenue_model()).unwrap();
1446        view(&mut app, MeasureId(102)); // Revenue is derived
1447        let before = app.model.clone();
1448        let err = app.apply_edit(0, 0, 42.0);
1449        assert!(err.is_err(), "derived edit must be rejected");
1450        assert_eq!(app.model, before, "model unchanged on rejected edit");
1451        // No edit open: not editing a formula, and edit keys do nothing.
1452        assert!(!app.editing_formula());
1453        app.edit_key(KeyCode::Char('x'));
1454        assert!(app.edit.is_none());
1455
1456        // begin_edit opens the formula instead, seeded with its text.
1457        app.begin_edit();
1458        assert!(app.editing_formula());
1459        assert_eq!(app.edit.as_deref(), Some("Price times Quantity"));
1460    }
1461
1462    #[test]
1463    fn editing_a_formula_recomputes_or_reports_the_error() {
1464        let mut app = App::new(revenue_model()).unwrap();
1465        view(&mut app, MeasureId(102));
1466        app.begin_edit();
1467        // The seeded controlled English commits back unchanged.
1468        app.commit_edit();
1469        assert!(app.edit.is_none());
1470        assert!(app.status.is_none());
1471        assert_eq!(app.grid.display_at(0, 0), "1000");
1472        app.begin_edit();
1473        app.edit = Some("Price * Quantity * 2".into());
1474        app.commit_edit();
1475        assert!(app.edit.is_none());
1476        assert_eq!(app.grid.display_at(0, 0), "2000");
1477        assert_eq!(
1478            app.formula_text().as_deref(),
1479            Some("Price times Quantity times 2")
1480        );
1481        // A parse error keeps the edit open, located, and changes nothing.
1482        let before = app.model.clone();
1483        app.begin_edit();
1484        app.edit = Some("Price * Quantty".into());
1485        app.commit_edit();
1486        assert!(app.editing_formula());
1487        let d = app.diagnostic.clone().unwrap();
1488        assert_eq!(
1489            (d.span, d.message.as_str()),
1490            (Some(8..15), "unknown measure: Quantty")
1491        );
1492        assert_eq!(app.model, before);
1493        // Tab that changes nothing keeps it; an edit clears it.
1494        app.edit_key(KeyCode::Tab);
1495        assert!(app.diagnostic.is_some());
1496        app.edit_key(KeyCode::Left);
1497        assert!(app.diagnostic.is_some(), "moving the caret keeps it");
1498        app.edit_key(KeyCode::Char('i'));
1499        assert!(app.diagnostic.is_none());
1500        app.edit = Some("Price * Quantty".into());
1501        app.commit_edit();
1502        app.edit.as_mut().unwrap().caret = 12;
1503        app.edit_key(KeyCode::Tab);
1504        assert_eq!(app.edit.as_deref(), Some("Price * Quantitytty"));
1505        assert!(app.diagnostic.is_none(), "a completion edits the text");
1506        app.cancel_edit();
1507        // A formula that parses but cannot build (a cycle) reports, unlocated.
1508        app.begin_edit();
1509        app.edit = Some("Revenue + 1".into());
1510        app.commit_edit();
1511        assert!(app.editing_formula(), "{:?}", app.status);
1512        assert!(app.status.is_some());
1513        assert!(app.diagnostic.is_none());
1514        assert_eq!(app.model, before);
1515        // A value edit is not a formula edit.
1516        view(&mut app, MeasureId(101));
1517        app.begin_edit();
1518        assert!(app.edit.is_some());
1519        assert!(!app.editing_formula());
1520    }
1521
1522    #[test]
1523    fn cursor_maps_to_expected_coordinate() {
1524        let mut app = App::new(revenue_model()).unwrap();
1525        view(&mut app, MeasureId(101)); // Quantity[Time, Product], 2x2
1526
1527        // [1,1] = Quantity[2026, WidgetB] = Time(11), Product(21).
1528        app.cursor_row = 1;
1529        app.cursor_col = 1;
1530        let key = app.grid.coord_key(1, 1);
1531        let mut expect = vec![(1u32, 11u32), (2, 21)];
1532        expect.sort();
1533        assert_eq!(key, expect, "cursor cell -> CoordKey");
1534
1535        // Round-trip: key -> Coordinate -> back matches cursor_coord.
1536        let (_m, coord) = app.cursor_coord();
1537        assert_eq!(improv_engine::encode_coord(&coord), key);
1538        assert_eq!(coord.get(CategoryId(1)), Some(ItemId(11)));
1539        assert_eq!(coord.get(CategoryId(2)), Some(ItemId(21)));
1540    }
1541
1542    #[test]
1543    fn paging_changes_the_pinned_extra_dimension() {
1544        // A 3-D input measure Sales[Time, Product, Region]; Time/Product are the
1545        // grid axes, Region is a page dimension navigated with [ / ].
1546        let mut m = Model::new();
1547        let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
1548        m.add_category(time, "Time");
1549        m.add_category(product, "Product");
1550        m.add_category(region, "Region");
1551        m.add_item(ItemId(10), time, "2025");
1552        m.add_item(ItemId(20), product, "WidgetA");
1553        m.add_item(ItemId(30), region, "North");
1554        m.add_item(ItemId(31), region, "South");
1555        m.add_measure(Measure {
1556            id: MeasureId(200),
1557            name: Name("Sales".into()),
1558            value_type: ValueType::Number,
1559            categories: vec![time, product, region],
1560            kind: MeasureKind::Input,
1561            description: None,
1562        });
1563        let c = |pairs: &[(CategoryId, ItemId)]| Coordinate::from_pairs(pairs.iter().copied());
1564        m.set_input(
1565            MeasureId(200),
1566            c(&[
1567                (time, ItemId(10)),
1568                (product, ItemId(20)),
1569                (region, ItemId(30)),
1570            ]),
1571            Value::Number(100.0),
1572        );
1573        m.set_input(
1574            MeasureId(200),
1575            c(&[
1576                (time, ItemId(10)),
1577                (product, ItemId(20)),
1578                (region, ItemId(31)),
1579            ]),
1580            Value::Number(250.0),
1581        );
1582
1583        let mut app = App::new(m).unwrap();
1584        // One page dimension (Region), starting on North (index 0).
1585        assert_eq!(app.grid.pages.len(), 1);
1586        assert_eq!(app.grid.pages[0].item_name, "North");
1587        assert_eq!(app.grid.pages[0].item_count, 2);
1588        assert_eq!(app.grid.value_at(0, 0), Some(100.0)); // North cell
1589
1590        // Page to the next Region: South, and the visible cell changes.
1591        app.page(1);
1592        assert_eq!(app.grid.pages[0].item_name, "South");
1593        assert_eq!(app.grid.value_at(0, 0), Some(250.0));
1594
1595        // Wraps back to North.
1596        app.page(1);
1597        assert_eq!(app.grid.pages[0].item_name, "North");
1598        assert_eq!(app.grid.value_at(0, 0), Some(100.0));
1599
1600        // Changing measure (here back to the only one) starts on the first
1601        // page again; a pivot moves Time to the page, also from its first item.
1602        app.page(1);
1603        app.next_measure();
1604        assert_eq!(app.grid.pages[0].item_name, "North");
1605        assert_eq!(app.page_idx, vec![0]);
1606        app.pivot();
1607        assert_eq!(
1608            app.status.as_deref(),
1609            Some("pivot: rows=Product cols=Region")
1610        );
1611        assert_eq!(app.grid.pages[0].cat_name, "Time");
1612        assert_eq!(app.page_idx, vec![0]);
1613    }
1614
1615    /// With no formulas there is no engine; value edits still land. A
1616    /// formula that cannot build fails `App::new` with the engine's error.
1617    #[test]
1618    fn engine_only_for_formulas_that_build() {
1619        let mut m = revenue_model();
1620        m.measures.remove(&MeasureId(102));
1621        let mut app = App::new(m).unwrap();
1622        assert!(app.engine.is_none());
1623        view(&mut app, MeasureId(101));
1624        app.apply_edit(0, 0, 7.0).unwrap();
1625        assert_eq!(app.grid.value_at(0, 0), Some(7.0));
1626
1627        let mut m = revenue_model();
1628        m.measures.get_mut(&MeasureId(102)).unwrap().kind = MeasureKind::Derived(Formula::new(
1629            Expr::Ref(MeasureId(102), DimensionSpec::default()),
1630        ));
1631        let want = Engine::new(&m, &[MeasureId(102)])
1632            .err()
1633            .unwrap()
1634            .to_string();
1635        assert_eq!(App::new(m).err(), Some(want));
1636    }
1637
1638    #[test]
1639    fn pivot_swaps_axes() {
1640        // Revenue[Time, Product]: rows=Time, cols=Product initially.
1641        let mut app = App::new(revenue_model()).unwrap();
1642        view(&mut app, MeasureId(101)); // Quantity[Time, Product] input, 2x2
1643        assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Time");
1644        assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Product");
1645
1646        // Pivot: rotate axes -> rows=Product, cols=Time.
1647        app.pivot();
1648        assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Product");
1649        assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Time");
1650
1651        // Pivot again on a 2-D measure returns to the original orientation.
1652        app.pivot();
1653        assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Time");
1654        assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Product");
1655    }
1656
1657    #[test]
1658    fn text_and_boolean_cells_are_editable() {
1659        // Regression: commit_edit only parsed f64, so a Text/Boolean input
1660        // measure could never be edited ("not a number: 'hi'").
1661        let mut m = revenue_model();
1662        let time = CategoryId(1);
1663        for (id, name, vt) in [
1664            (200, "Note", ValueType::Text),
1665            (201, "Flag", ValueType::Boolean),
1666        ] {
1667            m.add_measure(Measure {
1668                id: MeasureId(id),
1669                name: Name(name.into()),
1670                value_type: vt,
1671                categories: vec![time],
1672                kind: MeasureKind::Input,
1673                description: None,
1674            });
1675        }
1676        let mut app = App::new(m).unwrap();
1677        let at = Coordinate::from_pairs([(time, ItemId(10))]);
1678        view(&mut app, MeasureId(200));
1679        app.edit = Some("hi there".into());
1680        app.commit_edit();
1681        assert_eq!(app.status, None);
1682        assert_eq!(
1683            app.model.input(MeasureId(200), &at),
1684            Some(&Value::Text("hi there".into()))
1685        );
1686        app.begin_edit(); // seeds with the text, not blank
1687        assert_eq!(app.edit.as_deref(), Some("hi there"));
1688
1689        view(&mut app, MeasureId(201));
1690        app.edit = Some("maybe".into());
1691        app.commit_edit();
1692        assert_eq!(app.status.as_deref(), Some("not true|false: 'maybe'"));
1693        assert!(app.edit.is_some(), "bad input keeps the buffer open");
1694        app.edit = Some("true".into());
1695        app.commit_edit();
1696        assert_eq!(
1697            app.model.input(MeasureId(201), &at),
1698            Some(&Value::Boolean(true))
1699        );
1700        // Derived values survive a non-number edit.
1701        view(&mut app, MeasureId(102));
1702        assert_eq!(app.grid.value_at(0, 0), Some(1000.0));
1703        assert!(parse_typed("x", ValueType::DateTime).is_err());
1704    }
1705
1706    /// A boolean edit flows through the live engine as one delta (no rebuild):
1707    /// a derived `Hot = NOT Flag` follows it, and the untouched `Revenue`
1708    /// keeps its shared snapshot (a rebuild would compute it afresh).
1709    #[test]
1710    fn typed_edits_are_incremental() {
1711        let mut m = revenue_model();
1712        let time = CategoryId(1);
1713        m.add_measure(Measure {
1714            id: MeasureId(201),
1715            name: Name("Flag".into()),
1716            value_type: ValueType::Boolean,
1717            categories: vec![time],
1718            kind: MeasureKind::Input,
1719            description: None,
1720        });
1721        let at = Coordinate::from_pairs([(time, ItemId(10))]);
1722        m.set_input(MeasureId(201), at.clone(), Value::Boolean(false));
1723        let hot = improv_core_model::parser::parse_formula(&m, "Hot = NOT Flag").unwrap();
1724        m.add_measure(Measure {
1725            id: MeasureId(202),
1726            name: Name("Hot".into()),
1727            value_type: ValueType::Boolean,
1728            categories: vec![time],
1729            kind: MeasureKind::Derived(hot.formula),
1730            description: None,
1731        });
1732        let mut app = App::new(m).unwrap();
1733        let key = encode_coord(&at);
1734        let hot_at = |app: &App| app.snapshot[&MeasureId(202)].get(&key).cloned();
1735        assert_eq!(hot_at(&app), Some(CellValue::Bool(true)));
1736        let revenue = app.snapshot[&MeasureId(102)].clone();
1737        view(&mut app, MeasureId(201));
1738        app.edit = Some("true".into());
1739        app.commit_edit();
1740        assert_eq!(app.status, None);
1741        assert_eq!(hot_at(&app), Some(CellValue::Bool(false)));
1742        assert!(std::sync::Arc::ptr_eq(
1743            &revenue,
1744            &app.snapshot[&MeasureId(102)]
1745        ));
1746    }
1747
1748    /// Revenue model plus `Sales[Time, Product, Empty, Region]`: `Empty` has
1749    /// no items, so it is skipped as a page and `Region` is the first page.
1750    fn model_with_empty_page() -> Model {
1751        let mut m = revenue_model();
1752        m.add_category(CategoryId(3), "Empty");
1753        m.add_category(CategoryId(4), "Region");
1754        m.add_item(ItemId(40), CategoryId(4), "North");
1755        m.add_item(ItemId(41), CategoryId(4), "South");
1756        m.add_measure(Measure {
1757            id: MeasureId(300),
1758            name: Name("Sales".into()),
1759            value_type: ValueType::Number,
1760            categories: (1..=4).map(CategoryId).collect(),
1761            kind: MeasureKind::Input,
1762            description: None,
1763        });
1764        m
1765    }
1766
1767    #[test]
1768    fn paging_past_an_empty_page_dim_wraps_and_edits_are_refused() {
1769        let mut app = App::new(model_with_empty_page()).unwrap();
1770        view(&mut app, MeasureId(300));
1771        app.page_idx.clear(); // stale/short page_idx is resynced
1772        app.page(-1);
1773        assert_eq!(app.grid.pages.len(), 1);
1774        assert_eq!(app.grid.pages[0].item_name, "South", "wrapped backwards");
1775        app.page(1);
1776        assert_eq!(app.grid.pages[0].item_name, "North");
1777        // No full coordinate exists (Empty has no item): editing is refused.
1778        app.begin_edit();
1779        assert_eq!(app.status.as_deref(), Some(NO_CELL));
1780        // A buffer committed there anyway stays open, with the reason.
1781        app.edit = Some("5".into());
1782        app.status = None;
1783        app.commit_edit();
1784        assert_eq!(app.edit.as_deref(), Some("5"));
1785        assert_eq!(app.status.as_deref(), Some(NO_CELL));
1786    }
1787
1788    #[test]
1789    fn small_branches_pivot_click_seed_and_no_buffer_commits() {
1790        let mut app = App::new(revenue_model()).unwrap();
1791        // Pivot on a 1-D measure is a no-op with a message.
1792        view(&mut app, MeasureId(100));
1793        app.axis_order = app.natural_axis_order();
1794        app.pivot();
1795        assert!(app
1796            .status
1797            .as_deref()
1798            .unwrap()
1799            .starts_with("nothing to pivot"));
1800        // Clicks before any render, left of the grid, or past the window.
1801        app.click_at(50, 50);
1802        app.grid_geom = Some(GridGeom {
1803            x0: 10,
1804            y0: 5,
1805            col_w: 12,
1806            spacing: 1,
1807            row0: 0,
1808            col0: 0,
1809            rows: 1,
1810            cols: 1,
1811        });
1812        app.click_at(5, 6);
1813        app.click_at(10, 6); // row 1 is outside the 1-row window
1814        assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
1815        // Fractional values seed the edit buffer verbatim.
1816        view(&mut app, MeasureId(101));
1817        app.apply_edit(0, 0, 2.5).unwrap();
1818        app.begin_edit();
1819        assert_eq!(app.edit.as_deref(), Some("2.5"));
1820        app.edit = Some("abc".into());
1821        app.commit_edit();
1822        assert_eq!(app.status.as_deref(), Some("not a number: 'abc'"));
1823        app.edit = Some("false".into());
1824        assert_eq!(
1825            parse_typed("false", ValueType::Boolean),
1826            Ok(Value::Boolean(false))
1827        );
1828        // Commits with no active buffer do nothing.
1829        app.edit = None;
1830        app.status = None;
1831        app.commit_edit();
1832        app.commit_command();
1833        assert_eq!(app.status, None);
1834        // A derived measure's edit line is its formula.
1835        view(&mut app, MeasureId(102));
1836        app.edit = Some("1".into());
1837        app.commit_edit();
1838        assert_eq!(app.status, None);
1839        assert_eq!(app.formula_text().as_deref(), Some("1"));
1840    }
1841
1842    #[test]
1843    fn views_cycle_wrap_and_tolerate_missing_measures() {
1844        let mut app = App::new(revenue_model()).unwrap();
1845        app.cycle_view();
1846        assert!(app.status.as_deref().unwrap().starts_with("no saved views"));
1847        view(&mut app, MeasureId(101));
1848        let v1 = app.save_view("one", None);
1849        app.next_measure();
1850        let v2 = app.save_view("two", None);
1851        assert_eq!(v2.0, v1.0 + 1);
1852        app.cycle_view();
1853        assert_eq!(app.status.as_deref(), Some("view: one"));
1854        app.cycle_view();
1855        assert_eq!(app.status.as_deref(), Some("view: two"));
1856        app.cycle_view();
1857        assert_eq!(app.status.as_deref(), Some("view: one"), "wraps");
1858        // A view with an empty axis order falls back to natural order; one
1859        // whose measure is gone is refused.
1860        let mut v = app.build_view(ViewId(99), "bare");
1861        v.axis_order.clear();
1862        app.apply_view(&v);
1863        assert_eq!(app.axis_order, app.natural_axis_order());
1864        v.measure = MeasureId(9999);
1865        app.apply_view(&v);
1866        assert_eq!(
1867            app.status.as_deref(),
1868            Some("view's measure no longer exists")
1869        );
1870        // A save to an unwritable path reports the failure.
1871        app.save_view("bad", Some("/nonexistent/dir/x.db"));
1872        assert!(app.status.as_deref().unwrap().starts_with("save failed"));
1873    }
1874
1875    #[test]
1876    fn re_showing_every_filtered_item_drops_the_filter() {
1877        let mut app = App::new(revenue_model()).unwrap();
1878        let time = CategoryId(1);
1879        app.toggle_filter_item(time, ItemId(10));
1880        app.toggle_filter_item(time, ItemId(11));
1881        app.toggle_filter_item(time, ItemId(10));
1882        assert_eq!(app.filters.len(), 1, "one item still hidden");
1883        app.toggle_filter_item(time, ItemId(11));
1884        assert!(app.filters.is_empty(), "all shown again => unfiltered");
1885        // An item from another category does not count as re-showing one.
1886        app.toggle_filter_item(time, ItemId(10));
1887        app.toggle_filter_item(time, ItemId(20));
1888        assert_eq!(app.filters.len(), 1, "2025 is still hidden");
1889    }
1890
1891    #[test]
1892    fn grid_falls_back_on_bad_axis_order_and_unknown_ids() {
1893        let m = revenue_model();
1894        let s = Snapshot::default();
1895        // Not a permutation of the measure's categories: natural order wins.
1896        let g = build_grid_pivoted(&m, MeasureId(101), &s, &[], Some(&[CategoryId(2)]), &[]);
1897        assert_eq!(g.row_cat.unwrap().1, "Time");
1898        // Unknown measure: id as name, a single synthetic cell.
1899        let g = build_grid(&m, MeasureId(77), &s);
1900        assert_eq!(
1901            (g.measure_name.as_str(), g.n_rows(), g.n_cols()),
1902            ("77", 1, 1)
1903        );
1904        // Dangling category/item ids render as their numbers.
1905        let mut m = m;
1906        m.categories
1907            .get_mut(&CategoryId(2))
1908            .unwrap()
1909            .items
1910            .push(ItemId(29));
1911        let mut meas = m.measures[&MeasureId(101)].clone();
1912        meas.id = MeasureId(400);
1913        meas.categories = vec![CategoryId(2), CategoryId(8)];
1914        m.add_measure(meas);
1915        let g = build_grid(&m, MeasureId(400), &s);
1916        assert_eq!(g.rows.last().unwrap().1, "29");
1917        assert_eq!(g.col_cat.unwrap().1, "8");
1918        let mut app = App::new(m).unwrap();
1919        view(&mut app, MeasureId(400));
1920        app.axis_order = app.natural_axis_order();
1921        app.pivot();
1922        assert_eq!(app.status.as_deref(), Some("pivot: rows=8 cols=Product"));
1923    }
1924
1925    #[test]
1926    fn shift_arrows_select_a_range_and_sum_it() {
1927        let mut app = App::new(revenue_model()).unwrap();
1928        view(&mut app, MeasureId(101)); // [[100, 50], [120, 80]]
1929        assert_eq!(app.selection_sum(), Some(100.0));
1930        app.step(1, 0, true);
1931        app.step(0, 1, true);
1932        assert_eq!(app.anchor, Some((0, 0)));
1933        assert!(app.is_selected(1, 0));
1934        assert!(app.is_selected(0, 1));
1935        assert_eq!(app.selection_sum(), Some(350.0));
1936        app.step(-1, 0, false);
1937        assert_eq!((app.anchor, app.selection_sum()), (None, Some(50.0)));
1938        app.step(0, -1, true);
1939        app.clear_filters(); // any re-layout collapses the range
1940        assert_eq!(app.anchor, None);
1941        // Formula text for derived measures only; blank cells sum to None.
1942        assert_eq!(app.formula_text(), None);
1943        view(&mut app, MeasureId(102));
1944        assert_eq!(app.formula_text().as_deref(), Some("Price times Quantity"));
1945        let mut m = revenue_model();
1946        m.inputs.clear();
1947        assert_eq!(App::new(m).unwrap().selection_sum(), None);
1948    }
1949
1950    #[test]
1951    fn edit_on_fully_filtered_axis_is_refused_not_written() {
1952        // Regression: filtering out every row left a cursor on "no row"; an
1953        // edit then stored a cell whose coordinate lacked the row category.
1954        let mut app = App::new(revenue_model()).unwrap();
1955        view(&mut app, MeasureId(101));
1956        let time = CategoryId(1);
1957        app.toggle_filter_item(time, ItemId(10));
1958        app.toggle_filter_item(time, ItemId(11));
1959        assert!(app.grid.rows.is_empty());
1960        let before = app.model.inputs.len();
1961        app.begin_edit();
1962        assert!(app.edit.is_none());
1963        assert_eq!(app.status.as_deref(), Some(NO_CELL));
1964        assert_eq!(app.apply_edit(0, 0, 7.0), Err(NO_CELL.to_string()));
1965        assert_eq!(app.model.inputs.len(), before);
1966    }
1967
1968    #[test]
1969    fn click_moves_cursor_to_cell() {
1970        let mut app = App::new(revenue_model()).unwrap();
1971        view(&mut app, MeasureId(101)); // 2x2
1972                                        // Simulate the geometry a render would record.
1973        app.grid_geom = Some(GridGeom {
1974            x0: 2,
1975            y0: 5,
1976            col_w: 12,
1977            spacing: 1,
1978            row0: 0,
1979            col0: 0,
1980            rows: 10,
1981            cols: 10,
1982        });
1983        // Click in data row 1, column 1: x within the 2nd column (stride 13).
1984        app.click_at(2 + 13, 5 + 1);
1985        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1986        // A click above the grid (in the status area) is ignored.
1987        app.click_at(2, 0);
1988        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1989        // Inside the window but below or right of the 2x2 grid: ignored.
1990        app.click_at(2, 5 + 5);
1991        app.click_at(2 + 13 * 5, 5);
1992        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1993    }
1994
1995    #[test]
1996    fn filter_hides_a_row_item_from_the_grid() {
1997        let mut app = App::new(revenue_model()).unwrap();
1998        view(&mut app, MeasureId(101)); // Quantity[Time, Product]: rows=Time(2025,2026)
1999        assert_eq!(app.grid.n_rows(), 2);
2000        // Hide 2026 (ItemId 11) from the Time row axis.
2001        app.toggle_filter_item(CategoryId(1), ItemId(11));
2002        let rows: Vec<ItemId> = app.grid.rows.iter().map(|(id, _)| *id).collect();
2003        assert_eq!(rows, vec![ItemId(10)], "2026 filtered out of rows");
2004        // Columns (unfiltered Product) still show both.
2005        assert_eq!(app.grid.n_cols(), 2);
2006        // Toggling it back restores both rows (filter dropped when it keeps all).
2007        app.toggle_filter_item(CategoryId(1), ItemId(11));
2008        assert_eq!(app.grid.n_rows(), 2);
2009        assert!(app.filters.is_empty(), "unfiltered again");
2010    }
2011
2012    #[test]
2013    fn save_view_captures_axis_order_and_filters() {
2014        let mut app = App::new(revenue_model()).unwrap();
2015        view(&mut app, MeasureId(101)); // Quantity[Time, Product]
2016        app.pivot(); // rows=Product, cols=Time
2017        app.toggle_filter_item(CategoryId(2), ItemId(21)); // hide WidgetB
2018
2019        let id = app.save_view("L1", None);
2020        let v = app.model.views.get(&id).expect("view saved");
2021        assert_eq!(v.measure, MeasureId(101));
2022        assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
2023        let f = v
2024            .filters
2025            .iter()
2026            .find(|f| f.category == CategoryId(2))
2027            .unwrap();
2028        assert_eq!(f.items, vec![ItemId(20)]); // only WidgetA kept
2029        assert_eq!(app.model.view_by_name("L1").map(|v| v.id), Some(id));
2030    }
2031
2032    #[test]
2033    fn apply_view_restores_axis_order_and_filters() {
2034        // Build a view from one app, apply it to a fresh app -> layout matches.
2035        let mut src = App::new(revenue_model()).unwrap();
2036        view(&mut src, MeasureId(101));
2037        src.pivot(); // rows=Product, cols=Time
2038        src.toggle_filter_item(CategoryId(2), ItemId(21)); // hide WidgetB
2039        let v = src.build_view(ViewId(1), "L1");
2040
2041        let mut dst = App::new(revenue_model()).unwrap();
2042        assert_ne!(dst.grid.measure, MeasureId(101)); // starts on derived Revenue
2043        dst.apply_view(&v);
2044
2045        assert_eq!(dst.grid.measure, MeasureId(101));
2046        assert_eq!(dst.axis_order, vec![CategoryId(2), CategoryId(1)]);
2047        assert_eq!(dst.filters, v.filters);
2048        // The filter is reflected in the grid: WidgetB gone from the row axis.
2049        let rows: Vec<ItemId> = dst.grid.rows.iter().map(|(id, _)| *id).collect();
2050        assert_eq!(rows, vec![ItemId(20)], "only WidgetA on rows");
2051        assert_eq!(dst.grid.row_cat.as_ref().unwrap().1, "Product");
2052        assert_eq!(dst.grid.col_cat.as_ref().unwrap().1, "Time");
2053    }
2054
2055    #[test]
2056    fn apply_view_restores_page_item() {
2057        // 3-D Sales[Time, Product, Region]: save on South, apply to a fresh app.
2058        let mut m = Model::new();
2059        let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
2060        m.add_category(time, "Time");
2061        m.add_category(product, "Product");
2062        m.add_category(region, "Region");
2063        m.add_item(ItemId(10), time, "2025");
2064        m.add_item(ItemId(20), product, "WidgetA");
2065        m.add_item(ItemId(30), region, "North");
2066        m.add_item(ItemId(31), region, "South");
2067        m.add_measure(Measure {
2068            id: MeasureId(200),
2069            name: Name("Sales".into()),
2070            value_type: ValueType::Number,
2071            categories: vec![time, product, region],
2072            kind: MeasureKind::Input,
2073            description: None,
2074        });
2075        let c = |p: &[(CategoryId, ItemId)]| Coordinate::from_pairs(p.iter().copied());
2076        m.set_input(
2077            MeasureId(200),
2078            c(&[
2079                (time, ItemId(10)),
2080                (product, ItemId(20)),
2081                (region, ItemId(30)),
2082            ]),
2083            Value::Number(100.0),
2084        );
2085        m.set_input(
2086            MeasureId(200),
2087            c(&[
2088                (time, ItemId(10)),
2089                (product, ItemId(20)),
2090                (region, ItemId(31)),
2091            ]),
2092            Value::Number(250.0),
2093        );
2094
2095        let mut src = App::new(m.clone()).unwrap();
2096        src.page(1); // page Region to South
2097        assert_eq!(src.grid.pages[0].item_name, "South");
2098        let v = src.build_view(ViewId(1), "south");
2099        assert_eq!(v.page_items, vec![(region, ItemId(31))]);
2100
2101        let mut dst = App::new(m).unwrap();
2102        assert_eq!(dst.grid.pages[0].item_name, "North");
2103        dst.apply_view(&v);
2104        assert_eq!(dst.grid.pages[0].item_name, "South", "page item restored");
2105    }
2106
2107    // -- CSV import/export command prompt -----------------------------------
2108
2109    pub(crate) fn tmp_path(suffix: &str) -> PathBuf {
2110        std::env::temp_dir().join(format!(
2111            "improv_tui_test_{}_{}{}",
2112            std::process::id(),
2113            std::time::SystemTime::now()
2114                .duration_since(std::time::UNIX_EPOCH)
2115                .unwrap()
2116                .as_nanos(),
2117            suffix
2118        ))
2119    }
2120
2121    struct TmpFile(PathBuf);
2122    impl Drop for TmpFile {
2123        fn drop(&mut self) {
2124            let _ = std::fs::remove_file(&self.0);
2125        }
2126    }
2127
2128    #[test]
2129    fn parse_import_line_builds_expected_spec() {
2130        let spec = parse_import_line("sales.csv 100 Revenue revenue time:1:Time product:2:Product")
2131            .unwrap();
2132        assert_eq!(spec.path, PathBuf::from("sales.csv"));
2133        assert_eq!(spec.delimiter, b',');
2134        assert!(spec.has_header);
2135        assert_eq!(spec.measure_id, MeasureId(100));
2136        assert_eq!(spec.measure_name, "Revenue");
2137        assert_eq!(spec.value_column, ColumnRef::Name("revenue".into()));
2138        assert_eq!(spec.dimensions.len(), 2);
2139        assert_eq!(spec.dimensions[0].column, ColumnRef::Name("time".into()));
2140        assert_eq!(spec.dimensions[0].category_id, CategoryId(1));
2141        assert_eq!(spec.dimensions[0].category_name, "Time");
2142        assert_eq!(spec.dimensions[1].column, ColumnRef::Name("product".into()));
2143        assert_eq!(spec.dimensions[1].category_id, CategoryId(2));
2144        assert_eq!(spec.dimensions[1].category_name, "Product");
2145    }
2146
2147    #[test]
2148    fn parse_import_line_flags_flip_delimiter_and_header() {
2149        let spec = parse_import_line("sales.tsv 100 Revenue revenue time:1:Time --tsv --no-header")
2150            .unwrap();
2151        assert_eq!(spec.delimiter, b'\t');
2152        assert!(!spec.has_header);
2153        // Without a header, bare column refs parse as 0-based indices.
2154        assert_eq!(spec.value_column, ColumnRef::Name("revenue".into()));
2155    }
2156
2157    #[test]
2158    fn parse_import_line_no_header_uses_index_columns() {
2159        let spec = parse_import_line("sales.csv 100 Revenue 2 0:1:Time --no-header").unwrap();
2160        assert_eq!(spec.value_column, ColumnRef::Index(2));
2161        assert_eq!(spec.dimensions[0].column, ColumnRef::Index(0));
2162    }
2163
2164    #[test]
2165    fn parse_import_line_rejects_malformed_input() {
2166        assert!(parse_import_line("").is_err());
2167        assert!(parse_import_line("sales.csv 100 Revenue revenue").is_err()); // no dims
2168        assert!(parse_import_line("sales.csv notanumber Revenue revenue time:1:Time").is_err());
2169        assert!(parse_import_line("sales.csv 100 Revenue revenue time:notanumber:Time").is_err());
2170        assert!(parse_import_line("sales.csv 100 Revenue revenue badspec").is_err());
2171    }
2172
2173    #[test]
2174    fn parse_export_line_builds_expected_tuple() {
2175        let (target, mid, delim) = parse_export_line("out.csv 100").unwrap();
2176        assert_eq!(target, "out.csv");
2177        assert_eq!(mid, MeasureId(100));
2178        assert_eq!(delim, b',');
2179
2180        let (_, _, delim_tsv) = parse_export_line("out.csv 100 --tsv").unwrap();
2181        assert_eq!(delim_tsv, b'\t');
2182    }
2183
2184    #[test]
2185    fn parse_export_line_rejects_malformed_input() {
2186        assert!(parse_export_line("").is_err());
2187        assert!(parse_export_line("out.csv").is_err());
2188        assert!(parse_export_line("out.csv notanumber").is_err());
2189    }
2190
2191    /// A minimal starter model with one placeholder input measure, so
2192    /// `App::new` succeeds (it requires >= 1 measure) before import-csv adds
2193    /// the real one under test.
2194    fn starter_model() -> Model {
2195        let mut m = Model::new();
2196        let cat = CategoryId(9);
2197        m.add_category(cat, "Placeholder");
2198        m.add_measure(Measure {
2199            id: MeasureId(1),
2200            name: Name("Placeholder".into()),
2201            value_type: ValueType::Number,
2202            categories: vec![],
2203            kind: MeasureKind::Input,
2204            description: None,
2205        });
2206        m
2207    }
2208
2209    #[test]
2210    fn import_command_end_to_end_adds_measure_and_cells() {
2211        let csv_path = tmp_path(".csv");
2212        let _guard = TmpFile(csv_path.clone());
2213        std::fs::write(
2214            &csv_path,
2215            "time,product,revenue\n2025,WidgetA,1000\n2025,WidgetB,500\n2026,WidgetA,1200\n",
2216        )
2217        .unwrap();
2218
2219        let mut app = App::new(starter_model()).unwrap();
2220        app.begin_import_csv();
2221        assert_eq!(app.command.as_ref().unwrap().0, CommandKind::ImportCsv);
2222        app.command.as_mut().unwrap().1 = format!(
2223            "{} 100 Revenue revenue time:1:Time product:2:Product",
2224            csv_path.display()
2225        );
2226        app.commit_command();
2227
2228        assert!(app.command.is_none(), "command mode exited");
2229        let status = app.status.clone().unwrap_or_default();
2230        assert!(status.contains("imported 3"), "got: {status}");
2231
2232        let m = app.model.measure_by_name("Revenue").unwrap();
2233        assert!(m.is_input());
2234        assert_eq!(app.model.inputs.len(), 3);
2235        // The new measure is now selectable/viewable through the App.
2236        assert!(app.measures.contains(&MeasureId(100)));
2237    }
2238
2239    /// An import that changes a measure a formula uses reports that the
2240    /// formulas no longer build, instead of a bare success (the derived
2241    /// measures would silently go blank). One that does not keeps them live.
2242    #[test]
2243    fn import_reports_formulas_it_breaks() {
2244        let csv_path = tmp_path(".csv");
2245        let _guard = TmpFile(csv_path.clone());
2246        std::fs::write(&csv_path, "region,v\nNorth,1\nSouth,2\n").unwrap();
2247        let import = |spec: &str| {
2248            let mut app = App::new(revenue_model()).unwrap();
2249            app.begin_import_csv();
2250            app.command.as_mut().unwrap().1 = format!("{} {spec}", csv_path.display());
2251            app.commit_command();
2252            app
2253        };
2254        // Price becomes Price[Region]: Revenue = Price * Quantity breaks.
2255        let app = import("100 Price v region:7:Region");
2256        let s = app.status.clone().unwrap();
2257        assert!(
2258            s.starts_with("imported 2 cell(s) into measure 'Price'; formulas not recomputed: "),
2259            "{s}"
2260        );
2261        assert!(app.engine.is_none());
2262        // A new measure leaves Revenue computing.
2263        let mut app = import("200 Sales v region:7:Region");
2264        assert_eq!(
2265            app.status.as_deref(),
2266            Some("imported 2 cell(s) into measure 'Sales'")
2267        );
2268        assert!(app.engine.is_some());
2269        view(&mut app, MeasureId(102));
2270        assert_eq!(app.grid.display_at(0, 0), "1000");
2271        // The viewed measure stays, re-paged from its first item.
2272        let mut app = App::new(model_with_empty_page()).unwrap();
2273        view(&mut app, MeasureId(300));
2274        app.page(1);
2275        assert_eq!(app.grid.pages[0].item_name, "South");
2276        app.begin_import_csv();
2277        app.command.as_mut().unwrap().1 =
2278            format!("{} 201 Other v region:4:Region", csv_path.display());
2279        app.commit_command();
2280        assert_eq!(
2281            app.status.as_deref(),
2282            Some("imported 2 cell(s) into measure 'Other'")
2283        );
2284        assert_eq!(app.grid.measure, MeasureId(300));
2285        assert_eq!(app.grid.pages[0].item_name, "North");
2286        assert_eq!(app.page_idx, vec![0]);
2287    }
2288
2289    #[test]
2290    fn import_command_reports_parse_error_and_exits_mode() {
2291        let mut app = App::new(starter_model()).unwrap();
2292        app.begin_import_csv();
2293        app.command.as_mut().unwrap().1 = "sales.csv notanumber Revenue revenue".to_string();
2294        app.commit_command();
2295        assert!(app.command.is_none());
2296        assert!(app
2297            .status
2298            .as_deref()
2299            .unwrap_or("")
2300            .starts_with("import-csv:"));
2301        // A well-formed line naming a missing file: the I/O error, no measure.
2302        let missing = tmp_path(".missing.csv");
2303        app.begin_import_csv();
2304        app.command.as_mut().unwrap().1 = format!("{} 100 Revenue v t:9:P", missing.display());
2305        app.commit_command();
2306        assert!(app.status.as_deref().unwrap().starts_with("import-csv: "));
2307        assert!(app.model.measure_by_name("Revenue").is_none());
2308    }
2309
2310    #[test]
2311    fn export_command_end_to_end_round_trips() {
2312        // First import into a fresh measure, then export it and read the file back.
2313        let csv_path = tmp_path(".csv");
2314        let _csv_guard = TmpFile(csv_path.clone());
2315        std::fs::write(
2316            &csv_path,
2317            "time,product,revenue\n2025,WidgetA,1000\n2026,WidgetA,1200\n",
2318        )
2319        .unwrap();
2320
2321        let mut app = App::new(starter_model()).unwrap();
2322        app.begin_import_csv();
2323        app.command.as_mut().unwrap().1 = format!(
2324            "{} 100 Revenue revenue time:1:Time product:2:Product",
2325            csv_path.display()
2326        );
2327        app.commit_command();
2328        assert!(app.status.as_deref().unwrap_or("").contains("imported 2"));
2329
2330        let out_path = tmp_path(".out.csv");
2331        let _out_guard = TmpFile(out_path.clone());
2332        app.begin_export_csv();
2333        app.command.as_mut().unwrap().1 = format!("{} 100", out_path.display());
2334        app.commit_command();
2335        assert!(app.command.is_none());
2336        let status = app.status.clone().unwrap_or_default();
2337        assert!(status.contains("exported 2"), "got: {status}");
2338
2339        let contents = std::fs::read_to_string(&out_path).unwrap();
2340        assert!(contents.contains("Time"));
2341        assert!(contents.contains("Product"));
2342        assert!(contents.contains("Revenue"));
2343        assert_eq!(contents.lines().count(), 3); // header + 2 rows
2344    }
2345
2346    #[test]
2347    fn export_command_reports_error_for_unknown_measure() {
2348        let mut app = App::new(starter_model()).unwrap();
2349        app.begin_export_csv();
2350        app.command.as_mut().unwrap().1 = "out.csv 999".to_string();
2351        app.commit_command();
2352        assert!(app.command.is_none());
2353        assert!(app
2354            .status
2355            .as_deref()
2356            .unwrap_or("")
2357            .starts_with("export-csv:"));
2358    }
2359
2360    #[test]
2361    fn cancel_command_clears_state_without_side_effects() {
2362        let mut app = App::new(starter_model()).unwrap();
2363        app.begin_import_csv();
2364        app.command.as_mut().unwrap().1 = "whatever".to_string();
2365        app.cancel_command();
2366        assert!(app.command.is_none());
2367        assert!(app.status.is_none());
2368    }
2369}