git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit9bcb25d924c4942232da782b187075dd6295f88c
parent9771c92bc2
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 00:44
perf(spreadsheet): hold the table as columns of values, written as painted

SheetColumn (Text, Int, Float with decimals) and set_spreadsheet_columns:
the widget writes only the cells it paints and sorts by value. A host that
refills the table every frame no longer formats every cell of every row.
set_spreadsheet_data still works, as text columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                           |  15 +++
 src/widget/container/mod.rs         |   2 +-
 src/widget/container/spreadsheet.rs | 186 ++++++++++++++++++++++++++++++++----
 src/widget/mod.rs                   |  10 +-
 4 files changed, 191 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e57df27..697831c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2266,6 +2266,21 @@ what the pane asks. A scroll keeps its own full-precision copy too
 sends a pixel at a time, a quarter of a degree, which on a short vector
 is less than the rows can hold.
 
+### A spreadsheet is columns of values, written as they are painted
+
+`Spreadsheet` holds its table as COLUMNS (since 2026-10-07, `SheetColumn`:
+`Text`, `Int`, or `Float` to a number of decimals) and writes a cell's text
+only when it paints the cell. `SpreadsheetController::set_spreadsheet_columns`
+takes them; `set_spreadsheet_data` (rows of strings) still works, its cells
+becoming text columns. A sort compares the values — numbers by number, text
+as before (numerically where both cells parse) — instead of parsing every
+cell twice a comparison. It exists for a host that refills the table every
+frame: the designer did so during a simulation's playback with every cell of
+every row formatted into a `String`, ten thousand rows of them for a pane
+that shows thirty. A float cell is `format!("{:.*}", decimals, value)`, the
+string a host formatting it itself would have written.
+`a_column_table_is_written_as_painted_and_sorts_by_value` is the test.
+
 ### A spreadsheet's rows can be selected
 
 `Spreadsheet` keeps a selection of rows (since 2026-09-29): a press on a
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index e130e15..d4314be 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -14,7 +14,7 @@ pub use content_bg::ContentBg;
 pub use parameters_bg::ParametersBg;
 pub use menu::MenuBar;
 pub use breadcrumb::Breadcrumb;
-pub use spreadsheet::Spreadsheet;
+pub use spreadsheet::{SheetColumn, Spreadsheet};
 pub use scroll_box::ScrollBox;
 pub use paginator::Paginator;
 pub use treelist::{TreeList, TreeElement};
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 56785dc..c63043d 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -18,6 +18,15 @@
 //! draw, before the plate, through [`Spreadsheet::paint_scrollbars`] — the plate is the
 //! host's too.
 //!
+//! **The table is COLUMNS of values, formatted as they are painted** (since 2026-10-07,
+//! [`SheetColumn`], [`SpreadsheetController::set_spreadsheet_columns`]). A host that
+//! refills the table every frame — the designer's, during a simulation's playback — used
+//! to format every cell of every row into a `String` for a pane that shows thirty rows:
+//! at ten thousand points that was most of what the table cost a frame. A column of
+//! numbers is now the numbers, the cells on screen are written when painted, and a sort
+//! compares the numbers rather than parsing their text. `set_spreadsheet_data` (rows of
+//! strings) still works: its cells become text columns.
+//!
 //! The `PARAM_BG` background is NOT emitted here: the designer's render path draws every
 //! widget's background itself from `color()` + `corner_style()` (`push_widget_vertices`), and
 //! `PARAM_BG` is translucent — emitting it again would double-blend. This widget's own
@@ -32,6 +41,64 @@ use crate::widget::{
     NamedKey, Paint, ScrollbarActivity, SpreadsheetController,
 };
 
+/// One column of a [`Spreadsheet`], as its values: the widget writes the
+/// cells it paints, so a column of a hundred thousand numbers costs a copy
+/// of them, not a hundred thousand strings.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SheetColumn {
+    /// Cells as given. Sorted numerically where both cells parse as numbers,
+    /// as text otherwise.
+    Text(Vec<String>),
+    /// Whole numbers.
+    Int(Vec<i64>),
+    /// Numbers written to `decimals` places (`format!("{:.*}")`).
+    Float { values: Vec<f32>, decimals: usize },
+}
+
+impl SheetColumn {
+    /// How many cells the column has.
+    pub fn len(&self) -> usize {
+        match self {
+            SheetColumn::Text(v) => v.len(),
+            SheetColumn::Int(v) => v.len(),
+            SheetColumn::Float { values, .. } => values.len(),
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// Row `row`'s cell as it is shown; empty past the column's end.
+    pub fn cell(&self, row: usize) -> String {
+        match self {
+            SheetColumn::Text(v) => v.get(row).cloned().unwrap_or_default(),
+            SheetColumn::Int(v) => v.get(row).map(|n| n.to_string()).unwrap_or_default(),
+            SheetColumn::Float { values, decimals } => {
+                values.get(row).map(|x| format!("{:.*}", decimals, x)).unwrap_or_default()
+            }
+        }
+    }
+
+    /// The order of rows `a` and `b` by this column: by value for numbers
+    /// (a NaN is equal to anything, so it keeps its place), by
+    /// `Spreadsheet::cmp_cells` for text.
+    fn cmp_rows(&self, a: usize, b: usize) -> std::cmp::Ordering {
+        use std::cmp::Ordering::Equal;
+        match self {
+            SheetColumn::Text(v) => Spreadsheet::cmp_cells(
+                v.get(a).map(String::as_str).unwrap_or(""),
+                v.get(b).map(String::as_str).unwrap_or(""),
+            ),
+            SheetColumn::Int(v) => v.get(a).cmp(&v.get(b)),
+            SheetColumn::Float { values, .. } => match (values.get(a), values.get(b)) {
+                (Some(x), Some(y)) => x.partial_cmp(y).unwrap_or(Equal),
+                (x, y) => x.is_some().cmp(&y.is_some()),
+            },
+        }
+    }
+}
+
 const HEADER_H: f32 = 24.0;
 const ROW_H: f32 = 24.0;
 /// How far a track stops short of each end of what it spans, as every toolkit bar's does.
@@ -46,10 +113,12 @@ const MIN_COL_W: f32 = 76.0;
 pub struct Spreadsheet {
     hovered: bool,
     headers: Vec<String>,
-    rows: Vec<Vec<String>>,
-    /// Display order into `rows` — the identity permutation unless `sort` is set.
+    /// The table, a column a header ([`SheetColumn`]); `row_count` rows.
+    columns: Vec<SheetColumn>,
+    row_count: usize,
+    /// Display order into the rows — the identity permutation unless `sort` is set.
     /// Rebuilt by `apply_sort` at both mutation sites (header click, data refresh),
-    /// so `order.len() == rows.len()` always holds.
+    /// so `order.len() == row_count` always holds.
     order: Vec<usize>,
     /// Active sort: `(column, ascending)`. A header click cycles
     /// ascending → descending → natural order (matching the source data).
@@ -120,7 +189,8 @@ impl Spreadsheet {
         let mut s = Adapted::new(Spreadsheet {
             hovered: false,
             headers: Vec::new(),
-            rows: Vec::new(),
+            columns: Vec::new(),
+            row_count: 0,
             order: Vec::new(),
             sort: None,
             header_hover_col: None,
@@ -144,7 +214,7 @@ impl Spreadsheet {
     }
 
     fn geom(&self, rect: Rect) -> Option<ScrollGeom> {
-        let content_h = self.rows.len() as f32 * ROW_H;
+        let content_h = self.row_count as f32 * ROW_H;
         let visible_h = (rect.height - HEADER_H).max(0.0);
         if visible_h <= 0.0 || content_h <= visible_h {
             return None;
@@ -349,14 +419,14 @@ impl Spreadsheet {
                 self.sort = None;
             }
         }
-        self.order = (0..self.rows.len()).collect();
+        self.order = (0..self.row_count).collect();
         if let Some((col, ascending)) = self.sort {
-            let rows = &self.rows;
-            let cell = |r: usize| rows[r].get(col).map(String::as_str).unwrap_or("");
-            self.order.sort_by(|&a, &b| {
-                let ord = Self::cmp_cells(cell(a), cell(b));
-                if ascending { ord } else { ord.reverse() }
-            });
+            if let Some(column) = self.columns.get(col) {
+                self.order.sort_by(|&a, &b| {
+                    let ord = column.cmp_rows(a, b);
+                    if ascending { ord } else { ord.reverse() }
+                });
+            }
         }
     }
 
@@ -486,7 +556,7 @@ impl Paint for Spreadsheet {
             let [r, g, b, _] = colors::highlight_primary_color();
             [r, g, b, 0.28]
         };
-        for i in 0..self.rows.len() {
+        for i in 0..self.row_count {
             let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
             if ry + ROW_H <= body_top || ry >= body_bottom {
                 continue;
@@ -624,12 +694,13 @@ impl Paint for Spreadsheet {
             if bottom <= top {
                 continue;
             }
-            for (col_idx, val) in self.rows[src].iter().enumerate().take(n_cols) {
+            // Written here, for the cells on screen only (`SheetColumn`).
+            for (col_idx, column) in self.columns.iter().enumerate().take(n_cols) {
                 if !col_visible(col_idx) {
                     continue;
                 }
                 let cx = xoff + col_w * col_idx as f32 + 8.0;
-                ctx.text_with(fit(val.clone()), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc], None, col_bounds(col_idx, top, bottom - top));
+                ctx.text_with(fit(column.cell(src)), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc], None, col_bounds(col_idx, top, bottom - top));
             }
         }
     }
@@ -859,10 +930,12 @@ impl Input for Spreadsheet {
 
 }
 
-impl SpreadsheetController for Spreadsheet {
-    fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
+impl Spreadsheet {
+    /// Take a table: its headers, its columns and how many rows they hold.
+    fn set_columns(&mut self, headers: Vec<String>, columns: Vec<SheetColumn>, rows: usize) {
         self.headers = headers;
-        self.rows = rows;
+        self.columns = columns;
+        self.row_count = rows;
         // Re-derive the display order so an active sort survives a data refresh
         // (the designer re-sets the whole table on selection/param changes).
         self.apply_sort();
@@ -873,7 +946,7 @@ impl SpreadsheetController for Spreadsheet {
         // the designer re-sets the table on every frame of a playback, and
         // the rows selected are still the elements they were. What a
         // shorter table no longer has goes.
-        let n = self.rows.len();
+        let n = self.row_count;
         let kept = self.selected.len();
         self.selected.retain(|&r| r < n);
         if self.selected.len() != kept {
@@ -883,13 +956,33 @@ impl SpreadsheetController for Spreadsheet {
             self.anchor = None;
         }
     }
+}
+
+impl SpreadsheetController for Spreadsheet {
+    fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
+        // Rows of text become text columns, a cell a row; a short row's
+        // missing cells are empty.
+        let width = headers.len();
+        let mut columns: Vec<Vec<String>> = (0..width).map(|_| Vec::with_capacity(rows.len())).collect();
+        for row in &rows {
+            for (c, column) in columns.iter_mut().enumerate() {
+                column.push(row.get(c).cloned().unwrap_or_default());
+            }
+        }
+        self.set_columns(headers, columns.into_iter().map(SheetColumn::Text).collect(), rows.len());
+    }
+
+    fn set_spreadsheet_columns(&mut self, headers: Vec<String>, columns: Vec<SheetColumn>) {
+        let rows = columns.iter().map(SheetColumn::len).max().unwrap_or(0);
+        self.set_columns(headers, columns, rows);
+    }
 
     fn selected_rows(&self) -> Vec<usize> {
         self.selected.iter().copied().collect()
     }
 
     fn set_selected_rows(&mut self, rows: &[usize]) {
-        let n = self.rows.len();
+        let n = self.row_count;
         let next: std::collections::BTreeSet<usize> = rows.iter().copied().filter(|&r| r < n).collect();
         if next != self.selected {
             self.selected = next;
@@ -1345,6 +1438,59 @@ mod tests {
         assert_eq!(pills(&s), 4, "two tracks and two thumbs");
     }
 
+    /// A table of columns: the cells on screen are written as they are
+    /// painted — to a float column's decimals, an integer as one — and only
+    /// those; a sort compares the values, so 10 sorts after 9 without a
+    /// cell being parsed, and an integer column likewise.
+    #[test]
+    fn a_column_table_is_written_as_painted_and_sorts_by_value() {
+        let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
+        let mut ctx = UiContext::new();
+        let mut s = Spreadsheet::new();
+        s.set_visible(true);
+        WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
+        let (id, ptr) = (s.id(), s.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let n = 1000;
+        SpreadsheetController::set_spreadsheet_columns(
+            &mut *s,
+            vec!["i".into(), "x".into()],
+            vec![
+                SheetColumn::Int((0..n as i64).collect()),
+                SheetColumn::Float { values: (0..n).map(|r| ((r * 7919) % n) as f32 * 0.5 - 3.25).collect(), decimals: 4 },
+            ],
+        );
+        assert_eq!(s.inner().row_count, n);
+        let texts = |s: &Adapted<Spreadsheet>| -> Vec<String> {
+            let mut pc = crate::scene::paint::PaintCtx::new();
+            Paint::paint(&**s, rect, &mut pc);
+            pc.finish()
+                .items
+                .iter()
+                .filter_map(|item| match &item.prim {
+                    crate::scene::paint::Prim::Text { text, .. } => Some(text.clone()),
+                    _ => None,
+                })
+                .collect()
+        };
+        let shown = texts(&s);
+        assert!(shown.contains(&"0".to_string()) && shown.contains(&"-3.2500".to_string()), "{shown:?}");
+        assert!(!shown.contains(&"999".to_string()), "a row off screen is not written");
+
+        // Sorted by x, ascending: the least value first, by value.
+        assert!(s.handle_event(&header_click(150.0), &mut ctx));
+        let order = s.inner().order.clone();
+        let x = |r: usize| ((r * 7919) % n) as f32 * 0.5 - 3.25;
+        assert!(order.windows(2).all(|w| x(w[0]) <= x(w[1])));
+        // And by i, descending (two more clicks on the first header).
+        assert!(s.handle_event(&header_click(50.0), &mut ctx));
+        assert!(s.handle_event(&header_click(50.0), &mut ctx));
+        assert_eq!(s.inner().order[0], n - 1, "999 sorts after 99 and 100");
+        assert_eq!(SheetColumn::Float { values: vec![1.0 / 3.0], decimals: 2 }.cell(0), "0.33");
+        assert_eq!(SheetColumn::Int(vec![-4]).cell(0), "-4");
+        assert_eq!(SheetColumn::Int(vec![]).cell(3), "", "past the end is empty");
+    }
+
     #[test]
     fn data_refresh_reapplies_sort_and_column_shrink_clears_it() {
         let mut ctx = UiContext::new();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 2b57257..5e1370c 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -651,7 +651,7 @@ pub use self::container::{
     ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
     ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
     ContentBg, ParametersBg,
-    ScrollBox, MenuBar, Spreadsheet, Breadcrumb,
+    ScrollBox, MenuBar, SheetColumn, Spreadsheet, Breadcrumb,
     Paginator, TreeList, TreeElement
 };
 pub use self::display::{
@@ -772,6 +772,14 @@ pub trait GraphController {
 
 pub trait SpreadsheetController {
     fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>);
+    /// The table as columns of values ([`SheetColumn`]), a column a header:
+    /// the cells are written as they are painted, so a refill costs a copy
+    /// of the values. Rows of text, by default.
+    fn set_spreadsheet_columns(&mut self, headers: Vec<String>, columns: Vec<SheetColumn>) {
+        let n = columns.iter().map(SheetColumn::len).max().unwrap_or(0);
+        let rows = (0..n).map(|r| columns.iter().map(|c| c.cell(r)).collect()).collect();
+        self.set_spreadsheet_data(headers, rows);
+    }
     /// The selected rows, as indices into the rows last set, ascending.
     fn selected_rows(&self) -> Vec<usize> {
         Vec::new()