Perf: prefix-sum LineIndex for grid geometry (O(log n) with many custom row heights)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:36:28 -04:00
parent b8ff9648a9
commit 2618a233bf
4 changed files with 109 additions and 28 deletions

View File

@ -83,7 +83,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
id,
kind: ShapeKind::Ink,
anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 },
fill: Color::from_hex(&format!("#{:02X}0000", (width.round() as u8).min(255))).unwrap_or_default(),
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
};

View File

@ -14,7 +14,7 @@ pub mod workbook;
pub use cell::{Cell, Formula};
pub use features::*;
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, Sheet, Visibility};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
pub use store::CellStore;
pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,

View File

@ -209,6 +209,67 @@ impl Sheet {
}
}
/// Prefix sums over the custom-sized lines of an axis: O(log n) position ↔ line lookups even
/// when every row has an explicit height (common in imported files).
#[derive(Clone, Debug, Default)]
pub struct LineIndex {
default: f64,
max: u32,
/// (line, start position of that line, its size), sorted by line.
lines: Vec<(u32, f64, f64)>,
}
impl LineIndex {
pub fn new(custom: &BTreeMap<u32, LineInfo>, default: f32, max: u32) -> LineIndex {
let default = default as f64;
let mut lines = Vec::with_capacity(custom.len());
let mut pos = 0.0;
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) };
lines.push((line, pos, size));
pos += size;
prev = line + 1;
}
LineIndex { default, max, lines }
}
/// Start position of `line`.
pub fn start(&self, line: u32) -> f64 {
match self.lines.binary_search_by(|e| e.0.cmp(&line)) {
Ok(i) => self.lines.get(i).map_or(0.0, |e| e.1),
Err(0) => line as f64 * self.default,
Err(i) => match self.lines.get(i - 1) {
Some(&(l, p, s)) => p + s + (line - l - 1) as f64 * self.default,
None => line as f64 * self.default,
},
}
}
/// The line containing `pos`.
pub fn at(&self, pos: f64) -> u32 {
if pos <= 0.0 {
return 0;
}
// Last custom line starting at or before pos.
let i = self.lines.partition_point(|e| e.1 <= pos);
let (base_line, base_pos) = match i.checked_sub(1).and_then(|k| self.lines.get(k)) {
Some(&(l, p, s)) => {
if pos < p + s {
return l.min(self.max - 1);
}
(l + 1, p + s)
}
None => (0, 0.0),
};
if self.default <= 0.0 {
return base_line.min(self.max - 1);
}
let k = ((pos - base_pos) / self.default).floor();
let r = base_line as f64 + k;
if r >= self.max as f64 { self.max - 1 } else { r as u32 }
}
}
/// Inverse of the cumulative size along an axis with sparse custom sizes.
fn line_at(pos: f64, default: f64, custom: &BTreeMap<u32, LineInfo>, max: u32) -> u32 {
if pos <= 0.0 {
@ -243,6 +304,21 @@ fn line_at(pos: f64, default: f64, custom: &BTreeMap<u32, LineInfo>, max: u32) -
mod tests {
use super::*;
#[test]
fn line_index_matches_linear() {
let mut s = Sheet::new("S");
for r in (0..5000).step_by(3) {
s.rows.insert(r, LineInfo { size: Some(10.0 + (r % 7) as f32), hidden: r % 11 == 0, ..Default::default() });
}
let idx = LineIndex::new(&s.rows, s.default_row_height, sheetcraft_core::MAX_ROWS);
for r in [0u32, 1, 2, 3, 4, 100, 999, 4998, 5000, 6000] {
assert!((idx.start(r) - s.row_top(r)).abs() < 1e-6, "start {r}");
}
for y in [0.0, 5.0, 33.3, 1000.0, 47_000.0, 90_000.0] {
assert_eq!(idx.at(y), s.row_at(y), "at {y}");
}
}
#[test]
fn geometry() {
let mut s = Sheet::new("S");

View File

@ -9,7 +9,7 @@ use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stro
use serde_json::json;
use sheetcraft_engine::cf::{CfCache, CfLook};
use sheetcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
use sheetcraft_engine::model::{BorderStyle, Color, HAlign, PatternType, Sheet, Style, Underline, VAlign, Workbook};
use sheetcraft_engine::model::{BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
use crate::SheetApp;
use crate::editor::{EditState, REF_COLORS};
@ -96,7 +96,7 @@ impl GridState {
}
/// Geometry of the grid for one frame (handles zoom and frozen panes).
#[derive(Clone, Copy)]
#[derive(Clone)]
pub struct Geo {
pub rect: Rect,
pub cells: Rect,
@ -108,6 +108,9 @@ pub struct Geo {
pub frozen_w: f32,
pub frozen_h: f32,
pub scroll: egui::Vec2,
/// Prefix sums over custom row heights / column widths, built once per frame.
ri: std::sync::Arc<LineIndex>,
ci: std::sync::Arc<LineIndex>,
}
impl Geo {
@ -115,27 +118,29 @@ impl 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 last_row = sh.row_at((scroll.y + rect.height() / z) as f64 + 200.0) + 1;
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 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 = (sh.col_left(fc) as f32) * z;
let frozen_h = (sh.row_top(fr) as f32) * z;
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll }
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci }
}
pub fn x(&self, sh: &Sheet, col: u32) -> f32 {
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
if col < self.fc {
self.cells.left() + sh.col_left(col) as f32 * self.z
self.cells.left() + self.ci.start(col) as f32 * self.z
} else {
self.cells.left() + self.frozen_w + (sh.col_left(col) as f32 - sh.col_left(self.fc) as f32 - self.scroll.x) * self.z
self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z
}
}
pub fn y(&self, sh: &Sheet, row: u32) -> f32 {
pub fn y(&self, _sh: &Sheet, row: u32) -> f32 {
if row < self.fr {
self.cells.top() + sh.row_top(row) as f32 * self.z
self.cells.top() + self.ri.start(row) as f32 * self.z
} else {
self.cells.top() + self.frozen_h + (sh.row_top(row) as f32 - sh.row_top(self.fr) as f32 - self.scroll.y) * self.z
self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z
}
}
pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
@ -149,36 +154,36 @@ impl Geo {
let b = self.cell_rect(sh, r.end);
Rect::from_min_max(a.min, b.max)
}
pub fn col_at(&self, sh: &Sheet, x: f32) -> u32 {
pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 {
let dx = (x - self.cells.left()) / self.z;
if dx < self.frozen_w / self.z {
sh.col_at(dx.max(0.0) as f64)
self.ci.at(dx.max(0.0) as f64)
} else {
sh.col_at(sh.col_left(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64)
self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64)
}
}
pub fn row_at(&self, sh: &Sheet, y: f32) -> u32 {
pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 {
let dy = (y - self.cells.top()) / self.z;
if dy < self.frozen_h / self.z {
sh.row_at(dy.max(0.0) as f64)
self.ri.at(dy.max(0.0) as f64)
} else {
sh.row_at(sh.row_top(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64)
self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64)
}
}
pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef {
CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x))
}
/// Visible scrolled rows (from the first visible row after the frozen ones).
fn scroll_rows(&self, sh: &Sheet) -> (u32, u32) {
let top = sh.row_top(self.fr) + self.scroll.y as f64;
let r0 = sh.row_at(top).max(self.fr);
let r1 = sh.row_at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1);
fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) {
let top = self.ri.start(self.fr) + self.scroll.y as f64;
let r0 = self.ri.at(top).max(self.fr);
let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1);
(r0, r1)
}
fn scroll_cols(&self, sh: &Sheet) -> (u32, u32) {
let left = sh.col_left(self.fc) + self.scroll.x as f64;
let c0 = sh.col_at(left).max(self.fc);
let c1 = sh.col_at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1);
fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) {
let left = self.ci.start(self.fc) + self.scroll.x as f64;
let c0 = self.ci.at(left).max(self.fc);
let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1);
(c0, c1)
}
}