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 }