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

src/widget/container/spreadsheet.rs (37.9K)

  1 //! Narrow-trait `Spreadsheet` (Phase 5l) — a read-only table with a header row, zebra rows,
  2 //! column dividers, and an inertially-scrolled body: wheel input feeds a velocity that
  3 //! [`Input::tick`] integrates and decays each frame, the scrollbar thumb is host-drag-driven
  4 //! through the drag surface, and arrow/page/home/end keys jump the scroll. The scroll geometry
  5 //! (content/viewport heights, thumb position) is derived in one place ([`ScrollGeom`]) — legacy
  6 //! re-derived it in seven. [`SpreadsheetController`] rides the `Input` capability hooks.
  7 //!
  8 //! The `PARAM_BG` background is NOT emitted here: the designer's render path draws every
  9 //! widget's background itself from `color()` + `corner_style()` (`push_widget_vertices`), and
 10 //! `PARAM_BG` is translucent — emitting it again would double-blend. This widget's own
 11 //! geometry starts at the header strip, exactly like the legacy `extra_quads`.
 12 
 13 use crate::colors;
 14 use crate::scene::layout::Rect;
 15 use crate::scene::paint::PaintCtx;
 16 use crate::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion};
 17 use crate::widget::{
 18     Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, MouseScrollDelta,
 19     NamedKey, Paint, SpreadsheetController,
 20 };
 21 
 22 const HEADER_H: f32 = 24.0;
 23 const ROW_H: f32 = 24.0;
 24 const SCROLLBAR_W: f32 = 6.0;
 25 const SCROLLBAR_PAD: f32 = 2.0;
 26 /// Columns never squeeze below this: when they don't fit, the pane scrolls
 27 /// horizontally instead. Sized for a 4-decimal value / a short header plus its
 28 /// sort mark in the 12px mono label font, with the cell padding on both sides.
 29 const MIN_COL_W: f32 = 76.0;
 30 
 31 pub struct Spreadsheet {
 32     hovered: bool,
 33     headers: Vec<String>,
 34     rows: Vec<Vec<String>>,
 35     /// Display order into `rows` — the identity permutation unless `sort` is set.
 36     /// Rebuilt by `apply_sort` at both mutation sites (header click, data refresh),
 37     /// so `order.len() == rows.len()` always holds.
 38     order: Vec<usize>,
 39     /// Active sort: `(column, ascending)`. A header click cycles
 40     /// ascending → descending → natural order (matching the source data).
 41     sort: Option<(usize, bool)>,
 42     /// Header cell the pointer is over — hover tint, like the Processes page's
 43     /// sortable headers.
 44     header_hover_col: Option<usize>,
 45     scroll_y: f32,
 46     dragging_scrollbar: bool,
 47     drag_offset_y: f32,
 48     scrollbar_hovered: bool,
 49     scrollbar_thumb_hovered: bool,
 50     scroll_x: f32,
 51     /// Smooth-scroll driver behind `scroll_y`/`scroll_x`: wheel notches
 52     /// glide, trackpad flicks coast (the widget's former velocity model,
 53     /// now the toolkit-wide one).
 54     motion: ScrollMotion,
 55     dragging_hscrollbar: bool,
 56     drag_offset_x: f32,
 57     hscrollbar_hovered: bool,
 58     hscrollbar_thumb_hovered: bool,
 59 }
 60 
 61 /// Scroll/scrollbar geometry for one (row count, rect) pair. Present only when the content
 62 /// overflows the viewport — every scroll behavior is gated on that, as in legacy.
 63 struct ScrollGeom {
 64     visible_h: f32,
 65     max_scroll: f32,
 66     /// `scroll_y` clamped to the current bounds (data changes can leave the raw value stale).
 67     scroll: f32,
 68     scrollbar_x: f32,
 69     track_y: f32,
 70     thumb_h: f32,
 71     thumb_y: f32,
 72     track_range: f32,
 73 }
 74 
 75 /// [`ScrollGeom`]'s horizontal twin, for the bottom scrollbar. Present only
 76 /// when the column run overflows the pane width.
 77 struct HScrollGeom {
 78     visible_w: f32,
 79     max_scroll: f32,
 80     /// `scroll_x` clamped to the current bounds.
 81     scroll: f32,
 82     track_x: f32,
 83     track_y: f32,
 84     thumb_w: f32,
 85     thumb_x: f32,
 86     track_range: f32,
 87 }
 88 
 89 impl Spreadsheet {
 90     pub fn new() -> Adapted<Spreadsheet> {
 91         let mut s = Adapted::new(Spreadsheet {
 92             hovered: false,
 93             headers: Vec::new(),
 94             rows: Vec::new(),
 95             order: Vec::new(),
 96             sort: None,
 97             header_hover_col: None,
 98             scroll_y: 0.0,
 99             dragging_scrollbar: false,
100             drag_offset_y: 0.0,
101             scrollbar_hovered: false,
102             scrollbar_thumb_hovered: false,
103             scroll_x: 0.0,
104             motion: ScrollMotion::new(),
105             dragging_hscrollbar: false,
106             drag_offset_x: 0.0,
107             hscrollbar_hovered: false,
108             hscrollbar_thumb_hovered: false,
109         });
110         // The spreadsheet pane starts hidden (the designer toggles it in later).
111         crate::widget::WidgetHost::set_visible(&mut s, false);
112         s
113     }
114 
115     fn geom(&self, rect: Rect) -> Option<ScrollGeom> {
116         let content_h = self.rows.len() as f32 * ROW_H;
117         let visible_h = (rect.height - HEADER_H).max(0.0);
118         if visible_h <= 0.0 || content_h <= visible_h {
119             return None;
120         }
121         let max_scroll = content_h - visible_h;
122         let scroll = self.scroll_y.clamp(0.0, max_scroll);
123         let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
124         let track_y = rect.y + HEADER_H;
125         let track_range = visible_h - thumb_h;
126         Some(ScrollGeom {
127             visible_h,
128             max_scroll,
129             scroll,
130             scrollbar_x: rect.x + rect.width - SCROLLBAR_W - SCROLLBAR_PAD,
131             track_y,
132             thumb_h,
133             thumb_y: track_y + (scroll / max_scroll) * track_range,
134             track_range,
135         })
136     }
137 
138     /// One column's width: an even share of the pane, floored at [`MIN_COL_W`] —
139     /// past the floor the content overflows into the horizontal scroll.
140     fn col_w(&self, rect: Rect) -> f32 {
141         let n = self.headers.len().max(1) as f32;
142         (rect.width / n).max(MIN_COL_W)
143     }
144 
145     /// Horizontal counterpart of [`geom`]: present only when the column run is
146     /// wider than the pane.
147     fn hgeom(&self, rect: Rect) -> Option<HScrollGeom> {
148         let content_w = self.col_w(rect) * self.headers.len() as f32;
149         let visible_w = rect.width;
150         if visible_w <= 0.0 || content_w <= visible_w {
151             return None;
152         }
153         let max_scroll = content_w - visible_w;
154         let scroll = self.scroll_x.clamp(0.0, max_scroll);
155         let thumb_w = ((visible_w / content_w) * visible_w).clamp(15.0_f32.min(visible_w), visible_w);
156         let track_x = rect.x;
157         let track_range = visible_w - thumb_w;
158         Some(HScrollGeom {
159             visible_w,
160             max_scroll,
161             scroll,
162             track_x,
163             track_y: rect.y + rect.height - SCROLLBAR_W - SCROLLBAR_PAD,
164             thumb_w,
165             thumb_x: track_x + (scroll / max_scroll) * track_range,
166             track_range,
167         })
168     }
169 
170     /// The clamped horizontal scroll — 0 while everything fits.
171     fn hscroll(&self, rect: Rect) -> f32 {
172         self.hgeom(rect).map_or(0.0, |g| g.scroll)
173     }
174 
175     /// The header column under `(px, py)`, if the point is inside the header band.
176     fn header_col_at(&self, px: f32, py: f32, rect: Rect) -> Option<usize> {
177         if self.headers.is_empty() || rect.width <= 0.0 {
178             return None;
179         }
180         if px < rect.x || px > rect.x + rect.width || py < rect.y || py >= rect.y + HEADER_H {
181             return None;
182         }
183         let n = self.headers.len();
184         let col = ((px - rect.x + self.hscroll(rect)) / self.col_w(rect)) as usize;
185         if col >= n {
186             return None;
187         }
188         Some(col)
189     }
190 
191     /// Scroll to where a thumb dragged to `thumb_x` puts the columns.
192     fn hscroll_to_thumb(&mut self, g: &HScrollGeom, thumb_x: f32) {
193         let ratio = if g.track_range > 0.0 {
194             ((thumb_x - g.track_x) / g.track_range).clamp(0.0, 1.0)
195         } else {
196             0.0
197         };
198         self.scroll_x = ratio * g.max_scroll;
199     }
200 
201     /// Cell comparison: numeric when both cells parse (so "10" sorts after "9"),
202     /// lexicographic otherwise.
203     fn cmp_cells(a: &str, b: &str) -> std::cmp::Ordering {
204         match (a.parse::<f64>(), b.parse::<f64>()) {
205             (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
206             _ => a.cmp(b),
207         }
208     }
209 
210     /// Rebuild `order` from `sort`. Stable, so equal cells keep their source order.
211     fn apply_sort(&mut self) {
212         // A refresh can shrink the column set out from under the sort.
213         if let Some((col, _)) = self.sort {
214             if col >= self.headers.len() {
215                 self.sort = None;
216             }
217         }
218         self.order = (0..self.rows.len()).collect();
219         if let Some((col, ascending)) = self.sort {
220             let rows = &self.rows;
221             let cell = |r: usize| rows[r].get(col).map(String::as_str).unwrap_or("");
222             self.order.sort_by(|&a, &b| {
223                 let ord = Self::cmp_cells(cell(a), cell(b));
224                 if ascending { ord } else { ord.reverse() }
225             });
226         }
227     }
228 
229     /// Scroll to where a thumb dragged to `thumb_y` puts the content.
230     fn scroll_to_thumb(&mut self, g: &ScrollGeom, thumb_y: f32) {
231         let ratio = if g.track_range > 0.0 {
232             ((thumb_y - g.track_y) / g.track_range).clamp(0.0, 1.0)
233         } else {
234             0.0
235         };
236         self.scroll_y = ratio * g.max_scroll;
237     }
238 }
239 
240 impl Layout for Spreadsheet {}
241 
242 impl Paint for Spreadsheet {
243     /// The pane IS its own background plate, wearing the parameter plate's fill —
244     /// same tint, opacity, and blur-behind marker (`param_plate_fill`) — so it
245     /// bevels like the params plate and tracks a live retint / opacity / blur
246     /// toggle with it.
247     fn color(&self) -> [f32; 4] {
248         colors::param_plate_fill()
249     }
250 
251     /// The shared plate corner radius (rounded on all four corners when non-zero),
252     /// matching the rounded clip hosts carve for the pane's content.
253     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
254         let r = crate::layout::plate_corner_radius();
255         let on = r > 0.0;
256         Some((r, (on, on, on, on)))
257     }
258 
259     /// The shared plate border — under `control_relief` the host promotes this to
260     /// the plate bevel, like `ParametersBg`.
261     fn solid_border(&self) -> Option<([f32; 4], f32)> {
262         colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
263     }
264 
265     /// Subtree painter: `paint` authors the pane's complete text with
266     /// per-column clamp bounds, so its Text prims must pass through
267     /// `paint_self` verbatim — the own-labels re-derivation drops per-prim
268     /// bounds, which is exactly how long cell values used to overlap into
269     /// their neighbor columns on narrow panes.
270     fn paints_own_subtree(&self) -> bool {
271         true
272     }
273 
274     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
275         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
276         let scroll = self.geom(rect).map_or(0.0, |g| g.scroll);
277 
278         // Header bg
279         ctx.quad(Rect { x, y, width: w, height: HEADER_H }, [0.12, 0.12, 0.16, 0.4]);
280 
281         // Zebra rows + separators, clipped to the body band
282         let body_top = y + HEADER_H;
283         let body_bottom = y + h;
284         for i in 0..self.rows.len() {
285             let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
286             if ry + ROW_H <= body_top || ry >= body_bottom {
287                 continue;
288             }
289             let draw_y = ry.max(body_top);
290             let draw_h = (ry + ROW_H).min(body_bottom) - draw_y;
291             if draw_h > 0.0 {
292                 let row_color = if i % 2 == 0 {
293                     [0.10, 0.10, 0.13, 0.15]
294                 } else {
295                     [0.08, 0.08, 0.11, 0.05]
296                 };
297                 ctx.quad(Rect { x, y: draw_y, width: w, height: draw_h }, row_color);
298 
299                 let sep_y = ry + ROW_H;
300                 if sep_y >= body_top && sep_y < body_bottom {
301                     ctx.quad(Rect { x, y: sep_y, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.15]);
302                 }
303             }
304         }
305 
306         // Header separator
307         ctx.quad(Rect { x, y: y + HEADER_H, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.25]);
308 
309         // Vertical column dividers, at scrolled column edges, kept inside the pane.
310         let hscroll = self.hscroll(rect);
311         if h > 0.0 && !self.headers.is_empty() {
312             let n_cols = self.headers.len();
313             let cw = self.col_w(rect);
314             for i in 1..n_cols {
315                 let dx = x + cw * i as f32 - hscroll;
316                 if dx <= x || dx >= x + w {
317                     continue;
318                 }
319                 ctx.quad(Rect { x: dx, y, width: 1.0, height: h }, [0.20, 0.20, 0.25, 0.15]);
320             }
321         }
322 
323         // Scrollbar track & thumb
324         if let Some(g) = self.geom(rect) {
325             ctx.quad(
326                 Rect { x: g.scrollbar_x, y: g.track_y, width: SCROLLBAR_W, height: g.visible_h },
327                 [0.05, 0.05, 0.08, 0.15],
328             );
329             let thumb_color = if self.dragging_scrollbar {
330                 [0.40, 0.40, 0.48, 1.0]
331             } else if self.scrollbar_thumb_hovered {
332                 [0.32, 0.32, 0.38, 1.0]
333             } else if self.scrollbar_hovered {
334                 [0.24, 0.24, 0.30, 0.9]
335             } else {
336                 [0.18, 0.18, 0.24, 0.7]
337             };
338             ctx.quad(
339                 Rect { x: g.scrollbar_x, y: g.thumb_y, width: SCROLLBAR_W, height: g.thumb_h },
340                 thumb_color,
341             );
342         }
343 
344         // Horizontal scrollbar along the bottom, the vertical bar's twin.
345         if let Some(g) = self.hgeom(rect) {
346             ctx.quad(
347                 Rect { x: g.track_x, y: g.track_y, width: g.visible_w, height: SCROLLBAR_W },
348                 [0.05, 0.05, 0.08, 0.15],
349             );
350             let thumb_color = if self.dragging_hscrollbar {
351                 [0.40, 0.40, 0.48, 1.0]
352             } else if self.hscrollbar_thumb_hovered {
353                 [0.32, 0.32, 0.38, 1.0]
354             } else if self.hscrollbar_hovered {
355                 [0.24, 0.24, 0.30, 0.9]
356             } else {
357                 [0.18, 0.18, 0.24, 0.7]
358             };
359             ctx.quad(
360                 Rect { x: g.thumb_x, y: g.track_y, width: g.thumb_w, height: SCROLLBAR_W },
361                 thumb_color,
362             );
363         }
364 
365         // Header + cell text. Cells render only when the row lies fully inside the body.
366         if self.headers.is_empty() {
367             return;
368         }
369         let n_cols = self.headers.len();
370         let col_w = self.col_w(rect);
371         // Scrolled column origin; columns fully outside the pane skip.
372         let xoff = x - hscroll;
373         let col_visible = |col: usize| -> bool {
374             let cx0 = xoff + col_w * col as f32;
375             cx0 + col_w > x && cx0 < x + w
376         };
377         if let Some(hc) = self.header_hover_col {
378             // Subtle hover tint on the clickable header cell (the Processes-page
379             // sortable-header convention), clamped to the pane.
380             let hx0 = (xoff + col_w * hc as f32).max(x);
381             let hx1 = (xoff + col_w * (hc + 1) as f32).min(x + w);
382             if hx1 > hx0 {
383                 ctx.quad(Rect { x: hx0, y, width: hx1 - hx0, height: HEADER_H }, [1.0, 1.0, 1.0, 0.05]);
384             }
385         }
386         // Every label clamps to its own column (a 4px gutter short of the
387         // divider) AND to the pane, so a long value cuts off instead of
388         // running under its neighbor, and half-scrolled edge columns stop at
389         // the plate instead of bleeding past it.
390         let col_bounds = |col: usize, top: f32, height: f32| -> Option<[f32; 4]> {
391             let x0 = (xoff + col_w * col as f32).max(x);
392             let x1 = (xoff + col_w * (col + 1) as f32 - 4.0).min(x + w);
393             if x1 <= x0 {
394                 return None;
395             }
396             Some([x0, top, x1, top + height])
397         };
398         // Ellipsize what the clamp would cut, so truncation reads as
399         // deliberate. A char budget from ONE cached measurement is exact
400         // because the DE label font is monospace; the clamp bounds stay on
401         // as the backstop for any fallback-font drift. Below three columns'
402         // worth of budget the mark would REPLACE the content (a 19px column
403         // fits one glyph — a bare "…" says less than a clipped digit), so
404         // very narrow columns keep the raw string and let the clamp cut it.
405         let fam = crate::layout::control_label_font();
406         let char_w =
407             (crate::widget::display::measure_text_width("0123456789", &fam, 12.0) / 10.0).max(1.0);
408         let budget = ((col_w - 12.0) / char_w).floor() as usize;
409         let fit = move |s: String| -> String {
410             if budget < 3 || s.chars().count() <= budget {
411                 return s;
412             }
413             let mut out: String = s.chars().take(budget - 1).collect();
414             out.push('\u{2026}');
415             out
416         };
417         for (i, header) in self.headers.iter().enumerate() {
418             if !col_visible(i) {
419                 continue;
420             }
421             let cx = xoff + col_w * i as f32 + 8.0;
422             let (label, color) = match self.sort {
423                 Some((col, ascending)) if col == i => {
424                     let mark = if ascending { '\u{25b2}' } else { '\u{25bc}' };
425                     (format!("{header} {mark}"), [0xff, 0xff, 0xff])
426                 }
427                 _ => (header.clone(), [0xdd, 0xdd, 0xee]),
428             };
429             ctx.text_with(fit(label), cx, y + 6.0, 12.0, color, None, col_bounds(i, y, HEADER_H));
430         }
431         for (i, &src) in self.order.iter().enumerate() {
432             let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
433             if ry < body_top || ry + ROW_H > body_bottom {
434                 continue;
435             }
436             for (col_idx, val) in self.rows[src].iter().enumerate().take(n_cols) {
437                 if !col_visible(col_idx) {
438                     continue;
439                 }
440                 let cx = xoff + col_w * col_idx as f32 + 8.0;
441                 ctx.text_with(fit(val.clone()), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc], None, col_bounds(col_idx, ry, ROW_H));
442             }
443         }
444     }
445 }
446 
447 impl Input for Spreadsheet {
448     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
449         match event {
450             Event::PointerMove { x: px, y: py, .. } => {
451                 let r = ectx.rect;
452                 let was_hovered = self.hovered;
453                 self.hovered =
454                     *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
455 
456                 let was_sb = self.scrollbar_hovered;
457                 let was_thumb = self.scrollbar_thumb_hovered;
458                 if let Some(g) = self.geom(r) {
459                     self.scrollbar_hovered = *px >= g.scrollbar_x - 2.0
460                         && *px <= r.x + r.width
461                         && *py >= g.track_y
462                         && *py <= r.y + r.height;
463                     self.scrollbar_thumb_hovered = *px >= g.scrollbar_x - 2.0
464                         && *px <= r.x + r.width
465                         && *py >= g.thumb_y
466                         && *py <= g.thumb_y + g.thumb_h;
467                 } else {
468                     self.scrollbar_hovered = false;
469                     self.scrollbar_thumb_hovered = false;
470                 }
471                 let was_hsb = self.hscrollbar_hovered;
472                 let was_hthumb = self.hscrollbar_thumb_hovered;
473                 if let Some(g) = self.hgeom(r) {
474                     self.hscrollbar_hovered = *py >= g.track_y - 2.0
475                         && *py <= r.y + r.height
476                         && *px >= g.track_x
477                         && *px <= g.track_x + g.visible_w;
478                     self.hscrollbar_thumb_hovered = *py >= g.track_y - 2.0
479                         && *py <= r.y + r.height
480                         && *px >= g.thumb_x
481                         && *px <= g.thumb_x + g.thumb_w;
482                 } else {
483                     self.hscrollbar_hovered = false;
484                     self.hscrollbar_thumb_hovered = false;
485                 }
486                 let was_header = self.header_hover_col;
487                 self.header_hover_col = self.header_col_at(*px, *py, r);
488                 was_hovered != self.hovered
489                     || was_sb != self.scrollbar_hovered
490                     || was_thumb != self.scrollbar_thumb_hovered
491                     || was_hsb != self.hscrollbar_hovered
492                     || was_hthumb != self.hscrollbar_thumb_hovered
493                     || was_header != self.header_hover_col
494             }
495             // A left press on a column header cycles that column's sort:
496             // ascending → descending → back to natural order.
497             Event::MouseButton {
498                 button: MouseButton::Left,
499                 state: ElementState::Pressed,
500                 x: px,
501                 y: py,
502                 ..
503             } => {
504                 let Some(col) = self.header_col_at(*px, *py, ectx.rect) else {
505                     return false;
506                 };
507                 self.sort = match self.sort {
508                     Some((c, true)) if c == col => Some((col, false)),
509                     Some((c, false)) if c == col => None,
510                     _ => Some((col, true)),
511                 };
512                 self.apply_sort();
513                 true
514             }
515             // Hit-gated by the adapter (which also rejects hidden widgets).
516             Event::MouseWheel { delta, .. } => {
517                 // Delta signs follow the ScrollRegion/TextBox convention (negate the
518                 // event delta); natural scroll is already applied upstream by libinput.
519                 let (dx, dy) = ScrollMotion::delta_px(delta, (ROW_H, ROW_H));
520                 let by = self.geom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
521                 let bx = self.hgeom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
522                 // Claimed whenever the pointed axis can scroll at all (an
523                 // overflowing table swallows its wheel), moved or not.
524                 let used = (dy != 0.0 && by.hi > 0.0) || (dx != 0.0 && bx.hi > 0.0);
525                 self.motion.reconcile(self.scroll_x, self.scroll_y);
526                 let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
527                 self.motion.apply_px(dx, dy, discrete, bx, by);
528                 self.scroll_x = self.motion.x.pos();
529                 self.scroll_y = self.motion.y.pos();
530                 used
531             }
532             Event::KeyInput(key_event) => {
533                 if key_event.state != ElementState::Pressed {
534                     return false;
535                 }
536                 let Some(g) = self.geom(ectx.rect) else {
537                     return false;
538                 };
539                 // Steps glide, and a held key accumulates from the glide's
540                 // target rather than the offset drawn this frame.
541                 self.motion.reconcile(self.scroll_x, self.scroll_y);
542                 let by = Bounds::max(g.max_scroll);
543                 let old = by.clamp(self.motion.y.target());
544                 let new = match &key_event.logical_key {
545                     Key::Named(NamedKey::ArrowDown) => old + ROW_H,
546                     Key::Named(NamedKey::ArrowUp) => old - ROW_H,
547                     Key::Named(NamedKey::PageDown) => old + g.visible_h,
548                     Key::Named(NamedKey::PageUp) => old - g.visible_h,
549                     Key::Named(NamedKey::Home) => 0.0,
550                     Key::Named(NamedKey::End) => g.max_scroll,
551                     _ => return false,
552                 };
553                 let moved = self.motion.y.scroll_to(new, by, &scroll_settings());
554                 self.scroll_y = self.motion.y.pos();
555                 moved
556             }
557             _ => false,
558         }
559     }
560 
561     fn scrollable(&self) -> bool {
562         true
563     }
564 
565     // --- Scrollbar drag, host-driven (the designer checks `draggable()` on the pressed widget
566     // and then streams `drag_update` at it). `drag_begin` decides whether the press actually
567     // landed on the scrollbar; a body press starts no drag, exactly like legacy.
568 
569     fn draggable(&self, rect: Rect) -> bool {
570         self.geom(rect).is_some() || self.hgeom(rect).is_some()
571     }
572 
573     fn is_dragging(&self) -> bool {
574         self.dragging_scrollbar || self.dragging_hscrollbar
575     }
576 
577     fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
578         // A grab or release cancels any glide/coast in flight.
579         self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
580         // The vertical bar owns the shared bottom-right corner (it was here
581         // first); the horizontal bar takes what's left of the bottom band.
582         if let Some(g) = self.geom(rect) {
583             if px >= g.scrollbar_x - 4.0
584                 && px <= rect.x + rect.width
585                 && py >= g.track_y
586                 && py <= rect.y + rect.height
587             {
588                 self.dragging_scrollbar = true;
589                 if py >= g.thumb_y && py <= g.thumb_y + g.thumb_h {
590                     self.drag_offset_y = py - g.thumb_y;
591                 } else {
592                     // Track click: jump the thumb's center to the pointer.
593                     self.drag_offset_y = g.thumb_h / 2.0;
594                     self.scroll_to_thumb(&g, py - self.drag_offset_y);
595                 }
596                 return;
597             }
598         }
599         if let Some(g) = self.hgeom(rect) {
600             if py >= g.track_y - 4.0
601                 && py <= rect.y + rect.height
602                 && px >= g.track_x
603                 && px <= g.track_x + g.visible_w
604             {
605                 self.dragging_hscrollbar = true;
606                 if px >= g.thumb_x && px <= g.thumb_x + g.thumb_w {
607                     self.drag_offset_x = px - g.thumb_x;
608                 } else {
609                     self.drag_offset_x = g.thumb_w / 2.0;
610                     self.hscroll_to_thumb(&g, px - self.drag_offset_x);
611                 }
612             }
613         }
614     }
615 
616     fn drag_update(&mut self, px: f32, py: f32, rect: Rect) -> bool {
617         if self.dragging_scrollbar {
618             self.motion.y.jump_to(self.scroll_y);
619             if let Some(g) = self.geom(rect) {
620                 let old = g.scroll;
621                 self.scroll_to_thumb(&g, py - self.drag_offset_y);
622                 return (self.scroll_y - old).abs() > 0.01;
623             }
624             return false;
625         }
626         if self.dragging_hscrollbar {
627             self.motion.x.jump_to(self.scroll_x);
628             if let Some(g) = self.hgeom(rect) {
629                 let old = g.scroll;
630                 self.hscroll_to_thumb(&g, px - self.drag_offset_x);
631                 return (self.scroll_x - old).abs() > 0.01;
632             }
633         }
634         false
635     }
636 
637     fn drag_end(&mut self) {
638         self.dragging_scrollbar = false;
639         self.dragging_hscrollbar = false;
640         // A grab or release cancels any glide/coast in flight.
641         self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
642     }
643 
644     // --- Smooth scroll: the wheel/finger feed the shared motion; each frame advances it.
645 
646     fn tick(&mut self, dt: f32, rect: Rect) -> bool {
647         self.motion.reconcile(self.scroll_x, self.scroll_y);
648         if !self.motion.is_animating() {
649             return false;
650         }
651         let by = self.geom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
652         let bx = self.hgeom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
653         let moved = self.motion.tick(dt, bx, by);
654         self.scroll_x = self.motion.x.pos();
655         self.scroll_y = self.motion.y.pos();
656         moved || self.motion.is_animating()
657     }
658 
659     fn wants_tick(&self) -> bool {
660         true
661     }
662 
663 }
664 
665 impl SpreadsheetController for Spreadsheet {
666     fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
667         self.headers = headers;
668         self.rows = rows;
669         // Re-derive the display order so an active sort survives a data refresh
670         // (the designer re-sets the whole table on selection/param changes).
671         self.apply_sort();
672         // The raw scroll may now exceed the new content; every consumer clamps through
673         // `geom()`, and the next scroll write re-clamps it for real.
674     }
675 }
676 
677 #[cfg(test)]
678 mod tests {
679     use super::*;
680     use crate::context::UiContext;
681     use crate::widget::WidgetHost;
682 
683     fn filled(rows: usize) -> Adapted<Spreadsheet> {
684         let mut s = Spreadsheet::new();
685         s.set_visible(true);
686         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0); // viewport: 100 = ~4 rows of 24
687         let data: Vec<Vec<String>> =
688             (0..rows).map(|i| vec![format!("r{i}"), format!("v{i}")]).collect();
689         SpreadsheetController::set_spreadsheet_data(&mut *s, vec!["a".into(), "b".into()], data);
690         s
691     }
692 
693     fn wide(cols: usize) -> Adapted<Spreadsheet> {
694         let mut s = Spreadsheet::new();
695         s.set_visible(true);
696         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
697         let headers: Vec<String> = (0..cols).map(|i| format!("c{i}")).collect();
698         let rows = vec![(0..cols).map(|i| format!("v{i}")).collect::<Vec<String>>(); 2];
699         SpreadsheetController::set_spreadsheet_data(&mut *s, headers, rows);
700         s
701     }
702 
703     /// Columns floor at MIN_COL_W instead of squeezing: past the floor the run
704     /// overflows into the horizontal scroll, and within it there is none.
705     #[test]
706     fn columns_floor_at_min_width_and_overflow_scrolls() {
707         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
708         let s = wide(6);
709         assert_eq!((*s).col_w(rect), MIN_COL_W);
710         let g = (*s).hgeom(rect).expect("6 floored columns overflow a 200px pane");
711         assert!((g.max_scroll - (6.0 * MIN_COL_W - 200.0)).abs() < 0.01);
712 
713         let fits = wide(2);
714         assert!((*fits).hgeom(rect).is_none(), "2 columns share the pane, no h-scroll");
715         assert_eq!((*fits).col_w(rect), 100.0, "fitting columns still split the width evenly");
716     }
717 
718     /// The sort hit-test must look up columns through the scrolled origin, or
719     /// clicking a header would sort the column that USED to be under the pointer.
720     #[test]
721     fn header_hit_test_tracks_horizontal_scroll() {
722         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
723         let mut s = wide(6);
724         assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(0));
725         (*s).scroll_x = MIN_COL_W;
726         assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(1));
727     }
728 
729     /// A horizontal wheel feeds hscroll velocity, tick integrates and decays it,
730     /// and the scroll clamps inside the overflow.
731     #[test]
732     fn horizontal_wheel_integrates_and_decays_through_tick() {
733         let mut ctx = UiContext::new();
734         let mut s = wide(6);
735         let (id, ptr) = (s.id(), s.as_ptr_mut());
736         ctx.register_widget(id, ptr);
737 
738         let wheel = Event::MouseWheel {
739             delta: MouseScrollDelta::LineDelta(-2.0, 0.0),
740             x: 50.0,
741             y: 60.0,
742             local_x: 50.0,
743             local_y: 60.0,
744         };
745         assert!(s.handle_event(&wheel, &mut ctx), "in-rect horizontal wheel consumed");
746         assert!(WidgetHost::tick(&mut s, 0.016, &mut ctx), "first tick moves the h-scroll");
747         let mut guard = 0;
748         while WidgetHost::tick(&mut s, 0.016, &mut ctx) {
749             guard += 1;
750             assert!(guard < 1000, "h-inertia must decay to a stop");
751         }
752         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
753         let max = (*s).hgeom(rect).unwrap().max_scroll;
754         assert!((*s).scroll_x >= 0.0 && (*s).scroll_x <= max, "h-scroll stays clamped");
755         assert!((*s).scroll_x > 0.0, "negative dx scrolled the columns (ScrollRegion sign convention)");
756 
757         // A pane whose columns fit ignores horizontal wheels.
758         let mut fits = wide(2);
759         let (fid, fptr) = (fits.id(), fits.as_ptr_mut());
760         ctx.register_widget(fid, fptr);
761         assert!(!fits.handle_event(&wheel, &mut ctx), "no overflow, wheel passes through");
762     }
763 
764     #[test]
765     fn wheel_velocity_integrates_and_decays_through_tick() {
766         let mut ctx = UiContext::new();
767         let mut s = filled(50);
768         let (id, ptr) = (s.id(), s.as_ptr_mut());
769         ctx.register_widget(id, ptr);
770 
771         // A wheel over the body feeds velocity (negated delta, the ScrollRegion
772         // convention: a negative line delta scrolls the view down)…
773         let wheel = Event::MouseWheel {
774             delta: MouseScrollDelta::LineDelta(0.0, -2.0),
775             x: 50.0,
776             y: 60.0,
777             local_x: 50.0,
778             local_y: 60.0,
779         };
780         assert!(s.handle_event(&wheel, &mut ctx), "in-rect wheel consumed");
781 
782         // …which tick integrates into scroll movement and decays to a stop.
783         assert!(WidgetHost::tick(&mut s, 0.016, &mut ctx), "first tick moves the scroll");
784         let mut guard = 0;
785         while WidgetHost::tick(&mut s, 0.016, &mut ctx) {
786             guard += 1;
787             assert!(guard < 1000, "inertia must decay to a stop");
788         }
789 
790         // Hidden spreadsheets are not hittable, so the wheel passes through.
791         s.set_visible(false);
792         assert!(!s.handle_event(&wheel, &mut ctx), "hidden widget ignores wheel");
793     }
794 
795     #[test]
796     fn scrollbar_drag_and_keys_move_the_scroll() {
797         let mut ctx = UiContext::new();
798         let mut s = filled(50);
799         let (id, ptr) = (s.id(), s.as_ptr_mut());
800         ctx.register_widget(id, ptr);
801         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
802 
803         // content 1200, viewport 100 -> overflowing, so the host may drag it.
804         assert!(s.draggable());
805 
806         // Press on the scrollbar track (x >= 200-6-2-4): thumb jumps, drag engages.
807         s.drag_begin(195.0, 80.0);
808         assert!(s.is_dragging());
809         assert!(s.drag_update(195.0, 110.0), "thumb drag scrolls");
810         let dragged_to = s.inner().geom(rect).unwrap().scroll;
811         assert!(dragged_to > 0.0);
812         s.drag_end();
813         assert!(!s.is_dragging());
814 
815         // A body press (left of the scrollbar) engages no drag.
816         s.drag_begin(50.0, 60.0);
817         assert!(!s.is_dragging(), "body press is not a scrollbar drag");
818 
819         // End key jumps to max; Home returns to zero. (Keys route via keyboard_input.)
820         let end = crate::widget::KeyEvent {
821             state: ElementState::Pressed,
822             logical_key: Key::Named(NamedKey::End),
823             text: None,
824             repeat: false,
825             ctrl: false,
826             shift: false,
827             alt: false,
828         };
829         assert!(s.keyboard_input(&end, &mut ctx));
830         // The key glides: run the motion out before reading the offset.
831         for _ in 0..1000 {
832             if !Input::tick(&mut *s.inner_mut(), 1.0 / 60.0, rect) {
833                 break;
834             }
835         }
836         let g = s.inner().geom(rect).unwrap();
837         assert_eq!(g.scroll, g.max_scroll);
838 
839         // Hidden: the focused-widget keyboard path must not consume keys.
840         s.set_visible(false);
841         assert!(!s.keyboard_input(&end, &mut ctx), "hidden widget ignores keys");
842     }
843 
844     fn header_click(x: f32) -> Event {
845         Event::MouseButton {
846             button: MouseButton::Left,
847             state: ElementState::Pressed,
848             x,
849             y: 10.0,
850             local_x: x,
851             local_y: 10.0,
852         }
853     }
854 
855     #[test]
856     fn header_click_cycles_ascending_descending_natural() {
857         let mut ctx = UiContext::new();
858         let mut s = Spreadsheet::new();
859         s.set_visible(true);
860         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
861         let (id, ptr) = (s.id(), s.as_ptr_mut());
862         ctx.register_widget(id, ptr);
863         // Numeric strings out of lexicographic order: "10" must sort after "9".
864         let rows = vec![
865             vec!["10".to_string(), "b".to_string()],
866             vec!["9".to_string(), "c".to_string()],
867             vec!["2".to_string(), "a".to_string()],
868         ];
869         SpreadsheetController::set_spreadsheet_data(&mut *s, vec!["n".into(), "s".into()], rows);
870         assert_eq!(s.inner().order, vec![0, 1, 2], "unsorted = natural order");
871 
872         // Column 0 spans x 0..100. Click 1: ascending, numeric.
873         assert!(s.handle_event(&header_click(50.0), &mut ctx));
874         assert_eq!(s.inner().sort, Some((0, true)));
875         assert_eq!(s.inner().order, vec![2, 1, 0], "2 < 9 < 10 numerically");
876 
877         // Click 2: descending.
878         assert!(s.handle_event(&header_click(50.0), &mut ctx));
879         assert_eq!(s.inner().sort, Some((0, false)));
880         assert_eq!(s.inner().order, vec![0, 1, 2]);
881 
882         // Click 3: back to natural order.
883         assert!(s.handle_event(&header_click(50.0), &mut ctx));
884         assert_eq!(s.inner().sort, None);
885         assert_eq!(s.inner().order, vec![0, 1, 2]);
886 
887         // Column 1 (lexicographic), then a body click changes nothing.
888         assert!(s.handle_event(&header_click(150.0), &mut ctx));
889         assert_eq!(s.inner().order, vec![2, 0, 1], "a < b < c");
890         let body = Event::MouseButton {
891             button: MouseButton::Left,
892             state: ElementState::Pressed,
893             x: 50.0,
894             y: 60.0,
895             local_x: 50.0,
896             local_y: 60.0,
897         };
898         assert!(!s.handle_event(&body, &mut ctx), "body press is not a sort");
899         assert_eq!(s.inner().sort, Some((1, true)));
900     }
901 
902     #[test]
903     fn data_refresh_reapplies_sort_and_column_shrink_clears_it() {
904         let mut ctx = UiContext::new();
905         let mut s = Spreadsheet::new();
906         s.set_visible(true);
907         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
908         let (id, ptr) = (s.id(), s.as_ptr_mut());
909         ctx.register_widget(id, ptr);
910         SpreadsheetController::set_spreadsheet_data(
911             &mut *s,
912             vec!["a".into(), "b".into()],
913             vec![vec!["1".into(), "x".into()], vec!["2".into(), "y".into()]],
914         );
915         assert!(s.handle_event(&header_click(150.0), &mut ctx)); // sort col 1 asc
916 
917         // A refresh with new rows keeps the sort and re-derives the order.
918         SpreadsheetController::set_spreadsheet_data(
919             &mut *s,
920             vec!["a".into(), "b".into()],
921             vec![vec!["1".into(), "z".into()], vec!["2".into(), "w".into()]],
922         );
923         assert_eq!(s.inner().sort, Some((1, true)));
924         assert_eq!(s.inner().order, vec![1, 0], "w < z");
925 
926         // A refresh that drops the sorted column clears the sort.
927         SpreadsheetController::set_spreadsheet_data(
928             &mut *s,
929             vec!["a".into()],
930             vec![vec!["1".into()], vec!["2".into()]],
931         );
932         assert_eq!(s.inner().sort, None);
933         assert_eq!(s.inner().order, vec![0, 1]);
934     }
935 }