diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 440647d42..409e93023 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -566,7 +566,9 @@ impl LineIndex { let mut prev = 0u32; for (&line, info) in custom { pos += (line - prev) as f64 * default; - let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64) }; + // A negative size (only a malformed file has one) counts as zero, so positions only + // grow and the searches below stay valid. + let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64).max(0.0) }; lines.push((line, pos, size)); pos += size; prev = line + 1; @@ -584,6 +586,44 @@ impl LineIndex { }, } } + /// The first line at or after `line` (and before the axis end) with a size above zero, or + /// `None` when every line from there on is hidden or zero-sized. O(log n): a run of hidden + /// lines all start where the next shown line starts, so one search steps over the whole run. + pub fn next_visible(&self, line: u32) -> Option { + if line >= self.max { + return None; + } + let i = self.lines.partition_point(|e| e.0 < line); + let custom = self.lines.get(i).is_some_and(|e| e.0 == line); + if !custom && self.default > 0.0 { + return Some(line); + } + // Every line from `line` up to the answer is zero-sized, so they all start at `p`; the + // last custom line starting at `p` either has a size (the answer) or is the end of the run. + let p = self.start(line); + let k = self.lines.partition_point(|e| e.1 <= p); + let &(last, _, size) = self.lines.get(k.checked_sub(1)?)?; + if last < line { + return None; + } + if size > 0.0 { + return Some(last).filter(|l| *l < self.max); + } + // The line after the run has no custom line (a custom one would start at `p` too and be + // part of the run): it is shown when the default size is. + let next = last.checked_add(1).filter(|n| *n < self.max)?; + (self.default > 0.0).then_some(next) + } + /// The lines from `first` to `last` (inclusive) with a size above zero, in order. Runs of + /// hidden lines cost one O(log n) step each, not one per line. + pub fn visible(&self, first: u32, last: u32) -> impl Iterator + '_ { + let mut next = self.next_visible(first); + std::iter::from_fn(move || { + let line = next.filter(|l| *l <= last)?; + next = line.checked_add(1).and_then(|n| self.next_visible(n)); + Some(line) + }) + } /// The line containing `pos`. pub fn at(&self, pos: f64) -> u32 { if pos <= 0.0 { @@ -644,6 +684,63 @@ mod tests { use super::*; use crate::cell::Formula; + /// `LineIndex::visible` lists exactly the lines a line-by-line size check shows, for hidden + /// runs at the start, in the middle and up to the end of the axis, custom sizes (zero and + /// negative included), lines with only a style, and a zero default size. + #[test] + fn visible_lines_match_a_line_by_line_check() { + let max = 400u32; + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + for case in 0..300 { + let mut custom = BTreeMap::new(); + let runs = rnd(6); + for _ in 0..runs { + let start = rnd(u64::from(max)) as u32; + let len = rnd(if case % 3 == 0 { 400 } else { 30 }) as u32; + for l in start..(start + len).min(max) { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + // Runs that start at the first line or end at the last one. + if case % 5 == 0 { + for l in 0..rnd(20) as u32 { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + if case % 5 == 1 { + for l in max - rnd(20) as u32 - 1..max { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + for _ in 0..rnd(8) { + let size = [Some(0.0), Some(3.0), Some(20.0), Some(-5.0), None][rnd(5) as usize]; + custom.insert(rnd(u64::from(max)) as u32, LineInfo { size, ..Default::default() }); + } + let default = if case % 7 == 0 { 0.0 } else { 15.0 }; + let size = |l: u32| match custom.get(&l) { + Some(i) if i.hidden => 0.0, + Some(LineInfo { size: Some(s), .. }) => s.max(0.0), + _ => default, + }; + let index = LineIndex::new(&custom, default, max); + for (first, last) in [(0, max - 1), (rnd(u64::from(max)) as u32, max - 1), (5, 5), (max - 1, max - 1)] { + let want: Vec = (first..=last).filter(|&l| size(l) > 0.0).collect(); + assert_eq!(index.visible(first, last).collect::>(), want, "case {case}, {first}..={last}"); + } + assert_eq!(index.next_visible(max), None); + } + // The row after a million hidden rows comes straight after the last shown one. + let custom: BTreeMap = (47..1_048_422).map(|r| (r, LineInfo { hidden: true, ..Default::default() })).collect(); + let index = LineIndex::new(&custom, 15.0, 1_048_576); + assert_eq!(index.visible(40, 1_048_424).collect::>(), [40, 41, 42, 43, 44, 45, 46, 1_048_422, 1_048_423, 1_048_424]); + } + #[test] fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() { let a = Sheet::new("A"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index f0ae6270e..1a5d71621 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -235,6 +235,14 @@ impl Trace { #[derive(Default)] pub struct GridState { + /// The active sheet's line positions and outlines, kept with a weak reference to the sheet + /// they were built from: while it still upgrades to the very sheet shown, the sheet hasn't + /// changed (every change goes through `Arc::make_mut`, which moves or copies a sheet that + /// has a weak reference to it), so they still hold. + pub(crate) lines: Option<(std::sync::Weak, geo::SheetLines)>, + /// The conditional formatting statistics (minimum, maximum, percentiles, duplicates) of the + /// active sheet's rules, kept the same way: they are read from that sheet's cells alone. + pub(crate) cf: Option<(std::sync::Weak, CfCache)>, pub drag: Drag, pub ensure_visible: bool, /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs index a1005b32f..7786f4048 100644 --- a/crates/ui-egui/src/grid/geo.rs +++ b/crates/ui-egui/src/grid/geo.rs @@ -24,28 +24,60 @@ pub struct Geo { /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). pub rtl: bool, - /// Prefix sums over custom row heights / column widths, built once per frame. + /// Prefix sums over custom row heights / column widths, from [`SheetLines`]. pub(super) ri: std::sync::Arc, pub(super) ci: std::sync::Arc, + /// The sheet's used range, from [`SheetLines`]. + pub(super) used: Option, /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). pub paper: Option>, } +/// The parts of the grid's geometry that depend on the sheet alone: where each row and column +/// starts, and the row and column outlines. Building them walks every row and column that has a +/// size, a style or an outline level (a million rows in a file whose filter hides most of them), +/// so the grid keeps them while the sheet stays the same (`show::sheet_lines`). +#[derive(Clone)] +pub struct SheetLines { + pub(super) ri: Arc, + pub(super) ci: Arc, + pub(super) row_outline: Arc, + pub(super) col_outline: Arc, + /// The sheet's used range (it walks every stored row): how far the scrollbars reach. + pub(super) used: Option, +} + +impl SheetLines { + pub fn of(sh: &Sheet) -> SheetLines { + SheetLines { + ri: Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)), + ci: Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)), + row_outline: Arc::new(crate::outline::Axis::of(sh, true)), + col_outline: Arc::new(crate::outline::Axis::of(sh, false)), + used: sh.used_range(), + } + } +} + impl Geo { pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { + Geo::with_lines(sh, rect, scroll, &SheetLines::of(sh)) + } + /// [`Geo::new`] with the sheet's `lines` already built (`SheetLines::of(sh)`). + pub fn with_lines(sh: &Sheet, rect: Rect, scroll: egui::Vec2, lines: &SheetLines) -> Geo { let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); let z = zoom * DISPLAY_SCALE; let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; - let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); - let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); + let ri = lines.ri.clone(); + let ci = lines.ci.clone(); let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; let digits = (last_row as f32).log10().floor() as i32 + 1; let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; let (fr, fc) = sh.freeze.unwrap_or((0, 0)); let frozen_w = (ci.start(fc) as f32) * z; let frozen_h = (ri.start(fr) as f32) * z; - let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); - let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); + let row_outline = lines.row_outline.clone(); + let col_outline = lines.col_outline.clone(); // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). let symbols = sh.show_outline_symbols; let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; @@ -76,6 +108,7 @@ impl Geo { rtl: sh.right_to_left, ri, ci, + used: lines.used, paper: None, } } @@ -90,7 +123,11 @@ impl Geo { /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { - let mut g = Geo::new(sh, rect, scroll); + Geo::paged_with_lines(sh, rect, scroll, grid, &SheetLines::of(sh)) + } + /// [`Geo::paged`] with the sheet's `lines` already built. + pub fn paged_with_lines(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc, lines: &SheetLines) -> Geo { + let mut g = Geo::with_lines(sh, rect, scroll, lines); let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); @@ -111,17 +148,17 @@ impl Geo { } } /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. - pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + pub fn scroll_top_of(&self, _sh: &Sheet, row: u32) -> f32 { match &self.paper { Some(pg) => pg.row_y(row) as f32, - None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + None => (self.ri.start(row) - self.ri.start(self.fr)) as f32, } } /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. - pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + pub fn scroll_left_of(&self, _sh: &Sheet, col: u32) -> f32 { match &self.paper { Some(pg) => pg.col_x(col) as f32, - None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + None => (self.ci.start(col) - self.ci.start(self.fc)) as f32, } } /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. @@ -204,6 +241,16 @@ impl Geo { pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) } + /// The rows from `r0` to `r1` (inclusive) with a height above zero, in order. A run of + /// hidden rows costs one O(log n) step, so a filter that hides a million rows doesn't make + /// every frame walk them. + pub(super) fn shown_rows(&self, r0: u32, r1: u32) -> impl Iterator + '_ { + self.ri.visible(r0, r1) + } + /// The columns from `c0` to `c1` (inclusive) with a width above zero (see `shown_rows`). + pub(super) fn shown_cols(&self, c0: u32, c1: u32) -> impl Iterator + '_ { + self.ci.visible(c0, c1) + } /// Visible scrolled rows (from the first visible row after the frozen ones). pub(super) fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { let top = self.ri.start(self.fr) + self.scroll.y as f64; diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index 7461c2204..e209e4f4a 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -150,12 +150,12 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) { let bottom = geo.y_end(sh, r1); let left = geo.x(sh, c0); let right = geo.x_end(sh, c1); - let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]); + let xs = geo.shown_cols(c0, c1).map(|c| geo.x(sh, c)).chain([right]); for x in xs { let x = x.round() - 0.5; p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); } - let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]); + let ys = geo.shown_rows(r0, r1).map(|r| geo.y(sh, r)).chain([bottom]); for y in ys { let y = y.round() - 0.5; p.line_segment([pos2(left, y), pos2(right, y)], stroke); @@ -164,21 +164,14 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) { } fn visible_cells(q: &QuadrantPaint<'_>) -> Vec { - let sh = q.sheet; + let geo = q.geo; let (r0, r1) = q.rows; let (c0, c1) = q.cols; // Merged areas: blank them (no inner gridlines) and paint as one cell. + let cols: Vec = geo.shown_cols(c0, c1).collect(); let mut cells: Vec = Vec::new(); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - for c in c0..=c1 { - if sh.col_width(c) == 0.0 { - continue; - } - cells.push(CellRef::new(r, c)); - } + for r in geo.shown_rows(r0, r1) { + cells.extend(cols.iter().map(|&c| CellRef::new(r, c))); } cells } @@ -225,10 +218,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } + for r in geo.shown_rows(r0, r1) { let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); for n in [left, right].into_iter().flatten() { diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index c1c9011e7..34b76c982 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -15,6 +15,33 @@ use super::{ #[cfg(test)] use super::{CellRef, Pos2}; +/// The line positions and outlines of sheet `si`: the ones built for it before while it is the +/// same sheet, else built now and kept (see `GridState::lines`). +fn sheet_lines(app: &mut SheetApp, wb: &Workbook, si: usize) -> Option { + let sheet = wb.sheets.get(si)?; + if let Some((built_from, lines)) = &app.grid.lines + && is_sheet(built_from, wb, si) + { + return Some(lines.clone()); + } + let lines = super::geo::SheetLines::of(sheet); + app.grid.lines = Some((Arc::downgrade(sheet), lines.clone())); + Some(lines) +} + +/// Whether `built_from` still is sheet `si` of `wb`, unchanged (see `GridState::lines`). +fn is_sheet(built_from: &std::sync::Weak, wb: &Workbook, si: usize) -> bool { + wb.sheets.get(si).is_some_and(|s| built_from.upgrade().is_some_and(|b| Arc::ptr_eq(&b, s))) +} + +/// The conditional formatting cache kept for sheet `si` while it is unchanged, else a new one. +fn kept_cf(app: &mut SheetApp, wb: &Workbook, si: usize) -> CfCache { + match app.grid.cf.take() { + Some((built_from, cf)) if is_sheet(&built_from, wb, si) => cf, + _ => CfCache::new(), + } +} + pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let available = ui.available_rect_before_wrap(); @@ -44,15 +71,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else { Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) }; + let Some(lines) = sheet_lines(app, &wb, si) else { return }; let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { if paper { - let probe = Geo::new(sh, grid_rect, scroll); + let probe = Geo::with_lines(sh, grid_rect, scroll, &lines); if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); - return Geo::paged(sh, grid_rect, scroll, g); + return Geo::paged_with_lines(sh, grid_rect, scroll, g, &lines); } } - Geo::new(sh, rect, scroll) + Geo::with_lines(sh, rect, scroll, &lines) }; // Scrolling. let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); @@ -115,7 +143,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } update_zoom_glide(app, ui, place, sh.zoom, now); view.scroll = view.scroll.max(egui::Vec2::ZERO); - let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + let (mut max_x, mut max_y) = (lines.ci.start(MAX_COLS - 20) as f32, lines.ri.start(MAX_ROWS - 40) as f32); if paper { // Page layout units run past sheet units (margins and gaps); the end is known once the // pages reach the last row / column. @@ -146,7 +174,10 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { painter.rect_filled(rect, 0.0, t.grid_bg); // Everything up to the headers is painted in logical (left-to-right) coordinates. let mirror_from = painter.add(egui::Shape::Noop); - let mut cf = CfCache::new(); + // A rule over a whole column scans it for its statistics: done once per change of the sheet, + // not every frame. + let cf_sheet = wb.sheets.get(si).map(Arc::downgrade); + let mut cf = kept_cf(app, &wb, si); let quads = if geo.paper.is_some() { crate::paper_view::paint_pages( crate::paper_view::PagePaintInput { @@ -168,6 +199,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { &mut cf, ) }; + app.grid.cf = cf_sheet.map(|s| (s, cf)); // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act // on something the user can't see. if crate::chartview::objects_hidden(&wb) { @@ -448,7 +480,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } let visible = track.height() / geo.z; let scroll = app.view().scroll.y; - let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let last = geo.used.map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); let content = geo.scroll_top_of(sh, last); let extent = (content - visible).max(scroll).max(0.0).min(limit); let id = ui.scope_id().with("vscroll"); @@ -501,7 +533,7 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: let Some(sh) = wb.active() else { return }; let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); let scroll = app.view().scroll.x; - let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let last = geo.used.map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); let content = geo.scroll_left_of(sh, last); let extent = (content - visible).max(scroll).max(0.0); let id = ui.scope_id().with("hscroll"); @@ -574,7 +606,7 @@ pub(crate) fn paint_headers( for (rows, cols, clip) in quads { let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); if cols.0 <= cols.1 { - for c in cols.0..=cols.1 { + for c in geo.shown_cols(cols.0, cols.1) { if !done_cols.insert((c, clip.left() as i32)) { continue; } @@ -615,7 +647,7 @@ pub(crate) fn paint_headers( } let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); if rows.0 <= rows.1 { - for rr in rows.0..=rows.1 { + for rr in geo.shown_rows(rows.0, rows.1) { if !done_rows.insert((rr, clip.top() as i32)) { continue; } diff --git a/crates/ui-egui/src/grid/tests.rs b/crates/ui-egui/src/grid/tests.rs index 427c67629..be3ca6eb5 100644 --- a/crates/ui-egui/src/grid/tests.rs +++ b/crates/ui-egui/src/grid/tests.rs @@ -350,3 +350,86 @@ fn upward_text_with_middle_align_is_centred_in_a_merged_block() { assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); } + +/// One grid frame of `app` in a headless context. +fn grid_frame(app: &mut crate::SheetApp) { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::show(app, ui)); + out.textures_delta.clear(); // nothing consumes the uploads in a headless test +} + +/// The line positions, used range and conditional formatting statistics the grid keeps between +/// frames are rebuilt after every change of the sheet, whichever way it is changed, and match +/// what a fresh build gives. +#[test] +fn kept_sheet_geometry_and_cf_statistics_follow_every_change() { + use gridcraft_engine::cf::CfCache; + use gridcraft_engine::core::CellRef; + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = crate::SheetApp::new(session, Default::default()); + app.run("range.setValues", json!({"range": "A1:A3", "values": [[1], [2], [3]]})).unwrap(); + app.run("home.conditionalFormat", json!({"range": "A:A", "rule": {"type": "colorScale", "colors": ["#FFFFFF", "#FF0000"]}})).unwrap(); + let kept = |app: &crate::SheetApp| app.grid.lines.as_ref().map(|(_, l)| (Arc::clone(&l.ri), l.used)).unwrap(); + // The kept statistics give the look a fresh cache gives. + let check_cf = |app: &mut crate::SheetApp| { + let wb = app.session.active().unwrap().wb.clone(); + for row in 0..4 { + let c = CellRef::new(row, 0); + let kept = app.grid.cf.as_mut().map(|(_, cf)| cf.look(&wb, 0, c)); + assert_eq!(kept, Some(CfCache::new().look(&wb, 0, c)), "A{}", row + 1); + } + }; + grid_frame(&mut app); + let (ri, used) = kept(&app); + assert_eq!(used.map(|r| r.a1()).as_deref(), Some("A1:A3")); + check_cf(&mut app); + grid_frame(&mut app); + assert!(Arc::ptr_eq(&kept(&app).0, &ri), "an unchanged sheet keeps its line positions"); + + // A command: the new value moves the colour scale's maximum and the used range. + app.run("cell.set", json!({"cell": "A5", "input": "30"})).unwrap(); + grid_frame(&mut app); + assert_eq!(kept(&app).1.map(|r| r.a1()).as_deref(), Some("A1:A5")); + check_cf(&mut app); + // Hiding a row moves the rows under it. + app.run("selection.set", json!({"range": "A2"})).unwrap(); + app.run("home.hideRows", json!({})).unwrap(); + grid_frame(&mut app); + let geo = app.grid.geo.clone().unwrap(); + let sh = app.session.active().unwrap().wb.sheet(0).unwrap().clone(); + assert_eq!(geo.y(&sh, 2), geo.y(&sh, 1), "row 2 is hidden"); + assert!(!Arc::ptr_eq(&kept(&app).0, &ri)); + // Undo brings the row back. + app.run("edit.undo", json!({})).unwrap(); + grid_frame(&mut app); + let geo = app.grid.geo.clone().unwrap(); + let sh = app.session.active().unwrap().wb.sheet(0).unwrap().clone(); + assert!(geo.y(&sh, 2) > geo.y(&sh, 1), "row 2 is shown again"); + let fresh = super::geo::SheetLines::of(&sh); + for row in [0, 1, 2, 3, 100, 1_048_575] { + assert_eq!(kept(&app).0.start(row), fresh.ri.start(row), "row {row}"); + } + assert_eq!(kept(&app).1, fresh.used); + + // A change with nothing else holding the sheet (no undo step, no saved copy): the grid's weak + // reference makes `Arc::make_mut` move the sheet to a new allocation, and the grid notices. + let ri = kept(&app).0; + { + let d = app.session.active_mut().unwrap(); + d.undo.clear(); + d.redo.clear(); + d.saved = Arc::new(gridcraft_engine::model::Workbook::default()); + let sheet = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap(); + sheet.rows.entry(0).or_default().hidden = true; + sheet.rows.entry(1).or_default().hidden = true; + sheet.set_value(CellRef::new(2, 0), gridcraft_engine::core::Value::Number(300.0)); + } + grid_frame(&mut app); + let geo = app.grid.geo.clone().unwrap(); + let sh = app.session.active().unwrap().wb.sheet(0).unwrap().clone(); + assert_eq!(geo.y(&sh, 2), geo.y(&sh, 0), "rows 1 and 2 are hidden"); + assert!(!Arc::ptr_eq(&kept(&app).0, &ri)); + check_cf(&mut app); +}