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

commit7498b7662cbce273c1ba14710ed1d2f89d980a97
parentbb19b0cbfd
authorLucas Galante <[email protected]>
date2026-08-26 13:00
spreadsheet: horizontal scrolling — columns floor at a readable width

Columns no longer squeeze to fit: they take an even share of the pane
down to MIN_COL_W (a 4-decimal value / short header in the 12px mono
label font), and past the floor the run overflows into a horizontal
scroll — HScrollGeom mirrors ScrollGeom, driving a bottom scrollbar
(hover/drag states twinned with the vertical bar, which keeps the shared
corner), wheel dx with the same inertial integrate-and-decay, and
track/thumb dragging. Labels clamp to their column AND the pane, so
half-scrolled edge columns stop at the plate; the header sort hit-test
looks up columns through the scrolled origin.

Narrow panes that used to show 19px column slivers now show readable
76px columns you scroll across.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/widget/container/spreadsheet.rs | 370 ++++++++++++++++++++++++++++++------
 1 file changed, 312 insertions(+), 58 deletions(-)

diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 658e185..361d770 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -22,6 +22,10 @@ const HEADER_H: f32 = 24.0;
 const ROW_H: f32 = 24.0;
 const SCROLLBAR_W: f32 = 6.0;
 const SCROLLBAR_PAD: f32 = 2.0;
+/// Columns never squeeze below this: when they don't fit, the pane scrolls
+/// horizontally instead. Sized for a 4-decimal value / a short header plus its
+/// sort mark in the 12px mono label font, with the cell padding on both sides.
+const MIN_COL_W: f32 = 76.0;
 
 pub struct Spreadsheet {
     hovered: bool,
@@ -43,6 +47,12 @@ pub struct Spreadsheet {
     drag_offset_y: f32,
     scrollbar_hovered: bool,
     scrollbar_thumb_hovered: bool,
+    scroll_x: f32,
+    hscroll_velocity: f32,
+    dragging_hscrollbar: bool,
+    drag_offset_x: f32,
+    hscrollbar_hovered: bool,
+    hscrollbar_thumb_hovered: bool,
 }
 
 /// Scroll/scrollbar geometry for one (row count, rect) pair. Present only when the content
@@ -59,6 +69,20 @@ struct ScrollGeom {
     track_range: f32,
 }
 
+/// [`ScrollGeom`]'s horizontal twin, for the bottom scrollbar. Present only
+/// when the column run overflows the pane width.
+struct HScrollGeom {
+    visible_w: f32,
+    max_scroll: f32,
+    /// `scroll_x` clamped to the current bounds.
+    scroll: f32,
+    track_x: f32,
+    track_y: f32,
+    thumb_w: f32,
+    thumb_x: f32,
+    track_range: f32,
+}
+
 impl Spreadsheet {
     pub fn new() -> Adapted<Spreadsheet> {
         let mut s = Adapted::new(Spreadsheet {
@@ -74,6 +98,12 @@ impl Spreadsheet {
             drag_offset_y: 0.0,
             scrollbar_hovered: false,
             scrollbar_thumb_hovered: false,
+            scroll_x: 0.0,
+            hscroll_velocity: 0.0,
+            dragging_hscrollbar: false,
+            drag_offset_x: 0.0,
+            hscrollbar_hovered: false,
+            hscrollbar_thumb_hovered: false,
         });
         // The spreadsheet pane starts hidden (the designer toggles it in later).
         crate::widget::WidgetHost::set_visible(&mut s, false);
@@ -103,6 +133,43 @@ impl Spreadsheet {
         })
     }
 
+    /// One column's width: an even share of the pane, floored at [`MIN_COL_W`] —
+    /// past the floor the content overflows into the horizontal scroll.
+    fn col_w(&self, rect: Rect) -> f32 {
+        let n = self.headers.len().max(1) as f32;
+        (rect.width / n).max(MIN_COL_W)
+    }
+
+    /// Horizontal counterpart of [`geom`]: present only when the column run is
+    /// wider than the pane.
+    fn hgeom(&self, rect: Rect) -> Option<HScrollGeom> {
+        let content_w = self.col_w(rect) * self.headers.len() as f32;
+        let visible_w = rect.width;
+        if visible_w <= 0.0 || content_w <= visible_w {
+            return None;
+        }
+        let max_scroll = content_w - visible_w;
+        let scroll = self.scroll_x.clamp(0.0, max_scroll);
+        let thumb_w = ((visible_w / content_w) * visible_w).clamp(15.0_f32.min(visible_w), visible_w);
+        let track_x = rect.x;
+        let track_range = visible_w - thumb_w;
+        Some(HScrollGeom {
+            visible_w,
+            max_scroll,
+            scroll,
+            track_x,
+            track_y: rect.y + rect.height - SCROLLBAR_W - SCROLLBAR_PAD,
+            thumb_w,
+            thumb_x: track_x + (scroll / max_scroll) * track_range,
+            track_range,
+        })
+    }
+
+    /// The clamped horizontal scroll — 0 while everything fits.
+    fn hscroll(&self, rect: Rect) -> f32 {
+        self.hgeom(rect).map_or(0.0, |g| g.scroll)
+    }
+
     /// 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 {
@@ -112,8 +179,21 @@ impl Spreadsheet {
             return None;
         }
         let n = self.headers.len();
-        let col = ((px - rect.x) / (rect.width / n as f32)) as usize;
-        Some(col.min(n - 1))
+        let col = ((px - rect.x + self.hscroll(rect)) / self.col_w(rect)) as usize;
+        if col >= n {
+            return None;
+        }
+        Some(col)
+    }
+
+    /// Scroll to where a thumb dragged to `thumb_x` puts the columns.
+    fn hscroll_to_thumb(&mut self, g: &HScrollGeom, thumb_x: f32) {
+        let ratio = if g.track_range > 0.0 {
+            ((thumb_x - g.track_x) / g.track_range).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+        self.scroll_x = ratio * g.max_scroll;
     }
 
     /// Cell comparison: numeric when both cells parse (so "10" sorts after "9"),
@@ -224,12 +304,17 @@ impl Paint for Spreadsheet {
         // Header separator
         ctx.quad(Rect { x, y: y + HEADER_H, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.25]);
 
-        // Vertical column dividers
+        // Vertical column dividers, at scrolled column edges, kept inside the pane.
+        let hscroll = self.hscroll(rect);
         if h > 0.0 && !self.headers.is_empty() {
             let n_cols = self.headers.len();
+            let cw = self.col_w(rect);
             for i in 1..n_cols {
-                let r = i as f32 / n_cols as f32;
-                ctx.quad(Rect { x: x + w * r, y, width: 1.0, height: h }, [0.20, 0.20, 0.25, 0.15]);
+                let dx = x + cw * i as f32 - hscroll;
+                if dx <= x || dx >= x + w {
+                    continue;
+                }
+                ctx.quad(Rect { x: dx, y, width: 1.0, height: h }, [0.20, 0.20, 0.25, 0.15]);
             }
         }
 
@@ -254,30 +339,59 @@ impl Paint for Spreadsheet {
             );
         }
 
+        // Horizontal scrollbar along the bottom, the vertical bar's twin.
+        if let Some(g) = self.hgeom(rect) {
+            ctx.quad(
+                Rect { x: g.track_x, y: g.track_y, width: g.visible_w, height: SCROLLBAR_W },
+                [0.05, 0.05, 0.08, 0.15],
+            );
+            let thumb_color = if self.dragging_hscrollbar {
+                [0.40, 0.40, 0.48, 1.0]
+            } else if self.hscrollbar_thumb_hovered {
+                [0.32, 0.32, 0.38, 1.0]
+            } else if self.hscrollbar_hovered {
+                [0.24, 0.24, 0.30, 0.9]
+            } else {
+                [0.18, 0.18, 0.24, 0.7]
+            };
+            ctx.quad(
+                Rect { x: g.thumb_x, y: g.track_y, width: g.thumb_w, height: SCROLLBAR_W },
+                thumb_color,
+            );
+        }
+
         // Header + cell text. Cells render only when the row lies fully inside the body.
         if self.headers.is_empty() {
             return;
         }
         let n_cols = self.headers.len();
-        let col_w = w / n_cols as f32;
+        let col_w = self.col_w(rect);
+        // Scrolled column origin; columns fully outside the pane skip.
+        let xoff = x - hscroll;
+        let col_visible = |col: usize| -> bool {
+            let cx0 = xoff + col_w * col as f32;
+            cx0 + col_w > x && cx0 < x + w
+        };
         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],
-            );
+            // sortable-header convention), clamped to the pane.
+            let hx0 = (xoff + col_w * hc as f32).max(x);
+            let hx1 = (xoff + col_w * (hc + 1) as f32).min(x + w);
+            if hx1 > hx0 {
+                ctx.quad(Rect { x: hx0, y, width: hx1 - hx0, height: HEADER_H }, [1.0, 1.0, 1.0, 0.05]);
+            }
         }
         // Every label clamps to its own column (a 4px gutter short of the
-        // divider), so a long value cuts off instead of running under its
-        // neighbor — the overlap that made narrow panes unreadable.
+        // divider) AND to the pane, so a long value cuts off instead of
+        // running under its neighbor, and half-scrolled edge columns stop at
+        // the plate instead of bleeding past it.
         let col_bounds = |col: usize, top: f32, height: f32| -> Option<[f32; 4]> {
-            Some([
-                x + col_w * col as f32,
-                top,
-                x + col_w * (col + 1) as f32 - 4.0,
-                top + height,
-            ])
+            let x0 = (xoff + col_w * col as f32).max(x);
+            let x1 = (xoff + col_w * (col + 1) as f32 - 4.0).min(x + w);
+            if x1 <= x0 {
+                return None;
+            }
+            Some([x0, top, x1, top + height])
         };
         // Ellipsize what the clamp would cut, so truncation reads as
         // deliberate. A char budget from ONE cached measurement is exact
@@ -299,7 +413,10 @@ impl Paint for Spreadsheet {
             out
         };
         for (i, header) in self.headers.iter().enumerate() {
-            let cx = x + col_w * i as f32 + 8.0;
+            if !col_visible(i) {
+                continue;
+            }
+            let cx = xoff + 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}' };
@@ -315,7 +432,10 @@ impl Paint for Spreadsheet {
                 continue;
             }
             for (col_idx, val) in self.rows[src].iter().enumerate().take(n_cols) {
-                let cx = x + col_w * col_idx as f32 + 8.0;
+                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, ry, ROW_H));
             }
         }
@@ -346,11 +466,28 @@ impl Input for Spreadsheet {
                     self.scrollbar_hovered = false;
                     self.scrollbar_thumb_hovered = false;
                 }
+                let was_hsb = self.hscrollbar_hovered;
+                let was_hthumb = self.hscrollbar_thumb_hovered;
+                if let Some(g) = self.hgeom(r) {
+                    self.hscrollbar_hovered = *py >= g.track_y - 2.0
+                        && *py <= r.y + r.height
+                        && *px >= g.track_x
+                        && *px <= g.track_x + g.visible_w;
+                    self.hscrollbar_thumb_hovered = *py >= g.track_y - 2.0
+                        && *py <= r.y + r.height
+                        && *px >= g.thumb_x
+                        && *px <= g.thumb_x + g.thumb_w;
+                } else {
+                    self.hscrollbar_hovered = false;
+                    self.hscrollbar_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_hsb != self.hscrollbar_hovered
+                    || was_hthumb != self.hscrollbar_thumb_hovered
                     || was_header != self.header_hover_col
             }
             // A left press on a column header cycles that column's sort:
@@ -375,16 +512,20 @@ impl Input for Spreadsheet {
             }
             // Hit-gated by the adapter (which also rejects hidden widgets).
             Event::MouseWheel { delta, .. } => {
-                if self.geom(ectx.rect).is_some() {
-                    let scroll_amount = match delta {
-                        MouseScrollDelta::LineDelta(_x, y) => *y * ROW_H,
-                        MouseScrollDelta::PixelDelta(pos) => pos.y as f32,
-                    };
-                    self.scroll_velocity += scroll_amount * 12.0;
-                    true
-                } else {
-                    false
+                let (dx, dy) = match delta {
+                    MouseScrollDelta::LineDelta(x, y) => (*x * ROW_H, *y * ROW_H),
+                    MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
+                };
+                let mut used = false;
+                if dy.abs() > 0.0 && self.geom(ectx.rect).is_some() {
+                    self.scroll_velocity += dy * 12.0;
+                    used = true;
                 }
+                if dx.abs() > 0.0 && self.hgeom(ectx.rect).is_some() {
+                    self.hscroll_velocity += dx * 12.0;
+                    used = true;
+                }
+                used
             }
             Event::KeyInput(key_event) => {
                 if key_event.state != ElementState::Pressed {
@@ -419,15 +560,18 @@ impl Input for Spreadsheet {
     // landed on the scrollbar; a body press starts no drag, exactly like legacy.
 
     fn draggable(&self, rect: Rect) -> bool {
-        self.geom(rect).is_some()
+        self.geom(rect).is_some() || self.hgeom(rect).is_some()
     }
 
     fn is_dragging(&self) -> bool {
-        self.dragging_scrollbar
+        self.dragging_scrollbar || self.dragging_hscrollbar
     }
 
     fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
         self.scroll_velocity = 0.0;
+        self.hscroll_velocity = 0.0;
+        // The vertical bar owns the shared bottom-right corner (it was here
+        // first); the horizontal bar takes what's left of the bottom band.
         if let Some(g) = self.geom(rect) {
             if px >= g.scrollbar_x - 4.0
                 && px <= rect.x + rect.width
@@ -442,53 +586,92 @@ impl Input for Spreadsheet {
                     self.drag_offset_y = g.thumb_h / 2.0;
                     self.scroll_to_thumb(&g, py - self.drag_offset_y);
                 }
+                return;
+            }
+        }
+        if let Some(g) = self.hgeom(rect) {
+            if py >= g.track_y - 4.0
+                && py <= rect.y + rect.height
+                && px >= g.track_x
+                && px <= g.track_x + g.visible_w
+            {
+                self.dragging_hscrollbar = true;
+                if px >= g.thumb_x && px <= g.thumb_x + g.thumb_w {
+                    self.drag_offset_x = px - g.thumb_x;
+                } else {
+                    self.drag_offset_x = g.thumb_w / 2.0;
+                    self.hscroll_to_thumb(&g, px - self.drag_offset_x);
+                }
             }
         }
     }
 
-    fn drag_update(&mut self, _px: f32, py: f32, rect: Rect) -> bool {
-        if !self.dragging_scrollbar {
+    fn drag_update(&mut self, px: f32, py: f32, rect: Rect) -> bool {
+        if self.dragging_scrollbar {
+            self.scroll_velocity = 0.0;
+            if let Some(g) = self.geom(rect) {
+                let old = g.scroll;
+                self.scroll_to_thumb(&g, py - self.drag_offset_y);
+                return (self.scroll_y - old).abs() > 0.01;
+            }
             return false;
         }
-        self.scroll_velocity = 0.0;
-        if let Some(g) = self.geom(rect) {
-            let old = g.scroll;
-            self.scroll_to_thumb(&g, py - self.drag_offset_y);
-            return (self.scroll_y - old).abs() > 0.01;
+        if self.dragging_hscrollbar {
+            self.hscroll_velocity = 0.0;
+            if let Some(g) = self.hgeom(rect) {
+                let old = g.scroll;
+                self.hscroll_to_thumb(&g, px - self.drag_offset_x);
+                return (self.scroll_x - old).abs() > 0.01;
+            }
         }
         false
     }
 
     fn drag_end(&mut self) {
         self.dragging_scrollbar = false;
+        self.dragging_hscrollbar = false;
         self.scroll_velocity = 0.0;
+        self.hscroll_velocity = 0.0;
     }
 
     // --- Inertial scroll: the wheel only sets velocity; each frame integrates and decays it.
 
     fn tick(&mut self, dt: f32, rect: Rect) -> bool {
-        if self.scroll_velocity.abs() <= 0.01 {
-            return false;
-        }
-        let content_h = self.rows.len() as f32 * ROW_H;
-        let visible_h = (rect.height - HEADER_H).max(0.0);
-        let max_scroll = (content_h - visible_h).max(0.0);
-        let old = self.scroll_y;
-
-        self.scroll_y = (self.scroll_y + self.scroll_velocity * dt).clamp(0.0, max_scroll);
-
-        // Decelerate with friction (exponential decay); stop dead at the bounds or below the
-        // motion threshold.
+        let mut moved = false;
         let friction = 8.0;
-        self.scroll_velocity *= (-friction * dt).exp();
-        if self.scroll_y == 0.0 || self.scroll_y == max_scroll {
-            self.scroll_velocity = 0.0;
+        if self.scroll_velocity.abs() > 0.01 {
+            let content_h = self.rows.len() as f32 * ROW_H;
+            let visible_h = (rect.height - HEADER_H).max(0.0);
+            let max_scroll = (content_h - visible_h).max(0.0);
+            let old = self.scroll_y;
+
+            self.scroll_y = (self.scroll_y + self.scroll_velocity * dt).clamp(0.0, max_scroll);
+
+            // Decelerate with friction (exponential decay); stop dead at the bounds or below
+            // the motion threshold.
+            self.scroll_velocity *= (-friction * dt).exp();
+            if self.scroll_y == 0.0 || self.scroll_y == max_scroll {
+                self.scroll_velocity = 0.0;
+            }
+            if self.scroll_velocity.abs() < 5.0 {
+                self.scroll_velocity = 0.0;
+            }
+            moved |= (self.scroll_y - old).abs() > 0.01;
         }
-        if self.scroll_velocity.abs() < 5.0 {
-            self.scroll_velocity = 0.0;
+        if self.hscroll_velocity.abs() > 0.01 {
+            let max_scroll = self.hgeom(rect).map_or(0.0, |g| g.max_scroll);
+            let old = self.scroll_x;
+            self.scroll_x = (self.scroll_x + self.hscroll_velocity * dt).clamp(0.0, max_scroll);
+            self.hscroll_velocity *= (-friction * dt).exp();
+            if self.scroll_x == 0.0 || self.scroll_x == max_scroll {
+                self.hscroll_velocity = 0.0;
+            }
+            if self.hscroll_velocity.abs() < 5.0 {
+                self.hscroll_velocity = 0.0;
+            }
+            moved |= (self.scroll_x - old).abs() > 0.01;
         }
-
-        (self.scroll_y - old).abs() > 0.01
+        moved
     }
 
     fn wants_tick(&self) -> bool {
@@ -525,6 +708,77 @@ mod tests {
         s
     }
 
+    fn wide(cols: usize) -> Adapted<Spreadsheet> {
+        let mut s = Spreadsheet::new();
+        s.set_visible(true);
+        WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
+        let headers: Vec<String> = (0..cols).map(|i| format!("c{i}")).collect();
+        let rows = vec![(0..cols).map(|i| format!("v{i}")).collect::<Vec<String>>(); 2];
+        SpreadsheetController::set_spreadsheet_data(&mut *s, headers, rows);
+        s
+    }
+
+    /// Columns floor at MIN_COL_W instead of squeezing: past the floor the run
+    /// overflows into the horizontal scroll, and within it there is none.
+    #[test]
+    fn columns_floor_at_min_width_and_overflow_scrolls() {
+        let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
+        let s = wide(6);
+        assert_eq!((*s).col_w(rect), MIN_COL_W);
+        let g = (*s).hgeom(rect).expect("6 floored columns overflow a 200px pane");
+        assert!((g.max_scroll - (6.0 * MIN_COL_W - 200.0)).abs() < 0.01);
+
+        let fits = wide(2);
+        assert!((*fits).hgeom(rect).is_none(), "2 columns share the pane, no h-scroll");
+        assert_eq!((*fits).col_w(rect), 100.0, "fitting columns still split the width evenly");
+    }
+
+    /// The sort hit-test must look up columns through the scrolled origin, or
+    /// clicking a header would sort the column that USED to be under the pointer.
+    #[test]
+    fn header_hit_test_tracks_horizontal_scroll() {
+        let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
+        let mut s = wide(6);
+        assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(0));
+        (*s).scroll_x = MIN_COL_W;
+        assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(1));
+    }
+
+    /// A horizontal wheel feeds hscroll velocity, tick integrates and decays it,
+    /// and the scroll clamps inside the overflow.
+    #[test]
+    fn horizontal_wheel_integrates_and_decays_through_tick() {
+        let mut ctx = UiContext::new();
+        let mut s = wide(6);
+        let (id, ptr) = (s.id(), s.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+
+        let wheel = Event::MouseWheel {
+            delta: MouseScrollDelta::LineDelta(2.0, 0.0),
+            x: 50.0,
+            y: 60.0,
+            local_x: 50.0,
+            local_y: 60.0,
+        };
+        assert!(s.handle_event(&wheel, &mut ctx), "in-rect horizontal wheel consumed");
+        assert!(WidgetHost::tick(&mut s, 0.016, &mut ctx), "first tick moves the h-scroll");
+        let mut guard = 0;
+        while WidgetHost::tick(&mut s, 0.016, &mut ctx) {
+            guard += 1;
+            assert!(guard < 1000, "h-inertia must decay to a stop");
+        }
+        let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
+        let max = (*s).hgeom(rect).unwrap().max_scroll;
+        assert!((*s).scroll_x >= 0.0 && (*s).scroll_x <= max, "h-scroll stays clamped");
+        assert!((*s).scroll_x > 0.0, "positive dx scrolled the columns");
+
+        // A pane whose columns fit ignores horizontal wheels.
+        let mut fits = wide(2);
+        let (fid, fptr) = (fits.id(), fits.as_ptr_mut());
+        ctx.register_widget(fid, fptr);
+        assert!(!fits.handle_event(&wheel, &mut ctx), "no overflow, wheel passes through");
+    }
+
     #[test]
     fn wheel_velocity_integrates_and_decays_through_tick() {
         let mut ctx = UiContext::new();