Skip to main content

fc_grid/
lib.rs

1//! fc-grid — grid, cell, and selection model for Office-like surfaces.
2//!
3//! # Why this is in Rust
4//!
5//! Rendering is not the expensive part of a large grid; the browser is good at
6//! it, and virtualizing the DOM is a layout concern. The expensive part is the
7//! arithmetic around it: which cells intersect a scroll viewport, how a selection
8//! rectangle expands, how far a range extends past the last populated cell.
9//! Those are pure integer computations that run per frame during scroll and per
10//! keystroke during navigation -- exactly the tight loops where JS overhead
11//! dominates.
12//!
13//! # What is deliberately absent
14//!
15//! **Formulas.** Spreadsheet formula evaluation is a dependency graph with cycle
16//! detection, error propagation, and locale-dependent number formats -- tens of
17//! thousands of lines with a large external dependency. Folding that in would
18//! make this the largest module in the workspace and force every consumer of the
19//! selection model to download it. It belongs in a separate crate and a separate
20//! opt-in; see docs/WASM.md.
21//!
22//! **Rendering.** This crate never touches the DOM. It answers "what should be
23//! visible"; the JS layer decides how.
24
25use fc_core::FcError;
26use serde::{Deserialize, Serialize};
27#[cfg(feature = "bindgen")]
28use wasm_bindgen::prelude::*;
29
30/// A cell address. Row and column are zero-based.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct CellRef {
33    pub row: usize,
34    pub col: usize,
35}
36
37impl CellRef {
38    pub fn new(row: usize, col: usize) -> Self {
39        Self { row, col }
40    }
41}
42
43/// A rectangular selection, always stored normalized: `start` is the top-left
44/// (row-major minimum) and `end` the bottom-right, whichever corner the user
45/// dragged from.
46#[cfg_attr(feature = "bindgen", wasm_bindgen)]
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub struct CellRange {
49    pub start_row: usize,
50    pub start_col: usize,
51    pub end_row: usize,
52    pub end_col: usize,
53    /// Where the selection began, stored explicitly.
54    ///
55    /// Not derivable from the rectangle: normalization destroys it. Dragging
56    /// down-and-right makes the anchor the top-left; dragging up-and-left makes
57    /// it the bottom-right. Both are valid, and shift-arrow must extend from
58    /// whichever one the user actually started at.
59    #[serde(default)]
60    anchor_row: usize,
61    #[serde(default)]
62    anchor_col: usize,
63}
64
65impl CellRange {
66    /// Normalizes a possibly-inverted drag.
67    ///
68    /// Inversion is the normal case, not an error: dragging up-and-left from the
69    /// anchor produces an inverted range by definition. Normalizing here means
70    /// every downstream consumer can assume ordering.
71    pub fn from_corners(a: CellRef, b: CellRef) -> Self {
72        Self {
73            start_row: a.row.min(b.row),
74            start_col: a.col.min(b.col),
75            end_row: a.row.max(b.row),
76            end_col: a.col.max(b.col),
77            // `a` is the drag origin, which is the anchor by definition.
78            anchor_row: a.row,
79            anchor_col: a.col,
80        }
81    }
82
83    pub fn single(cell: CellRef) -> Self {
84        Self {
85            start_row: cell.row,
86            start_col: cell.col,
87            end_row: cell.row,
88            end_col: cell.col,
89            anchor_row: cell.row,
90            anchor_col: cell.col,
91        }
92    }
93
94    pub fn contains(&self, cell: CellRef) -> bool {
95        cell.row >= self.start_row
96            && cell.row <= self.end_row
97            && cell.col >= self.start_col
98            && cell.col <= self.end_col
99    }
100
101    pub fn row_count(&self) -> usize {
102        self.end_row - self.start_row + 1
103    }
104
105    pub fn col_count(&self) -> usize {
106        self.end_col - self.start_col + 1
107    }
108
109    /// Total cell count, saturating. WASM is a 32-bit platform, so a select-all
110    /// on a large sheet can overflow `usize`; saturating is the honest answer
111    /// because a number too large to count is not an actionable error.
112    pub fn cell_count(&self) -> u64 {
113        (self.row_count() as u64).saturating_mul(self.col_count() as u64)
114    }
115
116
117    /// A range with an explicit anchor, defaulting to the top-left corner.
118    pub fn with_anchor(mut self, cell: CellRef) -> Self {
119        self.anchor_row = cell.row;
120        self.anchor_col = cell.col;
121        self
122    }
123
124    /// The anchor cell.
125    pub fn anchor(&self) -> CellRef {
126        CellRef::new(self.anchor_row, self.anchor_col)
127    }
128
129    /// Extends to include `cell`, keeping the original anchor. This is
130    /// shift-arrow behaviour: focus moves, the anchor does not.
131    pub fn extend_to(&self, cell: CellRef) -> Self {
132        Self::from_corners(self.anchor(), cell).with_anchor(self.anchor())
133    }
134
135    /// The cells, in row-major order.
136    pub fn iter_cells(&self) -> Vec<CellRef> {
137        // Preallocated: this runs per keystroke, and repeated growth of a large
138        // vector is measurable in WASM, which has no generational GC to absorb it.
139        let cap = self.cell_count().min(65_536) as usize;
140        let mut out = Vec::with_capacity(cap);
141        for row in self.start_row..=self.end_row {
142            for col in self.start_col..=self.end_col {
143                out.push(CellRef::new(row, col));
144            }
145        }
146        out
147    }
148}
149
150/// A scroll viewport in cell coordinates, with a render margin.
151#[cfg_attr(feature = "bindgen", wasm_bindgen)]
152#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
153pub struct Viewport {
154    pub first_row: usize,
155    pub first_col: usize,
156    pub rows: usize,
157    pub cols: usize,
158    /// Extra rows/cols rendered beyond the visible edge, to absorb scroll jitter.
159    pub overscan: usize,
160}
161
162impl Viewport {
163    /// The range of cells actually visible.
164    pub fn visible_range(&self) -> CellRange {
165        // Saturating so a viewport near the end of a huge sheet does not
166        // overflow past the last row.
167        CellRange::from_corners(
168            CellRef::new(self.first_row, self.first_col),
169            CellRef::new(
170                self.first_row.saturating_add(self.rows.saturating_sub(1)),
171                self.first_col.saturating_add(self.cols.saturating_sub(1)),
172            ),
173        )
174    }
175
176    /// The range to render, including overscan.
177    pub fn render_range(&self) -> CellRange {
178        let v = self.visible_range();
179        CellRange::from_corners(
180            CellRef::new(
181                v.start_row.saturating_sub(self.overscan),
182                v.start_col.saturating_sub(self.overscan),
183            ),
184            CellRef::new(
185                v.end_row.saturating_add(self.overscan),
186                v.end_col.saturating_add(self.overscan),
187            ),
188        )
189    }
190
191    /// The cell holding keyboard focus.
192    pub fn active_cell(&self) -> CellRef {
193        CellRef::new(self.first_row, self.first_col)
194    }
195}
196
197/// A sparse grid of string cells.
198///
199/// Sparse because an Office-like document is mostly empty: a million-cell sheet
200/// with 900k empty cells must not allocate a million `String`s.
201#[cfg_attr(feature = "bindgen", wasm_bindgen)]
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct Grid {
204    cells: std::collections::HashMap<(usize, usize), String>,
205}
206
207impl Grid {
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    pub fn set(&mut self, cell: CellRef, value: String) {
213        if value.is_empty() {
214            // Storing an empty string would make "is this cell empty?" require a
215            // value check, and every consumer would have to remember it.
216            self.cells.remove(&(cell.row, cell.col));
217        } else {
218            self.cells.insert((cell.row, cell.col), value);
219        }
220    }
221
222    pub fn get(&self, cell: CellRef) -> Option<&str> {
223        self.cells.get(&(cell.row, cell.col)).map(String::as_str)
224    }
225
226    pub fn is_empty(&self) -> bool {
227        self.cells.is_empty()
228    }
229
230    pub fn len(&self) -> usize {
231        self.cells.len()
232    }
233
234    /// Number of populated cells inside a range.
235    pub fn count_in(&self, range: CellRange) -> usize {
236        self.cells
237            .keys()
238            .filter(|(r, c)| range.contains(CellRef::new(*r, *c)))
239            .count()
240    }
241
242    /// Highest populated row + 1.
243    pub fn used_rows(&self) -> usize {
244        self.cells.keys().map(|(r, _)| r + 1).max().unwrap_or(0)
245    }
246
247    /// Highest populated column + 1.
248    pub fn used_cols(&self) -> usize {
249        self.cells.keys().map(|(_, c)| c + 1).max().unwrap_or(0)
250    }
251
252    /// Fills `count` rows below a cell with the same value.
253    ///
254    /// Auto-fill. Bounded by `max_rows` so a malformed argument cannot spin
255    /// allocating millions of cells.
256    pub fn fill_down(&mut self, from: CellRef, count: usize, max_rows: usize) -> Result<usize, FcError> {
257        if count > max_rows {
258            return Err(FcError::InvalidInput {
259                message: format!("fill count {count} exceeds the {max_rows} row limit"),
260            });
261        }
262        let Some(value) = self.get(from).map(str::to_string) else {
263            return Err(FcError::InvalidInput {
264                message: "fill source cell is empty".to_string(),
265            });
266        };
267        for offset in 1..=count {
268            self.set(CellRef::new(from.row + offset, from.col), value.clone());
269        }
270        Ok(count)
271    }
272}
273// ---- JS bindings --------------------------------------------------------
274//
275// Ranges cross the boundary as flat `[startRow, startCol, endRow, endCol]`
276// arrays rather than as objects. Flat arrays allocate less per crossing, and this
277// runs once per keystroke in a large sheet.
278
279fn range_to_js(r: CellRange) -> Vec<u32> {
280    vec![r.start_row as u32, r.start_col as u32, r.end_row as u32, r.end_col as u32]
281}
282
283#[cfg_attr(feature = "bindgen", wasm_bindgen)]
284impl CellRange {
285    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = fromCorners))]
286    pub fn js_from_corners(r1: usize, c1: usize, r2: usize, c2: usize) -> CellRange {
287        CellRange::from_corners(CellRef::new(r1, c1), CellRef::new(r2, c2))
288    }
289
290    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = single))]
291    pub fn js_single(row: usize, col: usize) -> CellRange {
292        CellRange::single(CellRef::new(row, col))
293    }
294
295    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = toArray))]
296    pub fn js_to_array(&self) -> Vec<u32> {
297        range_to_js(*self)
298    }
299
300    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = cellCount))]
301    pub fn js_cell_count(&self) -> u64 {
302        self.cell_count()
303    }
304
305    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = contains))]
306    pub fn js_contains(&self, row: usize, col: usize) -> bool {
307        self.contains(CellRef::new(row, col))
308    }
309
310    /// Flat `[row, col, ...]` coordinates in row-major order.
311    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = cells))]
312    pub fn js_cells(&self) -> Vec<u32> {
313        self.iter_cells().into_iter().flat_map(|c| [c.row as u32, c.col as u32]).collect()
314    }
315
316}
317
318#[cfg_attr(feature = "bindgen", wasm_bindgen)]
319impl Viewport {
320    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = new))]
321    #[allow(clippy::too_many_arguments)]
322    pub fn js_new(
323        first_row: usize,
324        first_col: usize,
325        rows: usize,
326        cols: usize,
327        overscan: usize,
328    ) -> Viewport {
329        Viewport { first_row, first_col, rows, cols, overscan }
330    }
331
332    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = visibleRange))]
333    pub fn js_visible_range(&self) -> Vec<u32> {
334        range_to_js(self.visible_range())
335    }
336
337    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = renderRange))]
338    pub fn js_render_range(&self) -> Vec<u32> {
339        range_to_js(self.render_range())
340    }
341}
342
343#[cfg_attr(feature = "bindgen", wasm_bindgen)]
344impl Grid {
345    #[cfg_attr(feature = "bindgen", wasm_bindgen(constructor))]
346    pub fn js_new() -> Grid {
347        Grid::new()
348    }
349
350    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = set))]
351    pub fn js_set(&mut self, row: usize, col: usize, value: String) {
352        self.set(CellRef::new(row, col), value);
353    }
354
355    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = get))]
356    pub fn js_get(&self, row: usize, col: usize) -> Option<String> {
357        self.get(CellRef::new(row, col)).map(str::to_string)
358    }
359
360    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = cellCount))]
361    pub fn js_len(&self) -> usize {
362        self.len()
363    }
364
365    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = usedRows))]
366    pub fn js_used_rows(&self) -> usize {
367        self.used_rows()
368    }
369
370    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = usedCols))]
371    pub fn js_used_cols(&self) -> usize {
372        self.used_cols()
373    }
374
375    #[cfg(feature = "bindgen")]
376    #[wasm_bindgen(js_name = fillDown)]
377    pub fn js_fill_down(
378        &mut self,
379        row: usize,
380        col: usize,
381        count: usize,
382        max_rows: usize,
383    ) -> Result<usize, JsValue> {
384        self.fill_down(CellRef::new(row, col), count, max_rows).map_err(Into::into)
385    }
386
387    /// `count` populated cells inside a range.
388    #[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = countIn))]
389    pub fn js_count_in(&self, r1: usize, c1: usize, r2: usize, c2: usize) -> usize {
390        self.count_in(CellRange::from_corners(CellRef::new(r1, c1), CellRef::new(r2, c2)))
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn cell(row: usize, col: usize) -> CellRef {
399        CellRef::new(row, col)
400    }
401
402    #[test]
403    fn drag_is_normalized_regardless_of_direction() {
404        // Dragging up-and-left is the normal case, not an error.
405        let up_left = CellRange::from_corners(cell(5, 5), cell(1, 1));
406        assert_eq!(
407            (up_left.start_row, up_left.start_col, up_left.end_row, up_left.end_col),
408            (1, 1, 5, 5)
409        );
410    }
411
412    #[test]
413    fn single_cell_range_counts_one() {
414        let r = CellRange::single(cell(3, 3));
415        assert_eq!(r.cell_count(), 1);
416        assert_eq!(r.row_count(), 1);
417        assert_eq!(r.col_count(), 1);
418    }
419
420    #[test]
421    fn range_dimensions_are_inclusive() {
422        // Rows 0..=4 is five rows, not four. Off-by-one here is the classic
423        // spreadsheet bug where the last row is unreachable.
424        let r = CellRange::from_corners(cell(0, 0), cell(4, 4));
425        assert_eq!(r.row_count(), 5);
426        assert_eq!(r.col_count(), 5);
427        assert_eq!(r.cell_count(), 25);
428    }
429
430    #[test]
431    fn contains_respects_bounds() {
432        let r = CellRange::from_corners(cell(1, 1), cell(3, 3));
433        assert!(r.contains(cell(1, 1)), "top-left corner is inside");
434        assert!(r.contains(cell(3, 3)), "bottom-right corner is inside");
435        assert!(!r.contains(cell(0, 1)), "above is outside");
436        assert!(!r.contains(cell(4, 3)), "below is outside");
437    }
438
439    #[test]
440    fn extending_keeps_the_anchor() {
441        // shift-arrow: focus moves, the anchor does not.
442        let base = CellRange::single(cell(5, 5));
443        let extended = base.extend_to(cell(2, 8));
444        assert_eq!(extended.anchor(), cell(5, 5), "anchor must not move");
445        assert_eq!((extended.end_row, extended.end_col), (5, 8));
446    }
447
448    #[test]
449    fn extending_upward_still_produces_an_ordered_range() {
450        let extended = CellRange::single(cell(5, 5)).extend_to(cell(2, 2));
451        assert_eq!((extended.start_row, extended.start_col), (2, 2));
452        assert_eq!((extended.end_row, extended.end_col), (5, 5));
453    }
454
455    #[test]
456    fn huge_range_does_not_overflow() {
457        // WASM is 32-bit; u32::MAX squared must saturate, not wrap to a small
458        // or negative-looking number.
459        let huge = CellRange::from_corners(
460            CellRef::new(0, 0),
461            CellRef::new(usize::MAX - 1, usize::MAX - 1),
462        );
463        assert_eq!(huge.cell_count(), u64::MAX);
464    }
465
466    #[test]
467    fn cells_enumerate_in_row_major_order() {
468        let r = CellRange::from_corners(cell(0, 0), cell(1, 1));
469        let cells = r.iter_cells();
470        assert_eq!(cells.len(), 4);
471        assert_eq!(cells[0], cell(0, 0));
472        assert_eq!(cells[1], cell(0, 1));
473        assert_eq!(cells[2], cell(1, 0));
474        assert_eq!(cells[3], cell(1, 1));
475    }
476
477    #[test]
478    fn viewport_visible_range_is_sized_correctly() {
479        let vp = Viewport { first_row: 10, first_col: 5, rows: 20, cols: 4, overscan: 2 };
480        let v = vp.visible_range();
481        assert_eq!((v.start_row, v.end_row), (10, 29), "20 rows inclusive");
482        assert_eq!((v.start_col, v.end_col), (5, 8), "4 cols inclusive");
483    }
484
485    #[test]
486    fn viewport_overscan_expands_but_clamps_at_zero() {
487        let vp = Viewport { first_row: 1, first_col: 1, rows: 10, cols: 10, overscan: 5 };
488        // Subtracting 5 from row 1 must not wrap to a huge usize.
489        assert_eq!(vp.render_range().start_row, 0);
490    }
491
492    #[test]
493    fn zero_sized_viewport_does_not_underflow() {
494        // rows: 0 would make `rows - 1` wrap without saturating_sub.
495        let vp = Viewport { first_row: 5, first_col: 5, rows: 0, cols: 0, overscan: 0 };
496        let v = vp.visible_range();
497        assert_eq!(v.end_row, 5);
498        assert_eq!(v.end_col, 5);
499    }
500
501    #[test]
502    fn sparse_grid_stores_only_populated_cells() {
503        let mut g = Grid::new();
504        assert!(g.is_empty());
505        g.set(cell(1000, 1000), "x".into());
506        assert_eq!(g.len(), 1, "one cell must not imply a million allocations");
507        assert_eq!(g.used_rows(), 1001);
508        assert_eq!(g.used_cols(), 1001);
509    }
510
511    #[test]
512    fn setting_an_empty_value_removes_the_cell() {
513        let mut g = Grid::new();
514        g.set(cell(0, 0), "x".into());
515        g.set(cell(0, 0), String::new());
516        assert!(g.is_empty(), "an empty cell must not be stored");
517        assert_eq!(g.get(cell(0, 0)), None);
518    }
519
520    #[test]
521    fn count_in_counts_only_cells_inside_the_range() {
522        let mut g = Grid::new();
523        g.set(cell(0, 0), "a".into());
524        g.set(cell(1, 1), "b".into());
525        g.set(cell(9, 9), "c".into());
526        assert_eq!(g.count_in(CellRange::from_corners(cell(0, 0), cell(1, 1))), 2);
527        assert_eq!(g.count_in(CellRange::from_corners(cell(0, 0), cell(4, 4))), 2);
528        assert_eq!(g.count_in(CellRange::from_corners(cell(5, 5), cell(6, 6))), 0);
529    }
530
531    #[test]
532    fn fill_down_copies_the_source_value() {
533        let mut g = Grid::new();
534        g.set(cell(0, 0), "seed".into());
535        assert_eq!(g.fill_down(cell(0, 0), 3, 100).unwrap(), 3);
536        assert_eq!(g.get(cell(1, 0)), Some("seed"));
537        assert_eq!(g.get(cell(3, 0)), Some("seed"));
538        assert_eq!(g.get(cell(4, 0)), None, "must not overshoot the requested count");
539    }
540
541    #[test]
542    fn fill_down_from_an_empty_cell_is_an_error() {
543        let mut g = Grid::new();
544        assert_eq!(
545            g.fill_down(cell(0, 0), 5, 100).unwrap_err().kind(),
546            "invalidInput"
547        );
548    }
549
550    #[test]
551    fn fill_down_is_bounded() {
552        // A malformed count must not spin allocating millions of cells.
553        let mut g = Grid::new();
554        g.set(cell(0, 0), "x".into());
555        assert_eq!(g.fill_down(cell(0, 0), 10_000_000, 1000).unwrap_err().kind(), "invalidInput");
556    }
557}