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

commita693b97c4ec5bd8ee9cd8987885fd7ed50fc9cd3
parent68db05a1f0
authorLucas Galante <[email protected]>
date2026-08-24 15:22
spreadsheet: click a column header to sort (asc → desc → natural)

A left press in the header band cycles that column's sort: ascending,
descending, then back to the source order. Cells compare numerically when
both parse (so 10 sorts after 9), lexicographically otherwise; the sort is
a display-order permutation over the source rows, re-derived on every
set_spreadsheet_data so it survives the designer's data refreshes (and is
dropped when a refresh removes the sorted column). The active column shows
brighter with the Processes-page ▲/▼ mark; header cells get the same subtle
hover tint. The top 8px of the header stay the pane's resize grab — sort
clicks land below it.

 src/widget/container/spreadsheet.rs | 202 ++++++++++++++++++++++++++++++++++--
 1 file changed, 195 insertions(+), 7 deletions(-)

diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 2e70f65..12603e9 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -14,8 +14,8 @@ use crate::colors;
 use crate::scene::layout::Rect;
 use crate::scene::paint::PaintCtx;
 use crate::widget::{
-    Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseScrollDelta, NamedKey,
-    Paint, SpreadsheetController,
+    Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, MouseScrollDelta,
+    NamedKey, Paint, SpreadsheetController,
 };
 
 const HEADER_H: f32 = 24.0;
@@ -27,6 +27,16 @@ pub struct Spreadsheet {
     hovered: bool,
     headers: Vec<String>,
     rows: Vec<Vec<String>>,
+    /// Display order into `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.
+    order: Vec<usize>,
+    /// Active sort: `(column, ascending)`. A header click cycles
+    /// ascending → descending → natural order (matching the source data).
+    sort: Option<(usize, bool)>,
+    /// Header cell the pointer is over — hover tint, like the Processes page's
+    /// sortable headers.
+    header_hover_col: Option<usize>,
     scroll_y: f32,
     scroll_velocity: f32,
     dragging_scrollbar: bool,
@@ -55,6 +65,9 @@ impl Spreadsheet {
             hovered: false,
             headers: Vec::new(),
             rows: Vec::new(),
+            order: Vec::new(),
+            sort: None,
+            header_hover_col: None,
             scroll_y: 0.0,
             scroll_velocity: 0.0,
             dragging_scrollbar: false,
@@ -90,6 +103,47 @@ impl Spreadsheet {
         })
     }
 
+    /// The header column under `(px, py)`, if the point is inside the header band.
+    fn header_col_at(&self, px: f32, py: f32, rect: Rect) -> Option<usize> {
+        if self.headers.is_empty() || rect.width <= 0.0 {
+            return None;
+        }
+        if px < rect.x || px > rect.x + rect.width || py < rect.y || py >= rect.y + HEADER_H {
+            return None;
+        }
+        let n = self.headers.len();
+        let col = ((px - rect.x) / (rect.width / n as f32)) as usize;
+        Some(col.min(n - 1))
+    }
+
+    /// Cell comparison: numeric when both cells parse (so "10" sorts after "9"),
+    /// lexicographic otherwise.
+    fn cmp_cells(a: &str, b: &str) -> std::cmp::Ordering {
+        match (a.parse::<f64>(), b.parse::<f64>()) {
+            (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
+            _ => a.cmp(b),
+        }
+    }
+
+    /// Rebuild `order` from `sort`. Stable, so equal cells keep their source order.
+    fn apply_sort(&mut self) {
+        // A refresh can shrink the column set out from under the sort.
+        if let Some((col, _)) = self.sort {
+            if col >= self.headers.len() {
+                self.sort = None;
+            }
+        }
+        self.order = (0..self.rows.len()).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() }
+            });
+        }
+    }
+
     /// Scroll to where a thumb dragged to `thumb_y` puts the content.
     fn scroll_to_thumb(&mut self, g: &ScrollGeom, thumb_y: f32) {
         let ratio = if g.track_range > 0.0 {
@@ -196,17 +250,33 @@ impl Paint for Spreadsheet {
             return;
         }
         let n_cols = self.headers.len();
+        let col_w = w / n_cols as f32;
+        if let Some(hc) = self.header_hover_col {
+            // Subtle hover tint on the clickable header cell (the Processes-page
+            // sortable-header convention).
+            ctx.quad(
+                Rect { x: x + col_w * hc as f32, y, width: col_w, height: HEADER_H },
+                [1.0, 1.0, 1.0, 0.05],
+            );
+        }
         for (i, header) in self.headers.iter().enumerate() {
-            let cx = x + w * (i as f32 / n_cols as f32) + 8.0;
-            ctx.text(header.clone(), cx, y + 6.0, 12.0, [0xdd, 0xdd, 0xee]);
+            let cx = x + col_w * i as f32 + 8.0;
+            let (label, color) = match self.sort {
+                Some((col, ascending)) if col == i => {
+                    let mark = if ascending { '\u{25b2}' } else { '\u{25bc}' };
+                    (format!("{header} {mark}"), [0xff, 0xff, 0xff])
+                }
+                _ => (header.clone(), [0xdd, 0xdd, 0xee]),
+            };
+            ctx.text(label, cx, y + 6.0, 12.0, color);
         }
-        for (i, row) in self.rows.iter().enumerate() {
+        for (i, &src) in self.order.iter().enumerate() {
             let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
             if ry < body_top || ry + ROW_H > body_bottom {
                 continue;
             }
-            for (col_idx, val) in row.iter().enumerate().take(n_cols) {
-                let cx = x + w * (col_idx as f32 / n_cols as f32) + 8.0;
+            for (col_idx, val) in self.rows[src].iter().enumerate().take(n_cols) {
+                let cx = x + col_w * col_idx as f32 + 8.0;
                 ctx.text(val.clone(), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc]);
             }
         }
@@ -237,9 +307,32 @@ impl Input for Spreadsheet {
                     self.scrollbar_hovered = false;
                     self.scrollbar_thumb_hovered = false;
                 }
+                let was_header = self.header_hover_col;
+                self.header_hover_col = self.header_col_at(*px, *py, r);
                 was_hovered != self.hovered
                     || was_sb != self.scrollbar_hovered
                     || was_thumb != self.scrollbar_thumb_hovered
+                    || was_header != self.header_hover_col
+            }
+            // A left press on a column header cycles that column's sort:
+            // ascending → descending → back to natural order.
+            Event::MouseButton {
+                button: MouseButton::Left,
+                state: ElementState::Pressed,
+                x: px,
+                y: py,
+                ..
+            } => {
+                let Some(col) = self.header_col_at(*px, *py, ectx.rect) else {
+                    return false;
+                };
+                self.sort = match self.sort {
+                    Some((c, true)) if c == col => Some((col, false)),
+                    Some((c, false)) if c == col => None,
+                    _ => Some((col, true)),
+                };
+                self.apply_sort();
+                true
             }
             // Hit-gated by the adapter (which also rejects hidden widgets).
             Event::MouseWheel { delta, .. } => {
@@ -369,6 +462,9 @@ impl SpreadsheetController for Spreadsheet {
     fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
         self.headers = headers;
         self.rows = 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();
         // The raw scroll may now exceed the new content; every consumer clamps through
         // `geom()`, and the next scroll write re-clamps it for real.
     }
@@ -463,4 +559,96 @@ mod tests {
         s.set_visible(false);
         assert!(!s.keyboard_input(&end, &mut ctx), "hidden widget ignores keys");
     }
+
+    fn header_click(x: f32) -> Event {
+        Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x,
+            y: 10.0,
+            local_x: x,
+            local_y: 10.0,
+        }
+    }
+
+    #[test]
+    fn header_click_cycles_ascending_descending_natural() {
+        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);
+        // Numeric strings out of lexicographic order: "10" must sort after "9".
+        let rows = vec![
+            vec!["10".to_string(), "b".to_string()],
+            vec!["9".to_string(), "c".to_string()],
+            vec!["2".to_string(), "a".to_string()],
+        ];
+        SpreadsheetController::set_spreadsheet_data(&mut *s, vec!["n".into(), "s".into()], rows);
+        assert_eq!(s.inner().order, vec![0, 1, 2], "unsorted = natural order");
+
+        // Column 0 spans x 0..100. Click 1: ascending, numeric.
+        assert!(s.handle_event(&header_click(50.0), &mut ctx));
+        assert_eq!(s.inner().sort, Some((0, true)));
+        assert_eq!(s.inner().order, vec![2, 1, 0], "2 < 9 < 10 numerically");
+
+        // Click 2: descending.
+        assert!(s.handle_event(&header_click(50.0), &mut ctx));
+        assert_eq!(s.inner().sort, Some((0, false)));
+        assert_eq!(s.inner().order, vec![0, 1, 2]);
+
+        // Click 3: back to natural order.
+        assert!(s.handle_event(&header_click(50.0), &mut ctx));
+        assert_eq!(s.inner().sort, None);
+        assert_eq!(s.inner().order, vec![0, 1, 2]);
+
+        // Column 1 (lexicographic), then a body click changes nothing.
+        assert!(s.handle_event(&header_click(150.0), &mut ctx));
+        assert_eq!(s.inner().order, vec![2, 0, 1], "a < b < c");
+        let body = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 50.0,
+            y: 60.0,
+            local_x: 50.0,
+            local_y: 60.0,
+        };
+        assert!(!s.handle_event(&body, &mut ctx), "body press is not a sort");
+        assert_eq!(s.inner().sort, Some((1, true)));
+    }
+
+    #[test]
+    fn data_refresh_reapplies_sort_and_column_shrink_clears_it() {
+        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);
+        SpreadsheetController::set_spreadsheet_data(
+            &mut *s,
+            vec!["a".into(), "b".into()],
+            vec![vec!["1".into(), "x".into()], vec!["2".into(), "y".into()]],
+        );
+        assert!(s.handle_event(&header_click(150.0), &mut ctx)); // sort col 1 asc
+
+        // A refresh with new rows keeps the sort and re-derives the order.
+        SpreadsheetController::set_spreadsheet_data(
+            &mut *s,
+            vec!["a".into(), "b".into()],
+            vec![vec!["1".into(), "z".into()], vec!["2".into(), "w".into()]],
+        );
+        assert_eq!(s.inner().sort, Some((1, true)));
+        assert_eq!(s.inner().order, vec![1, 0], "w < z");
+
+        // A refresh that drops the sorted column clears the sort.
+        SpreadsheetController::set_spreadsheet_data(
+            &mut *s,
+            vec!["a".into()],
+            vec![vec!["1".into()], vec!["2".into()]],
+        );
+        assert_eq!(s.inner().sort, None);
+        assert_eq!(s.inner().order, vec![0, 1]);
+    }
 }