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

commit2c8c534f919049d8520efbf6456435dcdfa6aefc
parent03a2729a8a
authorLucas Galante <[email protected]>
date2026-07-08 12:56
feat(widget): migrate Spreadsheet + the tick/scroll adapter surface (Phase 5l)

New Input surface, forwarded by the adapter: tick(dt, rect) + wants_tick
(inertial scroll integration — hosts broadcast Element::tick every frame),
scrollable (-> Element::is_scrollable, the router's scroll-gesture hint),
and draggable now receives the laid-out rect (scroll widgets are draggable
only while content overflows; five implementors updated mechanically).

Adapted also gains real visibility: the Widget base carries none and the
legacy Element defaults are a no-op set_visible / always-true visible(), so
hideable widgets each stored their own flag. The adapter now owns one for
all migrated widgets, with gates where legacy hideable widgets carried
them: hit_test (the designer broadcasts wheel/press dispatch and relies on
hidden widgets rejecting the hit), direct-dispatch keyboard_input (hiding a
pane doesn't unfocus it), and the text_labels bridge. Deliberate behavior
fix, flagged: set_visible on already-migrated widgets (designer's pane
toggles on Breadcrumb) now actually works instead of being ignored.

Spreadsheet itself: scroll/scrollbar geometry consolidated into one
geom() helper (legacy re-derived it in seven places); wheel feeds velocity,
tick integrates + decays it; scrollbar drag and arrow/page/home/end keys
port unchanged; SpreadsheetController rides the capability hooks. paint()
deliberately does NOT emit the PARAM_BG background — the designer draws
widget backgrounds itself from color() + corner_style, and PARAM_BG is
translucent (emitting it again would double-blend).

Verified on the live compositor against the stashed legacy build, same
click sequence (View menu -> Show Spreadsheet Pane): startup idle diff 4px,
pane-open diff 32px, both confined to a one-pixel edge-blend strip at the
window's bottom row. The pane rendering mostly below the window at this
size is pre-existing designer layout, identical in both builds. 162 tests
pass (wheel->velocity->tick decay, scrollbar drag, key scrolling, hidden-
widget gating); all 19 client crates build.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkfJazPs9bRchkcozmxXCX

 src/widget/container/spreadsheet.rs | 706 ++++++++++++++++++------------------
 src/widget/display/node.rs          |   2 +-
 src/widget/display/panel.rs         |   2 +-
 src/widget/display/splitter.rs      |   2 +-
 src/widget/input/slider.rs          |   4 +-
 src/widget/model.rs                 |  67 +++-
 6 files changed, 425 insertions(+), 358 deletions(-)

diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index b39e155..60b65b8 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -1,14 +1,30 @@
+//! Narrow-trait `Spreadsheet` (Phase 5l) — a read-only table with a header row, zebra rows,
+//! column dividers, and an inertially-scrolled body: wheel input feeds a velocity that
+//! [`Input::tick`] integrates and decays each frame, the scrollbar thumb is host-drag-driven
+//! through the drag surface, and arrow/page/home/end keys jump the scroll. The scroll geometry
+//! (content/viewport heights, thumb position) is derived in one place ([`ScrollGeom`]) — legacy
+//! re-derived it in seven. [`SpreadsheetController`] rides the `Input` capability hooks.
+//!
+//! 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
+//! geometry starts at the header strip, exactly like the legacy `extra_quads`.
+
 use crate::colors;
-use crate::widget::*;
-use crate::widget::display::TextLabel;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{
+    Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseScrollDelta, NamedKey,
+    Paint, SpreadsheetController,
+};
+
+const HEADER_H: f32 = 24.0;
+const ROW_H: f32 = 24.0;
+const SCROLLBAR_W: f32 = 6.0;
+const SCROLLBAR_PAD: f32 = 2.0;
 
 pub struct Spreadsheet {
-    x: f32,
-    y: f32,
-    w: f32,
-    h: f32,
     hovered: bool,
-    visible: bool,
     headers: Vec<String>,
     rows: Vec<Vec<String>>,
     scroll_y: f32,
@@ -19,15 +35,24 @@ pub struct Spreadsheet {
     scrollbar_thumb_hovered: bool,
 }
 
+/// Scroll/scrollbar geometry for one (row count, rect) pair. Present only when the content
+/// overflows the viewport — every scroll behavior is gated on that, as in legacy.
+struct ScrollGeom {
+    visible_h: f32,
+    max_scroll: f32,
+    /// `scroll_y` clamped to the current bounds (data changes can leave the raw value stale).
+    scroll: f32,
+    scrollbar_x: f32,
+    track_y: f32,
+    thumb_h: f32,
+    thumb_y: f32,
+    track_range: f32,
+}
+
 impl Spreadsheet {
-    pub fn new() -> Self {
-        Self {
-            x: 0.0,
-            y: 0.0,
-            w: 0.0,
-            h: 0.0,
+    pub fn new() -> Adapted<Spreadsheet> {
+        let mut s = Adapted::new(Spreadsheet {
             hovered: false,
-            visible: false,
             headers: Vec::new(),
             rows: Vec::new(),
             scroll_y: 0.0,
@@ -36,309 +61,108 @@ impl Spreadsheet {
             drag_offset_y: 0.0,
             scrollbar_hovered: false,
             scrollbar_thumb_hovered: false,
-        }
-    }
-}
-
-impl Element for Spreadsheet {
-    fn is_scrollable(&self) -> bool { true }
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) { (true, true, true, true) }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
-    }
-
-    fn rect(&self) -> (f32, f32, f32, f32) {
-        if !self.visible {
-            (0.0, 0.0, 0.0, 0.0)
-        } else {
-            (self.x, self.y, self.w, self.h)
-        }
-    }
-
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.x = x;
-        self.y = y;
-        self.w = w;
-        self.h = h;
-    }
-
-    fn color(&self) -> [f32; 4] {
-        if !self.visible {
-            [0.0, 0.0, 0.0, 0.0]
-        } else {
-            colors::PARAM_BG
-        }
-    }
-
-    fn set_hovered(&mut self, v: bool) {
-        self.hovered = v;
-    }
-
-    fn hovered(&self) -> bool {
-        self.hovered
+        });
+        // The spreadsheet pane starts hidden (the designer toggles it in later).
+        crate::widget::Element::set_visible(&mut s, false);
+        s
     }
 
-    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
-            return false;
+    fn geom(&self, rect: Rect) -> Option<ScrollGeom> {
+        let content_h = self.rows.len() 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;
         }
-        let (rx, ry, rw, rh) = self.rect();
-        px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
+        let max_scroll = content_h - visible_h;
+        let scroll = self.scroll_y.clamp(0.0, max_scroll);
+        let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
+        let track_y = rect.y + HEADER_H;
+        let track_range = visible_h - thumb_h;
+        Some(ScrollGeom {
+            visible_h,
+            max_scroll,
+            scroll,
+            scrollbar_x: rect.x + rect.width - SCROLLBAR_W - SCROLLBAR_PAD,
+            track_y,
+            thumb_h,
+            thumb_y: track_y + (scroll / max_scroll) * track_range,
+            track_range,
+        })
     }
 
-    fn set_visible(&mut self, visible: bool) {
-        self.visible = visible;
-    }
-
-    fn visible(&self) -> bool {
-        self.visible
-    }
-
-    fn as_spreadsheet_controller(&self) -> Option<&dyn SpreadsheetController> { Some(self) }
-    fn as_spreadsheet_controller_mut(&mut self) -> Option<&mut dyn SpreadsheetController> { Some(self) }
-
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was_hovered = self.hovered;
-        self.hovered = self.hit_test(px, py, ctx);
-
-        let was_sb_hovered = self.scrollbar_hovered;
-        let was_thumb_hovered = self.scrollbar_thumb_hovered;
-
-        let content_h = self.rows.len() as f32 * 24.0;
-        let visible_h = (self.h - 24.0).max(0.0);
-        if visible_h > 0.0 && content_h > visible_h {
-            let scrollbar_w = 6.0;
-            let scrollbar_padding = 2.0;
-            let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
-            let track_y = self.y + 24.0;
-
-            self.scrollbar_hovered = px >= scrollbar_x - 2.0 && px <= self.x + self.w
-                && py >= track_y && py <= self.y + self.h;
-
-            let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
-            let max_scroll_y = content_h - visible_h;
-            let scroll_ratio = self.scroll_y / max_scroll_y;
-            let track_scroll_range = visible_h - thumb_h;
-            let thumb_y = track_y + scroll_ratio * track_scroll_range;
-
-            self.scrollbar_thumb_hovered = px >= scrollbar_x - 2.0 && px <= self.x + self.w
-                && py >= thumb_y && py <= thumb_y + thumb_h;
+    /// 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 {
+            ((thumb_y - g.track_y) / g.track_range).clamp(0.0, 1.0)
         } else {
-            self.scrollbar_hovered = false;
-            self.scrollbar_thumb_hovered = false;
-        }
-
-        was_hovered != self.hovered
-            || was_sb_hovered != self.scrollbar_hovered
-            || was_thumb_hovered != self.scrollbar_thumb_hovered
-    }
-
-    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        if self.hit_test(px, py, ctx) {
-            let content_h = self.rows.len() as f32 * 24.0;
-            let visible_h = (self.h - 24.0).max(0.0);
-            if visible_h > 0.0 && content_h > visible_h {
-                let scroll_amount = match delta {
-                    MouseScrollDelta::LineDelta(_x, y) => *y * 24.0,
-                    MouseScrollDelta::PixelDelta(pos) => pos.y as f32,
-                };
-                self.scroll_velocity += scroll_amount * 12.0;
-                return true;
-            }
-        }
-        false
-    }
-
-    fn draggable(&self) -> bool {
-        if !self.visible {
-            return false;
-        }
-        let content_h = self.rows.len() as f32 * 24.0;
-        let visible_h = (self.h - 24.0).max(0.0);
-        visible_h > 0.0 && content_h > visible_h
-    }
-
-    fn is_dragging(&self) -> bool {
-        self.dragging_scrollbar
-    }
-
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        self.scroll_velocity = 0.0;
-        let content_h = self.rows.len() as f32 * 24.0;
-        let visible_h = (self.h - 24.0).max(0.0);
-        if visible_h > 0.0 && content_h > visible_h {
-            let scrollbar_w = 6.0;
-            let scrollbar_padding = 2.0;
-            let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
-            let track_y = self.y + 24.0;
-
-            if px >= scrollbar_x - 4.0 && px <= self.x + self.w
-                && py >= track_y && py <= self.y + self.h
-            {
-                self.dragging_scrollbar = true;
-
-                let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
-                let max_scroll_y = content_h - visible_h;
-                let scroll_ratio = self.scroll_y / max_scroll_y;
-                let track_scroll_range = visible_h - thumb_h;
-                let thumb_y = track_y + scroll_ratio * track_scroll_range;
-
-                if py >= thumb_y && py <= thumb_y + thumb_h {
-                    self.drag_offset_y = py - thumb_y;
-                } else {
-                    self.drag_offset_y = thumb_h / 2.0;
-                    let new_thumb_y = py - self.drag_offset_y;
-                    let scroll_ratio = if track_scroll_range > 0.0 {
-                        ((new_thumb_y - track_y) / track_scroll_range).clamp(0.0, 1.0)
-                    } else {
-                        0.0
-                    };
-                    self.scroll_y = scroll_ratio * max_scroll_y;
-                }
-            }
-        }
+            0.0
+        };
+        self.scroll_y = ratio * g.max_scroll;
     }
+}
 
-    fn drag_update(&mut self, _px: f32, py: f32) -> bool {
-        if self.dragging_scrollbar {
-            self.scroll_velocity = 0.0;
-            let content_h = self.rows.len() as f32 * 24.0;
-            let visible_h = (self.h - 24.0).max(0.0);
-            if visible_h > 0.0 && content_h > visible_h {
-                let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
-                let max_scroll_y = content_h - visible_h;
-                let track_y = self.y + 24.0;
-                let track_scroll_range = visible_h - thumb_h;
-
-                let new_thumb_y = py - self.drag_offset_y;
-                let scroll_ratio = if track_scroll_range > 0.0 {
-                    ((new_thumb_y - track_y) / track_scroll_range).clamp(0.0, 1.0)
-                } else {
-                    0.0
-                };
-                let old_scroll_y = self.scroll_y;
-                self.scroll_y = scroll_ratio * max_scroll_y;
+impl Layout for Spreadsheet {}
 
-                return (self.scroll_y - old_scroll_y).abs() > 0.01;
-            }
-        }
-        false
+impl Paint for Spreadsheet {
+    fn color(&self) -> [f32; 4] {
+        colors::PARAM_BG
     }
 
-    fn drag_end(&mut self) {
-        self.dragging_scrollbar = false;
-        self.scroll_velocity = 0.0;
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+        // Legacy: rounded_corners override (all corners) with the Element-default 12.0 radius.
+        Some((12.0, (true, true, true, true)))
     }
 
-    fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
-        if self.scroll_velocity.abs() > 0.01 {
-            let content_h = self.rows.len() as f32 * 24.0;
-            let visible_h = (self.h - 24.0).max(0.0);
-            let max_scroll_y = (content_h - visible_h).max(0.0);
-            let old_scroll_y = self.scroll_y;
-
-            self.scroll_y = (self.scroll_y + self.scroll_velocity * dt).clamp(0.0, max_scroll_y);
-
-            // Decelerate with friction (exponential decay)
-            let friction = 8.0;
-            self.scroll_velocity *= (-friction * dt).exp();
-
-            if self.scroll_y == 0.0 || self.scroll_y == max_scroll_y {
-                self.scroll_velocity = 0.0;
-            }
-
-            if self.scroll_velocity.abs() < 5.0 {
-                self.scroll_velocity = 0.0;
-            }
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
+        let scroll = self.geom(rect).map_or(0.0, |g| g.scroll);
 
-            (self.scroll_y - old_scroll_y).abs() > 0.01
-        } else {
-            false
-        }
-    }
-
-    fn wants_tick(&self) -> bool {
-        true
-    }
-
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let mut quads = Vec::new();
-        
         // Header bg
-        quads.push((self.x, self.y, self.w, 24.0, [0.12, 0.12, 0.16, 0.4]));
+        ctx.quad(Rect { x, y, width: w, height: HEADER_H }, [0.12, 0.12, 0.16, 0.4]);
 
-        // Zebra rows
-        let row_h = 24.0;
-        let body_top = self.y + 24.0;
-        let body_bottom = self.y + self.h;
+        // Zebra rows + separators, clipped to the body band
+        let body_top = y + HEADER_H;
+        let body_bottom = y + h;
         for i in 0..self.rows.len() {
-            let ry = self.y + 24.0 + i as f32 * row_h - self.scroll_y;
-            if ry + row_h <= body_top || ry >= body_bottom {
+            let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
+            if ry + ROW_H <= body_top || ry >= body_bottom {
                 continue;
             }
             let draw_y = ry.max(body_top);
-            let draw_h = (ry + row_h).min(body_bottom) - draw_y;
+            let draw_h = (ry + ROW_H).min(body_bottom) - draw_y;
             if draw_h > 0.0 {
                 let row_color = if i % 2 == 0 {
                     [0.10, 0.10, 0.13, 0.15]
                 } else {
                     [0.08, 0.08, 0.11, 0.05]
                 };
-                quads.push((self.x, draw_y, self.w, draw_h, row_color));
+                ctx.quad(Rect { x, y: draw_y, width: w, height: draw_h }, row_color);
 
-                // Horizontal row separator
-                let sep_y = ry + row_h;
+                let sep_y = ry + ROW_H;
                 if sep_y >= body_top && sep_y < body_bottom {
-                    quads.push((self.x, sep_y, self.w, 1.0, [0.20, 0.20, 0.25, 0.15]));
+                    ctx.quad(Rect { x, y: sep_y, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.15]);
                 }
             }
         }
 
         // Header separator
-        quads.push((self.x, self.y + 24.0, self.w, 1.0, [0.20, 0.20, 0.25, 0.25]));
+        ctx.quad(Rect { x, y: y + HEADER_H, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.25]);
 
-        // Vertical separators
-        let divider_h = self.h;
-        if divider_h > 0.0 && !self.headers.is_empty() {
+        // Vertical column dividers
+        if h > 0.0 && !self.headers.is_empty() {
             let n_cols = self.headers.len();
             for i in 1..n_cols {
                 let r = i as f32 / n_cols as f32;
-                quads.push((self.x + self.w * r, self.y, 1.0, divider_h, [0.20, 0.20, 0.25, 0.15]));
+                ctx.quad(Rect { x: x + w * r, y, width: 1.0, height: h }, [0.20, 0.20, 0.25, 0.15]);
             }
         }
 
         // Scrollbar track & thumb
-        let content_h = self.rows.len() as f32 * row_h;
-        let visible_h = (self.h - 24.0).max(0.0);
-        if visible_h > 0.0 && content_h > visible_h {
-            let scrollbar_w = 6.0;
-            let scrollbar_padding = 2.0;
-            let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
-            let track_y = self.y + 24.0;
-            let track_h = visible_h;
-
-            // Track BG
-            quads.push((scrollbar_x, track_y, scrollbar_w, track_h, [0.05, 0.05, 0.08, 0.15]));
-
-            // Thumb
-            let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
-            let max_scroll_y = content_h - visible_h;
-            let scroll_ratio = self.scroll_y / max_scroll_y;
-            let track_scroll_range = visible_h - thumb_h;
-            let thumb_y = track_y + scroll_ratio * track_scroll_range;
-
+        if let Some(g) = self.geom(rect) {
+            ctx.quad(
+                Rect { x: g.scrollbar_x, y: g.track_y, width: SCROLLBAR_W, height: g.visible_h },
+                [0.05, 0.05, 0.08, 0.15],
+            );
             let thumb_color = if self.dragging_scrollbar {
                 [0.40, 0.40, 0.48, 1.0]
             } else if self.scrollbar_thumb_hovered {
@@ -348,96 +172,189 @@ impl Element for Spreadsheet {
             } else {
                 [0.18, 0.18, 0.24, 0.7]
             };
-
-            quads.push((scrollbar_x, thumb_y, scrollbar_w, thumb_h, thumb_color));
+            ctx.quad(
+                Rect { x: g.scrollbar_x, y: g.thumb_y, width: SCROLLBAR_W, height: g.thumb_h },
+                thumb_color,
+            );
         }
 
-        quads
-    }
-
-    fn text_labels(&self) -> Vec<TextLabel> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let mut labels = Vec::new();
+        // Header + cell text. Cells render only when the row lies fully inside the body.
         if self.headers.is_empty() {
-            return labels;
+            return;
         }
-
         let n_cols = self.headers.len();
         for (i, header) in self.headers.iter().enumerate() {
-            let cx = self.x + self.w * (i as f32 / n_cols as f32) + 8.0;
-            labels.push(TextLabel {
-                text: header.clone(),
-                x: cx,
-                y: self.y + 6.0,
-                font_size: 12.0,
-                color: [0xdd, 0xdd, 0xee],
-            });
+            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 row_h = 24.0;
-        let body_top = self.y + 24.0;
-        let body_bottom = self.y + self.h;
         for (i, row) in self.rows.iter().enumerate() {
-            let ry = self.y + 24.0 + i as f32 * row_h - self.scroll_y;
-            // Only show text if the row is fully inside the spreadsheet body
-            if ry < body_top || ry + row_h > body_bottom {
+            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 = self.x + self.w * (col_idx as f32 / n_cols as f32) + 8.0;
-                labels.push(TextLabel {
-                    text: val.clone(),
-                    x: cx,
-                    y: ry + 6.0,
-                    font_size: 12.0,
-                    color: [0xbb, 0xbb, 0xcc],
-                });
+                let cx = x + w * (col_idx as f32 / n_cols as f32) + 8.0;
+                ctx.text(val.clone(), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc]);
             }
         }
-        labels
     }
+}
 
-    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
+impl Input for Spreadsheet {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::PointerMove { x: px, y: py, .. } => {
+                let r = ectx.rect;
+                let was_hovered = self.hovered;
+                self.hovered =
+                    *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
+
+                let was_sb = self.scrollbar_hovered;
+                let was_thumb = self.scrollbar_thumb_hovered;
+                if let Some(g) = self.geom(r) {
+                    self.scrollbar_hovered = *px >= g.scrollbar_x - 2.0
+                        && *px <= r.x + r.width
+                        && *py >= g.track_y
+                        && *py <= r.y + r.height;
+                    self.scrollbar_thumb_hovered = *px >= g.scrollbar_x - 2.0
+                        && *px <= r.x + r.width
+                        && *py >= g.thumb_y
+                        && *py <= g.thumb_y + g.thumb_h;
+                } else {
+                    self.scrollbar_hovered = false;
+                    self.scrollbar_thumb_hovered = false;
+                }
+                was_hovered != self.hovered
+                    || was_sb != self.scrollbar_hovered
+                    || was_thumb != self.scrollbar_thumb_hovered
+            }
+            // 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
+                }
+            }
+            Event::KeyInput(key_event) => {
+                if key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                let Some(g) = self.geom(ectx.rect) else {
+                    return false;
+                };
+                let old = g.scroll;
+                let new = match &key_event.logical_key {
+                    Key::Named(NamedKey::ArrowDown) => (old + ROW_H).clamp(0.0, g.max_scroll),
+                    Key::Named(NamedKey::ArrowUp) => (old - ROW_H).clamp(0.0, g.max_scroll),
+                    Key::Named(NamedKey::PageDown) => (old + g.visible_h).clamp(0.0, g.max_scroll),
+                    Key::Named(NamedKey::PageUp) => (old - g.visible_h).clamp(0.0, g.max_scroll),
+                    Key::Named(NamedKey::Home) => 0.0,
+                    Key::Named(NamedKey::End) => g.max_scroll,
+                    _ => return false,
+                };
+                self.scroll_y = new;
+                (new - old).abs() > 0.01
+            }
+            _ => false,
         }
-        if event.state != ElementState::Pressed {
-            return false;
+    }
+
+    fn scrollable(&self) -> bool {
+        true
+    }
+
+    // --- Scrollbar drag, host-driven (the designer checks `draggable()` on the pressed widget
+    // and then streams `drag_update` at it). `drag_begin` decides whether the press actually
+    // landed on the scrollbar; a body press starts no drag, exactly like legacy.
+
+    fn draggable(&self, rect: Rect) -> bool {
+        self.geom(rect).is_some()
+    }
+
+    fn is_dragging(&self) -> bool {
+        self.dragging_scrollbar
+    }
+
+    fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
+        self.scroll_velocity = 0.0;
+        if let Some(g) = self.geom(rect) {
+            if px >= g.scrollbar_x - 4.0
+                && px <= rect.x + rect.width
+                && py >= g.track_y
+                && py <= rect.y + rect.height
+            {
+                self.dragging_scrollbar = true;
+                if py >= g.thumb_y && py <= g.thumb_y + g.thumb_h {
+                    self.drag_offset_y = py - g.thumb_y;
+                } else {
+                    // Track click: jump the thumb's center to the pointer.
+                    self.drag_offset_y = g.thumb_h / 2.0;
+                    self.scroll_to_thumb(&g, py - self.drag_offset_y);
+                }
+            }
         }
-        let content_h = self.rows.len() as f32 * 24.0;
-        let visible_h = (self.h - 24.0).max(0.0);
-        if visible_h <= 0.0 || content_h <= visible_h {
+    }
+
+    fn drag_update(&mut self, _px: f32, py: f32, rect: Rect) -> bool {
+        if !self.dragging_scrollbar {
             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;
+        }
+        false
+    }
 
-        let max_scroll_y = content_h - visible_h;
-        let old_scroll_y = self.scroll_y;
-        match &event.logical_key {
-            Key::Named(NamedKey::ArrowDown) => {
-                self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll_y);
-            }
-            Key::Named(NamedKey::ArrowUp) => {
-                self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll_y);
-            }
-            Key::Named(NamedKey::PageDown) => {
-                self.scroll_y = (self.scroll_y + visible_h).clamp(0.0, max_scroll_y);
-            }
-            Key::Named(NamedKey::PageUp) => {
-                self.scroll_y = (self.scroll_y - visible_h).clamp(0.0, max_scroll_y);
-            }
-            Key::Named(NamedKey::Home) => {
-                self.scroll_y = 0.0;
-            }
-            Key::Named(NamedKey::End) => {
-                self.scroll_y = max_scroll_y;
-            }
-            _ => return false,
+    fn drag_end(&mut self) {
+        self.dragging_scrollbar = false;
+        self.scroll_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 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() < 5.0 {
+            self.scroll_velocity = 0.0;
+        }
+
+        (self.scroll_y - old).abs() > 0.01
+    }
+
+    fn wants_tick(&self) -> bool {
+        true
+    }
 
-        (self.scroll_y - old_scroll_y).abs() > 0.01
+    fn spreadsheet_controller(&self) -> Option<&dyn SpreadsheetController> {
+        Some(self)
+    }
+    fn spreadsheet_controller_mut(&mut self) -> Option<&mut dyn SpreadsheetController> {
+        Some(self)
     }
 }
 
@@ -445,11 +362,100 @@ impl SpreadsheetController for Spreadsheet {
     fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
         self.headers = headers;
         self.rows = rows;
-        
-        // Clamp scroll_y to new bounds
-        let content_h = self.rows.len() as f32 * 24.0;
-        let visible_h = (self.h - 24.0).max(0.0);
-        let max_scroll_y = (content_h - visible_h).max(0.0);
-        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll_y);
+        // The raw scroll may now exceed the new content; every consumer clamps through
+        // `geom()`, and the next scroll write re-clamps it for real.
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::context::UiContext;
+    use crate::widget::Element;
+
+    fn filled(rows: usize) -> Adapted<Spreadsheet> {
+        let mut s = Spreadsheet::new();
+        s.set_visible(true);
+        Element::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0); // viewport: 100 = ~4 rows of 24
+        let data: Vec<Vec<String>> =
+            (0..rows).map(|i| vec![format!("r{i}"), format!("v{i}")]).collect();
+        let elem: &mut dyn Element = &mut s;
+        elem.as_spreadsheet_controller_mut()
+            .expect("Spreadsheet exposes SpreadsheetController")
+            .set_spreadsheet_data(vec!["a".into(), "b".into()], data);
+        s
+    }
+
+    #[test]
+    fn wheel_velocity_integrates_and_decays_through_tick() {
+        let mut ctx = UiContext::new();
+        let mut s = filled(50);
+        let (id, ptr) = (s.id(), s.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+
+        // A wheel over the body feeds velocity (scroll down = negative line delta in practice,
+        // but sign just follows the delta)…
+        let wheel = Event::MouseWheel {
+            delta: MouseScrollDelta::LineDelta(0.0, 2.0),
+            x: 50.0,
+            y: 60.0,
+            local_x: 50.0,
+            local_y: 60.0,
+        };
+        assert!(s.handle_event(&wheel, &mut ctx), "in-rect wheel consumed");
+
+        // …which tick integrates into scroll movement and decays to a stop.
+        assert!(Element::tick(&mut s, 0.016, &mut ctx), "first tick moves the scroll");
+        let mut guard = 0;
+        while Element::tick(&mut s, 0.016, &mut ctx) {
+            guard += 1;
+            assert!(guard < 1000, "inertia must decay to a stop");
+        }
+
+        // Hidden spreadsheets are not hittable, so the wheel passes through.
+        s.set_visible(false);
+        assert!(!s.handle_event(&wheel, &mut ctx), "hidden widget ignores wheel");
+    }
+
+    #[test]
+    fn scrollbar_drag_and_keys_move_the_scroll() {
+        let mut ctx = UiContext::new();
+        let mut s = filled(50);
+        let (id, ptr) = (s.id(), s.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
+
+        // content 1200, viewport 100 -> overflowing, so the host may drag it.
+        assert!(Element::draggable(&s));
+
+        // Press on the scrollbar track (x >= 200-6-2-4): thumb jumps, drag engages.
+        Element::drag_begin(&mut s, 195.0, 80.0);
+        assert!(Element::is_dragging(&s));
+        assert!(Element::drag_update(&mut s, 195.0, 110.0), "thumb drag scrolls");
+        let dragged_to = s.inner().geom(rect).unwrap().scroll;
+        assert!(dragged_to > 0.0);
+        Element::drag_end(&mut s);
+        assert!(!Element::is_dragging(&s));
+
+        // A body press (left of the scrollbar) engages no drag.
+        Element::drag_begin(&mut s, 50.0, 60.0);
+        assert!(!Element::is_dragging(&s), "body press is not a scrollbar drag");
+
+        // End key jumps to max; Home returns to zero. (Keys route via keyboard_input.)
+        let end = crate::widget::KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::End),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(Element::keyboard_input(&mut s, &end, &mut ctx));
+        let g = s.inner().geom(rect).unwrap();
+        assert_eq!(g.scroll, g.max_scroll);
+
+        // Hidden: the focused-widget keyboard path must not consume keys.
+        s.set_visible(false);
+        assert!(!Element::keyboard_input(&mut s, &end, &mut ctx), "hidden widget ignores keys");
     }
 }
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index 08676d2..35bd841 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -174,7 +174,7 @@ impl Input for Node {
         }
     }
 
-    fn draggable(&self) -> bool {
+    fn draggable(&self, _rect: Rect) -> bool {
         !self.toggle_hovered
     }
     fn is_dragging(&self) -> bool {
diff --git a/src/widget/display/panel.rs b/src/widget/display/panel.rs
index 73eb394..906bfa6 100644
--- a/src/widget/display/panel.rs
+++ b/src/widget/display/panel.rs
@@ -58,7 +58,7 @@ impl Input for Panel {
         }
     }
 
-    fn draggable(&self) -> bool {
+    fn draggable(&self, _rect: Rect) -> bool {
         true
     }
     fn is_dragging(&self) -> bool {
diff --git a/src/widget/display/splitter.rs b/src/widget/display/splitter.rs
index e4de131..2f18997 100644
--- a/src/widget/display/splitter.rs
+++ b/src/widget/display/splitter.rs
@@ -57,7 +57,7 @@ impl Input for Splitter {
         }
     }
 
-    fn draggable(&self) -> bool {
+    fn draggable(&self, _rect: Rect) -> bool {
         true
     }
     fn is_dragging(&self) -> bool {
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 310796b..63b9205 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -385,7 +385,7 @@ impl Input for Slider {
         true
     }
 
-    fn draggable(&self) -> bool {
+    fn draggable(&self, _rect: Rect) -> bool {
         true
     }
     fn is_dragging(&self) -> bool {
@@ -615,7 +615,7 @@ impl Input for RangeSlider {
         }
     }
 
-    fn draggable(&self) -> bool {
+    fn draggable(&self, _rect: Rect) -> bool {
         true
     }
     fn is_dragging(&self) -> bool {
diff --git a/src/widget/model.rs b/src/widget/model.rs
index a69935c..bce2514 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -249,7 +249,9 @@ pub trait Input {
     // --- Drag surface: legacy hosts (designer, control_panel, parameters_bg, graph, audio…)
     // drive drags by calling these directly on the widget, not through events.
 
-    fn draggable(&self) -> bool {
+    /// Whether a press on this widget starts a host-driven drag. Receives the laid-out rect:
+    /// scroll widgets (Spreadsheet) are draggable only while their content overflows it.
+    fn draggable(&self, _rect: Rect) -> bool {
         false
     }
     fn is_dragging(&self) -> bool {
@@ -269,6 +271,28 @@ pub trait Input {
     /// Movement bounds pushed in by hosts (legacy `Element::set_drag_bounds`).
     fn set_drag_bounds(&mut self, _bx: f32, _by: f32, _bw: f32, _bh: f32) {}
 
+    // --- Tick surface: hosts broadcast `Element::tick(dt)` every frame (the designer's render
+    // loop) to advance time-based widget state — inertial scroll velocity, here. Transitional:
+    // §3.6 `Animated<T>` + arena-driven frame requests replace hand-ticked state.
+
+    /// Advance time-based state by `dt` seconds against the laid-out rect. Return whether
+    /// anything observable changed (drives redraw).
+    fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
+        false
+    }
+
+    /// Whether this widget wants `tick` calls from tick-gating hosts (legacy
+    /// `Element::wants_tick`; the designer ticks unconditionally and ignores this).
+    fn wants_tick(&self) -> bool {
+        false
+    }
+
+    /// Whether this widget consumes scroll gestures (legacy `Element::is_scrollable`, read by
+    /// the router's scroll-gesture gating).
+    fn scrollable(&self) -> bool {
+        false
+    }
+
     // --- Controller capabilities (transitional, like the polling surface above). The legacy
     // tree reaches a widget's typed API through the `Element::as_*_controller` downcast pairs;
     // `Element` is implemented exactly once (for `Adapted<W>`), so a migrated controller widget
@@ -328,6 +352,11 @@ pub trait Input {
 #[derive(Debug, Clone)]
 pub struct Adapted<W: Layout + Paint + Input + 'static> {
     base: Widget,
+    /// The [`Widget`] base carries no visibility, and the legacy `Element` defaults are a no-op
+    /// `set_visible` + always-true `visible()` — every hideable legacy widget stores its own
+    /// flag. The adapter owns it once for all migrated widgets: hosts toggle panes through
+    /// `Element::set_visible` (the designer), and the hit-test/render bridges gate on it.
+    visible: bool,
     inner: W,
 }
 
@@ -340,7 +369,7 @@ impl<W: Layout + Paint + Input + 'static> Drop for Adapted<W> {
 impl<W: Layout + Paint + Input + 'static> Adapted<W> {
     /// Wrap `inner` with a fresh [`Widget`] base.
     pub fn new(inner: W) -> Self {
-        Adapted { base: Widget::new(), inner }
+        Adapted { base: Widget::new(), visible: true, inner }
     }
 
     /// The wrapped widget.
@@ -473,6 +502,13 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         self as *mut Self as *mut (dyn Element + 'static)
     }
 
+    fn set_visible(&mut self, visible: bool) {
+        self.visible = visible;
+    }
+    fn visible(&self) -> bool {
+        self.visible
+    }
+
     // --- Layout concern -> `Layout` ---
     fn layout_style(&self) -> Option<Style> {
         Layout::layout_style(&self.inner)
@@ -564,6 +600,9 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     /// widget draws — inline labels, readouts), plus the base-label text for detached-label
     /// widgets (drawn by the adapter, since the label lives on the base).
     fn text_labels(&self) -> Vec<TextLabel> {
+        if !self.visible() {
+            return Vec::new();
+        }
         let mut out: Vec<TextLabel> = self
             .painted_prims()
             .into_iter()
@@ -667,11 +706,21 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         Input::set_selected(&mut self.inner, selected)
     }
     fn draggable(&self) -> bool {
-        Input::draggable(&self.inner)
+        Input::draggable(&self.inner, self.content_rect())
     }
     fn is_dragging(&self) -> bool {
         Input::is_dragging(&self.inner)
     }
+    fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
+        let rect = self.content_rect();
+        Input::tick(&mut self.inner, dt, rect)
+    }
+    fn wants_tick(&self) -> bool {
+        Input::wants_tick(&self.inner)
+    }
+    fn is_scrollable(&self) -> bool {
+        Input::scrollable(&self.inner)
+    }
     fn drag_begin(&mut self, px: f32, py: f32) {
         let rect = self.content_rect();
         Input::drag_begin(&mut self.inner, px, py, rect)
@@ -752,6 +801,12 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         )
     }
     fn keyboard_input(&mut self, event: &crate::widget::KeyEvent, ctx: &mut UiContext) -> bool {
+        // A widget hidden while still holding focus (the designer keys into `focused_widget`;
+        // hiding a pane doesn't unfocus it) must not consume keys — the legacy visibility-toggled
+        // widgets gated their keyboard_input overrides on `visible` themselves.
+        if !self.visible() {
+            return false;
+        }
         self.handle_event(&Event::KeyInput(event.clone()), ctx)
     }
 
@@ -799,6 +854,12 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     }
 
     fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+        // Hidden widgets are not hittable. Legacy widgets with a visibility toggle (Spreadsheet)
+        // carry this gate themselves — and need it: hosts broadcast wheel/press dispatch to
+        // every widget (the designer) and rely on hidden ones rejecting the hit.
+        if !self.visible() {
+            return false;
+        }
         // Preserve the legacy occlusion check (a covering layer swallows the hit), then delegate
         // the geometric test to the narrow trait instead of the row/label-offset machinery.
         if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {