diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 1b8e1de..243d580 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -83,7 +83,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { 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, }; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 2049034..f454f8f 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 4627f89..cf542b7 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -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, 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, max: u32) -> u32 { if pos <= 0.0 { @@ -243,6 +304,21 @@ fn line_at(pos: f64, default: f64, custom: &BTreeMap, 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"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 7f84a93..3688c5f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -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, + ci: std::sync::Arc, } 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) } }