perf(grid): skip hidden row runs and keep sheet geometry and CF statistics between frames (#3580) (#3629)

A sheet whose filter hides a million rows walked every hidden row
several times per frame (cells, gridlines, headers), rebuilt the row
index, the outline and the used range every frame, and a conditional
format over a whole column rescanned it every frame for its statistics.

- LineIndex::next_visible / visible step over a run of hidden lines in
  one O(log n) search; the grid's row and column loops use them.
- The grid keeps the active sheet's line index, outlines, used range and
  CfCache between frames, tied to the sheet by a Weak: any change goes
  through Arc::make_mut, which moves or copies a sheet that has a weak
  reference, so the cache can't outlive a change.
- Scroll offsets for the scrollbars and the scroll limit come from the
  line index instead of walking every custom row.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-11-09 08:46:53 +01:00
1 parent 76416d4869
commit 7102429a9a
6 files changed
+294 -37

No files matched your search

+98 -1
View File
@@ -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<u32> {
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<Item = u32> + '_ {
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<u32> = (first..=last).filter(|&l| size(l) > 0.0).collect();
assert_eq!(index.visible(first, last).collect::<Vec<_>>(), 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<u32, LineInfo> = (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::<Vec<_>>(), [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");
+8
View File
@@ -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<Sheet>, 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<Sheet>, CfCache)>,
pub drag: Drag,
pub ensure_visible: bool,
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
+57 -10
View File
@@ -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<LineIndex>,
pub(super) ci: std::sync::Arc<LineIndex>,
/// The sheet's used range, from [`SheetLines`].
pub(super) used: Option<RangeRef>,
/// Page Layout view: cells sit on pages (`scroll` is then in page-layout units).
pub paper: Option<Arc<gridcraft_engine::paper::PageGrid>>,
}
/// 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<LineIndex>,
pub(super) ci: Arc<LineIndex>,
pub(super) row_outline: Arc<crate::outline::Axis>,
pub(super) col_outline: Arc<crate::outline::Axis>,
/// The sheet's used range (it walks every stored row): how far the scrollbars reach.
pub(super) used: Option<RangeRef>,
}
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<gridcraft_engine::paper::PageGrid>) -> 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<gridcraft_engine::paper::PageGrid>, 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<Item = u32> + '_ {
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<Item = u32> + '_ {
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;
+7 -17
View File
@@ -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<CellRef> {
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<u32> = geo.shown_cols(c0, c1).collect();
let mut cells: Vec<CellRef> = 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<CfLook>)> = 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() {
+41 -9
View File
@@ -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<super::geo::SheetLines> {
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<Sheet>, 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;
}
+83
View File
@@ -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);
}