GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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) {