GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/container/spreadsheet.rs (75.9K)
1 //! Narrow-trait `Spreadsheet` (Phase 5l) — a read-only table with a header row, zebra rows,
2 //! selectable rows (a press selects one, ctrl toggles one, shift extends from the last
3 //! pressed — see [`Spreadsheet::press_row`]),
4 //! column dividers, and an inertially-scrolled body: wheel input feeds a velocity that
5 //! [`Input::tick`] integrates and decays each frame, the scrollbar thumb is host-drag-driven
6 //! through the drag surface, and arrow/page/home/end keys jump the scroll. The scroll geometry
7 //! (content/viewport heights, thumb position) is derived in one place ([`ScrollGeom`]) — legacy
8 //! re-derived it in seven. [`SpreadsheetController`] rides the `Input` capability hooks.
9 //!
10 //! **The two scrollbars cross at the body's centre** (since 2026-10-06): the vertical bar
11 //! rides the pane's vertical centre line and the horizontal one the body's horizontal centre
12 //! line, as the params pane's and the designer dialog's bars ride theirs — over the cells,
13 //! reserving no lane. They idle BEHIND the pane's plate ([`ScrollbarActivity`]): a scroll,
14 //! a key or a thumb drag raises them in front, a pointer over a raised bar keeps it there,
15 //! and once nothing holds them for the hold window they sink again. Sunk they take no input,
16 //! so a press on their lane reaches the row beneath. The fore copy is painted by
17 //! [`Paint::paint`] at the activity's fade; the copy behind the plate is the HOST's to
18 //! draw, before the plate, through [`Spreadsheet::paint_scrollbars`] — the plate is the
19 //! host's too.
20 //!
21 //! **The table is COLUMNS of values, formatted as they are painted** (since 2026-10-07,
22 //! [`SheetColumn`], [`SpreadsheetController::set_spreadsheet_columns`]). A host that
23 //! refills the table every frame — the designer's, during a simulation's playback — used
24 //! to format every cell of every row into a `String` for a pane that shows thirty rows:
25 //! at ten thousand points that was most of what the table cost a frame. A column of
26 //! numbers is now the numbers, the cells on screen are written when painted, and a sort
27 //! compares the numbers rather than parsing their text. `set_spreadsheet_data` (rows of
28 //! strings) still works: its cells become text columns.
29 //!
30 //! The `PARAM_BG` background is NOT emitted here: the designer's render path draws every
31 //! widget's background itself from `color()` + `corner_style()` (`append_widget_plate`), and
32 //! `PARAM_BG` is translucent — emitting it again would double-blend. This widget's own
33 //! geometry starts at the header strip, exactly like the legacy `extra_quads`.
34
35 use crate::colors;
36 use crate::scene::layout::Rect;
37 use crate::scene::paint::PaintCtx;
38 use crate::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion};
39 use crate::widget::{
40 Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, MouseScrollDelta,
41 NamedKey, Paint, ScrollbarActivity, SpreadsheetController,
42 };
43
44 /// One column of a [`Spreadsheet`], as its values: the widget writes the
45 /// cells it paints, so a column of a hundred thousand numbers costs a copy
46 /// of them, not a hundred thousand strings.
47 #[derive(Debug, Clone, PartialEq)]
48 pub enum SheetColumn {
49 /// Cells as given. Sorted numerically where both cells parse as numbers,
50 /// as text otherwise.
51 Text(Vec<String>),
52 /// Whole numbers.
53 Int(Vec<i64>),
54 /// Numbers written to `decimals` places (`format!("{:.*}")`).
55 Float { values: Vec<f32>, decimals: usize },
56 }
57
58 impl SheetColumn {
59 /// How many cells the column has.
60 pub fn len(&self) -> usize {
61 match self {
62 SheetColumn::Text(v) => v.len(),
63 SheetColumn::Int(v) => v.len(),
64 SheetColumn::Float { values, .. } => values.len(),
65 }
66 }
67
68 pub fn is_empty(&self) -> bool {
69 self.len() == 0
70 }
71
72 /// Row `row`'s cell as it is shown; empty past the column's end.
73 pub fn cell(&self, row: usize) -> String {
74 match self {
75 SheetColumn::Text(v) => v.get(row).cloned().unwrap_or_default(),
76 SheetColumn::Int(v) => v.get(row).map(|n| n.to_string()).unwrap_or_default(),
77 SheetColumn::Float { values, decimals } => {
78 values.get(row).map(|x| format!("{:.*}", decimals, x)).unwrap_or_default()
79 }
80 }
81 }
82
83 /// The widest cell, in characters, as [`Self::cell`] writes it — worked
84 /// out from the values without writing them: a whole number's widest is
85 /// its least or its greatest, a float's too (the sign and the integer
86 /// digits are what vary; the decimals are fixed), plus `NaN` / `inf` /
87 /// `-inf` where those occur. 0 for an empty column.
88 pub fn max_chars(&self) -> usize {
89 match self {
90 SheetColumn::Text(v) => v.iter().map(|c| c.chars().count()).max().unwrap_or(0),
91 SheetColumn::Int(v) => {
92 let Some(&first) = v.first() else { return 0 };
93 let (lo, hi) = v.iter().fold((first, first), |(lo, hi), &x| (lo.min(x), hi.max(x)));
94 lo.to_string().len().max(hi.to_string().len())
95 }
96 SheetColumn::Float { values, decimals } => {
97 // Plain comparisons, which the compiler vectorizes, where
98 // `f32::min` / `max` carry NaN handling a finite value does
99 // not need: at a million values a refill (the designer's
100 // table at 57k points, every frame of a playback) this pass
101 // was 1.35 ms (until 2026-10-07). A tie between 0 and -0
102 // takes -0, whose `-` is a character wider.
103 // No branch in the loop: a value that is not finite stands
104 // out of the range it does not belong to, and what is
105 // special is OR'd up and settled after.
106 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
107 let (mut neg_zero, mut nan_or_inf, mut neg_inf) = (false, false, false);
108 for &x in values {
109 let finite = x.is_finite();
110 let a = if finite { x } else { f32::INFINITY };
111 let b = if finite { x } else { f32::NEG_INFINITY };
112 lo = if a < lo { a } else { lo };
113 hi = if b > hi { b } else { hi };
114 neg_zero |= x == 0.0 && x.is_sign_negative();
115 nan_or_inf |= !finite && x != f32::NEG_INFINITY;
116 neg_inf |= x == f32::NEG_INFINITY;
117 }
118 if lo == 0.0 && neg_zero {
119 lo = -0.0;
120 }
121 let odd = if neg_inf { 4 } else if nan_or_inf { 3 } else { 0 };
122 let w = |x: f32| format!("{:.*}", decimals, x).len();
123 let finite = if lo <= hi { w(lo).max(w(hi)) } else { 0 };
124 finite.max(odd)
125 }
126 }
127 }
128
129 /// The order of rows `a` and `b` by this column: by value for numbers
130 /// (a NaN is equal to anything, so it keeps its place), by
131 /// `Spreadsheet::cmp_cells` for text.
132 fn cmp_rows(&self, a: usize, b: usize) -> std::cmp::Ordering {
133 use std::cmp::Ordering::Equal;
134 match self {
135 SheetColumn::Text(v) => Spreadsheet::cmp_cells(
136 v.get(a).map(String::as_str).unwrap_or(""),
137 v.get(b).map(String::as_str).unwrap_or(""),
138 ),
139 SheetColumn::Int(v) => v.get(a).cmp(&v.get(b)),
140 SheetColumn::Float { values, .. } => match (values.get(a), values.get(b)) {
141 (Some(x), Some(y)) => x.partial_cmp(y).unwrap_or(Equal),
142 (x, y) => x.is_some().cmp(&y.is_some()),
143 },
144 }
145 }
146 }
147
148 const HEADER_H: f32 = 24.0;
149 const ROW_H: f32 = 24.0;
150 /// How far a track stops short of each end of what it spans, as every toolkit bar's does.
151 const TRACK_INSET: f32 = 4.0;
152 /// How far either side of a bar a press or the pointer still counts as on it.
153 const BAR_SLOP: f32 = 4.0;
154 /// A column's width beyond its text: the 8 px a label stands in from the
155 /// column's left edge, and the 4 px gutter it is clamped short of the divider
156 /// by, with room to spare.
157 const CELL_PAD: f32 = 16.0;
158 /// Room after a header's name for the sort glyph, in characters — kept
159 /// whether or not the column is sorted, so sorting moves no column.
160 const SORT_MARK_CHARS: usize = 2;
161 /// The narrowest a column is, in characters, so a header is a target.
162 const MIN_COL_CHARS: usize = 3;
163
164 pub struct Spreadsheet {
165 hovered: bool,
166 headers: Vec<String>,
167 /// The table, a column a header ([`SheetColumn`]); `row_count` rows.
168 columns: Vec<SheetColumn>,
169 row_count: usize,
170 /// Display order into the rows — the identity permutation unless `sort` is set.
171 /// Rebuilt by `apply_sort` at both mutation sites (header click, data refresh),
172 /// so `order.len() == row_count` always holds.
173 order: Vec<usize>,
174 /// Active sort: `(column, ascending)`. A header click cycles
175 /// ascending → descending → natural order (matching the source data).
176 sort: Option<(usize, bool)>,
177 /// Header cell the pointer is over — hover tint, like the Processes page's
178 /// sortable headers.
179 header_hover_col: Option<usize>,
180 /// Each column's width in characters: its header with room for the
181 /// sort glyph, or its widest cell, whichever is wider (`set_columns`).
182 col_chars: Vec<usize>,
183 scroll_y: f32,
184 dragging_scrollbar: bool,
185 drag_offset_y: f32,
186 scroll_x: f32,
187 /// Smooth-scroll driver behind `scroll_y`/`scroll_x`: wheel notches
188 /// glide, trackpad flicks coast (the widget's former velocity model,
189 /// now the toolkit-wide one).
190 motion: ScrollMotion,
191 dragging_hscrollbar: bool,
192 drag_offset_x: f32,
193 /// Whether the two bars are raised in front of the plate or sunk behind
194 /// it — one latch for both, since they are one cross.
195 activity: ScrollbarActivity,
196 /// The selected rows, as indices into `rows` — the SOURCE order, so a
197 /// sort moves a selected row's place in the pane and not what it names.
198 selected: std::collections::BTreeSet<usize>,
199 /// The row a shift press extends from: the last one pressed without it.
200 anchor: Option<usize>,
201 /// Set by whatever changed `selected`, taken by the host.
202 selection_changed: bool,
203 /// The modifiers the host pushed in ahead of the press.
204 ctrl: bool,
205 shift: bool,
206 }
207
208 /// Scroll/scrollbar geometry for one (row count, rect) pair. Present only when the content
209 /// overflows the viewport — every scroll behavior is gated on that, as in legacy.
210 struct ScrollGeom {
211 visible_h: f32,
212 max_scroll: f32,
213 /// `scroll_y` clamped to the current bounds (data changes can leave the raw value stale).
214 scroll: f32,
215 /// The bar's left edge: it is centred on the pane's width.
216 bar_x: f32,
217 bar_w: f32,
218 track_y: f32,
219 track_h: f32,
220 thumb_h: f32,
221 thumb_y: f32,
222 track_range: f32,
223 }
224
225 /// [`ScrollGeom`]'s horizontal twin, for the bottom scrollbar. Present only
226 /// when the column run overflows the pane width.
227 struct HScrollGeom {
228 max_scroll: f32,
229 /// `scroll_x` clamped to the current bounds.
230 scroll: f32,
231 track_x: f32,
232 track_w: f32,
233 /// The bar's top edge: it is centred on the body's height.
234 bar_y: f32,
235 bar_h: f32,
236 thumb_w: f32,
237 thumb_x: f32,
238 track_range: f32,
239 }
240
241 impl Spreadsheet {
242 pub fn new() -> Adapted<Spreadsheet> {
243 let mut s = Adapted::new(Spreadsheet {
244 hovered: false,
245 headers: Vec::new(),
246 columns: Vec::new(),
247 row_count: 0,
248 order: Vec::new(),
249 sort: None,
250 header_hover_col: None,
251 col_chars: Vec::new(),
252 scroll_y: 0.0,
253 dragging_scrollbar: false,
254 drag_offset_y: 0.0,
255 scroll_x: 0.0,
256 motion: ScrollMotion::new(),
257 dragging_hscrollbar: false,
258 drag_offset_x: 0.0,
259 activity: ScrollbarActivity::new(),
260 selected: std::collections::BTreeSet::new(),
261 anchor: None,
262 selection_changed: false,
263 ctrl: false,
264 shift: false,
265 });
266 // The spreadsheet pane starts hidden (the designer toggles it in later).
267 crate::widget::WidgetHost::set_visible(&mut s, false);
268 s
269 }
270
271 fn geom(&self, rect: Rect) -> Option<ScrollGeom> {
272 let content_h = self.row_count as f32 * ROW_H;
273 let visible_h = (rect.height - HEADER_H).max(0.0);
274 if visible_h <= 0.0 || content_h <= visible_h {
275 return None;
276 }
277 let max_scroll = content_h - visible_h;
278 let scroll = self.scroll_y.clamp(0.0, max_scroll);
279 let bar_w = Self::bar_w();
280 let track_y = rect.y + HEADER_H + TRACK_INSET;
281 let track_h = (visible_h - 2.0 * TRACK_INSET).max(0.0);
282 let thumb_h = Self::thumb_len(track_h, visible_h / content_h);
283 let track_range = track_h - thumb_h;
284 Some(ScrollGeom {
285 visible_h,
286 max_scroll,
287 scroll,
288 bar_x: rect.x + (rect.width - bar_w) * 0.5,
289 bar_w,
290 track_y,
291 track_h,
292 thumb_h,
293 thumb_y: track_y + (scroll / max_scroll) * track_range,
294 track_range,
295 })
296 }
297
298 /// One character's width in the label font, which is monospace.
299 fn char_w() -> f32 {
300 let fam = crate::layout::control_label_font();
301 (crate::widget::display::measure_text_width("0123456789", &fam, 12.0) / 10.0).max(1.0)
302 }
303
304 /// The columns' edges from the table's left, `n + 1` of them: each
305 /// column is as wide as its content (`col_chars`) — its header and sort
306 /// glyph, or its widest cell — and no wider. The table does not stretch
307 /// to the pane: what is left of a wide pane is left empty, and a table
308 /// wider than the pane scrolls. (Until 2026-10-07 every column was an
309 /// even share of the pane, floored at 76 px, so a point index or a
310 /// group's 0 and 1 took as much room as a four-decimal float.)
311 fn col_edges(&self) -> Vec<f32> {
312 let cw = Self::char_w();
313 let mut edges = Vec::with_capacity(self.headers.len() + 1);
314 let mut x = 0.0;
315 edges.push(x);
316 for i in 0..self.headers.len() {
317 let chars = self.col_chars.get(i).copied().unwrap_or(0).max(MIN_COL_CHARS);
318 x += chars as f32 * cw + CELL_PAD;
319 edges.push(x);
320 }
321 edges
322 }
323
324 /// How wide the table is: its columns, each as wide as its content. A
325 /// host sizes the pane by it — the designer's plate is as wide as the
326 /// table, up to the room it has (since 2026-10-07).
327 pub fn content_width(&self) -> f32 {
328 self.col_edges().last().copied().unwrap_or(0.0)
329 }
330
331 /// Horizontal counterpart of [`geom`]: present only when the column run is
332 /// wider than the pane.
333 fn hgeom(&self, rect: Rect) -> Option<HScrollGeom> {
334 let content_w = self.col_edges().last().copied().unwrap_or(0.0);
335 let visible_w = rect.width;
336 if visible_w <= 0.0 || content_w <= visible_w {
337 return None;
338 }
339 let max_scroll = content_w - visible_w;
340 let scroll = self.scroll_x.clamp(0.0, max_scroll);
341 let bar_h = Self::bar_w();
342 let track_x = rect.x + TRACK_INSET;
343 let track_w = (visible_w - 2.0 * TRACK_INSET).max(0.0);
344 let thumb_w = Self::thumb_len(track_w, visible_w / content_w);
345 let track_range = track_w - thumb_w;
346 let body_h = (rect.height - HEADER_H).max(0.0);
347 Some(HScrollGeom {
348 max_scroll,
349 scroll,
350 track_x,
351 track_w,
352 bar_y: rect.y + HEADER_H + (body_h - bar_h) * 0.5,
353 bar_h,
354 thumb_w,
355 thumb_x: track_x + (scroll / max_scroll) * track_range,
356 track_range,
357 })
358 }
359
360 /// A bar's thickness — the DE's centred width, as the params pane's bar
361 /// is, since it sits over cells rather than in a lane.
362 fn bar_w() -> f32 {
363 crate::layout::centred_scrollbar_width()
364 }
365
366 /// A thumb's length on a track `track` long showing `ratio` of the content.
367 fn thumb_len(track: f32, ratio: f32) -> f32 {
368 if track <= 20.0 {
369 track
370 } else {
371 (track * ratio).clamp(20.0, track)
372 }
373 }
374
375 /// Whether `(px, py)` is on the vertical bar's strip (with slop).
376 fn over_vbar(&self, px: f32, py: f32, rect: Rect) -> bool {
377 self.geom(rect).is_some_and(|g| {
378 px >= g.bar_x - BAR_SLOP
379 && px <= g.bar_x + g.bar_w + BAR_SLOP
380 && py >= g.track_y
381 && py <= g.track_y + g.track_h
382 })
383 }
384
385 /// Whether `(px, py)` is on the horizontal bar's strip (with slop).
386 fn over_hbar(&self, px: f32, py: f32, rect: Rect) -> bool {
387 self.hgeom(rect).is_some_and(|g| {
388 py >= g.bar_y - BAR_SLOP
389 && py <= g.bar_y + g.bar_h + BAR_SLOP
390 && px >= g.track_x
391 && px <= g.track_x + g.track_w
392 })
393 }
394
395 /// Whether the pane has either bar at all.
396 pub fn scrollbars_shown(&self, rect: Rect) -> bool {
397 self.geom(rect).is_some() || self.hgeom(rect).is_some()
398 }
399
400 /// Whether the bars are raised in front of the plate — the latch, which
401 /// gates input. Paint the fore copy with [`Self::scrollbar_fade`].
402 pub fn scrollbars_raised(&self) -> bool {
403 self.activity.raised()
404 }
405
406 /// How far the fore copy has faded in, 0..=1.
407 pub fn scrollbar_fade(&self) -> f32 {
408 self.activity.fade()
409 }
410
411 /// The cross of pill scrollbars on `rect`, scaled to `alpha`: both
412 /// tracks, then both thumbs, so neither track covers the other's thumb
413 /// where they cross. [`Paint::paint`] draws the fore copy; the host draws
414 /// the copy that idles behind its plate, at full alpha, BEFORE the plate.
415 pub fn paint_scrollbars(&self, rect: Rect, ctx: &mut PaintCtx, alpha: f32) {
416 let a = alpha.clamp(0.0, 1.0);
417 if a <= 0.001 {
418 return;
419 }
420 let dim = |mut c: [f32; 4]| {
421 c[3] *= a;
422 c
423 };
424 let all = (true, true, true, true);
425 let track = dim(crate::color::scrollbar_track_color());
426 let thumb = dim(crate::color::scrollbar_thumb_color());
427 let pill = |ctx: &mut PaintCtx, r: Rect, c: [f32; 4]| {
428 if r.width > 0.0 && r.height > 0.0 {
429 ctx.rounded_rect(r, r.width.min(r.height) * 0.5, all, c);
430 }
431 };
432 let v = self.geom(rect);
433 let h = self.hgeom(rect);
434 if let Some(g) = &v {
435 pill(ctx, Rect { x: g.bar_x, y: g.track_y, width: g.bar_w, height: g.track_h }, track);
436 }
437 if let Some(g) = &h {
438 pill(ctx, Rect { x: g.track_x, y: g.bar_y, width: g.track_w, height: g.bar_h }, track);
439 }
440 if let Some(g) = &v {
441 pill(ctx, Rect { x: g.bar_x, y: g.thumb_y, width: g.bar_w, height: g.thumb_h }, thumb);
442 }
443 if let Some(g) = &h {
444 pill(ctx, Rect { x: g.thumb_x, y: g.bar_y, width: g.thumb_w, height: g.bar_h }, thumb);
445 }
446 }
447
448 /// Re-latch the raise/sink, returning whether it flipped.
449 fn recompute_bars(&mut self, rect: Rect) -> bool {
450 let shown = self.scrollbars_shown(rect);
451 let dragging = self.dragging_scrollbar || self.dragging_hscrollbar;
452 self.activity.recompute(shown, dragging)
453 }
454
455 /// The clamped horizontal scroll — 0 while everything fits.
456 fn hscroll(&self, rect: Rect) -> f32 {
457 self.hgeom(rect).map_or(0.0, |g| g.scroll)
458 }
459
460 /// The header column under `(px, py)`, if the point is inside the header band.
461 fn header_col_at(&self, px: f32, py: f32, rect: Rect) -> Option<usize> {
462 if self.headers.is_empty() || rect.width <= 0.0 {
463 return None;
464 }
465 if px < rect.x || px > rect.x + rect.width || py < rect.y || py >= rect.y + HEADER_H {
466 return None;
467 }
468 let at = px - rect.x + self.hscroll(rect);
469 let edges = self.col_edges();
470 (0..self.headers.len()).find(|&c| at >= edges[c] && at < edges[c + 1])
471 }
472
473 /// Scroll to where a thumb dragged to `thumb_x` puts the columns.
474 fn hscroll_to_thumb(&mut self, g: &HScrollGeom, thumb_x: f32) {
475 let ratio = if g.track_range > 0.0 {
476 ((thumb_x - g.track_x) / g.track_range).clamp(0.0, 1.0)
477 } else {
478 0.0
479 };
480 self.scroll_x = ratio * g.max_scroll;
481 }
482
483 /// Cell comparison: numeric when both cells parse (so "10" sorts after "9"),
484 /// lexicographic otherwise.
485 fn cmp_cells(a: &str, b: &str) -> std::cmp::Ordering {
486 match (a.parse::<f64>(), b.parse::<f64>()) {
487 (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
488 _ => a.cmp(b),
489 }
490 }
491
492 /// Rebuild `order` from `sort`. Stable, so equal cells keep their source order.
493 fn apply_sort(&mut self) {
494 // A refresh can shrink the column set out from under the sort.
495 if let Some((col, _)) = self.sort {
496 if col >= self.headers.len() {
497 self.sort = None;
498 }
499 }
500 self.order = (0..self.row_count).collect();
501 if let Some((col, ascending)) = self.sort {
502 if let Some(column) = self.columns.get(col) {
503 self.order.sort_by(|&a, &b| {
504 let ord = column.cmp_rows(a, b);
505 if ascending { ord } else { ord.reverse() }
506 });
507 }
508 }
509 }
510
511 /// What a body press at `(px, py)` lands on: `Some(Some(place))` for the
512 /// row at that place in the DISPLAY order, `Some(None)` for the empty
513 /// body under the last row, `None` for a press that is not the body's —
514 /// outside it, or on a RAISED scrollbar, which the drag surface owns. A
515 /// sunk bar is behind the plate and the press is the row's.
516 fn body_row_at(&self, px: f32, py: f32, rect: Rect) -> Option<Option<usize>> {
517 let body_top = rect.y + HEADER_H;
518 if px < rect.x || px > rect.x + rect.width || py < body_top || py > rect.y + rect.height {
519 return None;
520 }
521 if self.activity.raised() && (self.over_vbar(px, py, rect) || self.over_hbar(px, py, rect)) {
522 return None;
523 }
524 let scroll = self.geom(rect).map_or(0.0, |g| g.scroll);
525 let place = ((py - body_top + scroll) / ROW_H) as usize;
526 Some((place < self.order.len()).then_some(place))
527 }
528
529 /// A press on the row at `place` of the display order, or on the empty
530 /// body (`None`). Plain, it selects that row alone — or nothing, where
531 /// the row was the whole selection already or the press met no row.
532 /// With ctrl it toggles the row and leaves the rest. With shift it
533 /// selects the run from the anchor to the row AS DISPLAYED, so under a
534 /// sort the run is what the eye sees between the two; with ctrl as well
535 /// the run is added to what is selected.
536 fn press_row(&mut self, place: Option<usize>, ctrl: bool, shift: bool) {
537 let before = self.selected.clone();
538 match place {
539 None => {
540 if !ctrl && !shift {
541 self.selected.clear();
542 self.anchor = None;
543 }
544 }
545 Some(place) => {
546 let src = self.order[place];
547 let anchor_place = self.anchor.and_then(|a| self.order.iter().position(|&r| r == a));
548 match (shift, anchor_place) {
549 (true, Some(from)) => {
550 if !ctrl {
551 self.selected.clear();
552 }
553 let (lo, hi) = (from.min(place), from.max(place));
554 self.selected.extend(self.order[lo..=hi].iter().copied());
555 }
556 _ if ctrl => {
557 if !self.selected.remove(&src) {
558 self.selected.insert(src);
559 }
560 self.anchor = Some(src);
561 }
562 _ => {
563 let alone = self.selected.len() == 1 && self.selected.contains(&src);
564 self.selected.clear();
565 if !alone {
566 self.selected.insert(src);
567 }
568 self.anchor = Some(src);
569 }
570 }
571 }
572 }
573 if self.selected != before {
574 self.selection_changed = true;
575 }
576 }
577
578 /// Scroll to where a thumb dragged to `thumb_y` puts the content.
579 fn scroll_to_thumb(&mut self, g: &ScrollGeom, thumb_y: f32) {
580 let ratio = if g.track_range > 0.0 {
581 ((thumb_y - g.track_y) / g.track_range).clamp(0.0, 1.0)
582 } else {
583 0.0
584 };
585 self.scroll_y = ratio * g.max_scroll;
586 }
587 }
588
589 impl Layout for Spreadsheet {}
590
591 impl Paint for Spreadsheet {
592 /// The pane IS its own background plate, wearing the parameter plate's fill —
593 /// same tint, opacity, and blur-behind marker (`param_plate_fill`) — so it
594 /// bevels like the params plate and tracks a live retint / opacity / blur
595 /// toggle with it.
596 fn color(&self) -> [f32; 4] {
597 colors::param_plate_fill()
598 }
599
600 /// The shared plate corner radius (rounded on all four corners when non-zero),
601 /// matching the rounded clip hosts carve for the pane's content.
602 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
603 let r = crate::layout::plate_corner_radius();
604 let on = r > 0.0;
605 Some((r, (on, on, on, on)))
606 }
607
608 /// The shared plate border — under `control_relief` the host promotes this to
609 /// the plate bevel, like `ParametersBg`.
610 fn solid_border(&self) -> Option<([f32; 4], f32)> {
611 colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
612 }
613
614 /// Subtree painter: `paint` authors the pane's complete text with
615 /// per-column clamp bounds, so its Text prims must pass through
616 /// `paint_self` verbatim — the own-labels re-derivation drops per-prim
617 /// bounds, which is exactly how long cell values used to overlap into
618 /// their neighbor columns on narrow panes.
619 fn paints_own_subtree(&self) -> bool {
620 true
621 }
622
623 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
624 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
625 let scroll = self.geom(rect).map_or(0.0, |g| g.scroll);
626
627 // Header bg
628 ctx.quad(Rect { x, y, width: w, height: HEADER_H }, [0.12, 0.12, 0.16, 0.4]);
629
630 // Zebra rows + separators, clipped to the body band
631 let body_top = y + HEADER_H;
632 let body_bottom = y + h;
633 let selected_fill = {
634 let [r, g, b, _] = colors::highlight_primary_color();
635 [r, g, b, 0.28]
636 };
637 for i in 0..self.row_count {
638 let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
639 if ry + ROW_H <= body_top || ry >= body_bottom {
640 continue;
641 }
642 let draw_y = ry.max(body_top);
643 let draw_h = (ry + ROW_H).min(body_bottom) - draw_y;
644 if draw_h > 0.0 {
645 let row_color = if i % 2 == 0 {
646 [0.10, 0.10, 0.13, 0.15]
647 } else {
648 [0.08, 0.08, 0.11, 0.05]
649 };
650 ctx.quad(Rect { x, y: draw_y, width: w, height: draw_h }, row_color);
651 if self.order.get(i).is_some_and(|src| self.selected.contains(src)) {
652 ctx.quad(Rect { x, y: draw_y, width: w, height: draw_h }, selected_fill);
653 }
654
655 let sep_y = ry + ROW_H;
656 if sep_y >= body_top && sep_y < body_bottom {
657 ctx.quad(Rect { x, y: sep_y, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.15]);
658 }
659 }
660 }
661
662 // Header separator
663 ctx.quad(Rect { x, y: y + HEADER_H, width: w, height: 1.0 }, [0.20, 0.20, 0.25, 0.25]);
664
665 // Vertical column dividers, at scrolled column edges, kept inside the pane.
666 let hscroll = self.hscroll(rect);
667 // The last edge too: the table stops short of a wide pane, and the
668 // line says where.
669 let edges = self.col_edges();
670 if h > 0.0 && !self.headers.is_empty() {
671 for &edge in &edges[1..] {
672 let dx = x + edge - hscroll;
673 if dx <= x || dx >= x + w {
674 continue;
675 }
676 ctx.quad(Rect { x: dx, y, width: 1.0, height: h }, [0.20, 0.20, 0.25, 0.15]);
677 }
678 }
679
680 // The scrollbars' fore copy, over the cells, at the activity's fade —
681 // the fade rather than the latch, so it draws all the way out. The
682 // copy behind the plate is the host's (`paint_scrollbars`).
683 self.paint_scrollbars(rect, ctx, self.activity.fade());
684
685 // Header + cell text, each cell clamped to its column and the body band.
686 if self.headers.is_empty() {
687 return;
688 }
689 let n_cols = self.headers.len();
690 // Scrolled column origin; columns fully outside the pane skip.
691 let xoff = x - hscroll;
692 let col_visible = |col: usize| -> bool {
693 xoff + edges[col + 1] > x && xoff + edges[col] < x + w
694 };
695 if let Some(hc) = self.header_hover_col.filter(|&hc| hc < n_cols) {
696 // Subtle hover tint on the clickable header cell (the Processes-page
697 // sortable-header convention), clamped to the pane.
698 let hx0 = (xoff + edges[hc]).max(x);
699 let hx1 = (xoff + edges[hc + 1]).min(x + w);
700 if hx1 > hx0 {
701 ctx.quad(Rect { x: hx0, y, width: hx1 - hx0, height: HEADER_H }, [1.0, 1.0, 1.0, 0.05]);
702 }
703 }
704 // Every label clamps to its own column (a 4px gutter short of the
705 // divider) AND to the pane, so a long value cuts off instead of
706 // running under its neighbor, and half-scrolled edge columns stop at
707 // the plate instead of bleeding past it.
708 let col_bounds = |col: usize, top: f32, height: f32| -> Option<[f32; 4]> {
709 let x0 = (xoff + edges[col]).max(x);
710 let x1 = (xoff + edges[col + 1] - 4.0).min(x + w);
711 if x1 <= x0 {
712 return None;
713 }
714 Some([x0, top, x1, top + height])
715 };
716 // Ellipsize what the clamp would cut, so truncation reads as
717 // deliberate. A char budget from ONE cached measurement is exact
718 // because the DE label font is monospace; the clamp bounds stay on
719 // as the backstop for any fallback-font drift. Below three columns'
720 // worth of budget the mark would REPLACE the content (a 19px column
721 // fits one glyph — a bare "…" says less than a clipped digit), so
722 // very narrow columns keep the raw string and let the clamp cut it.
723 let char_w = Self::char_w();
724 // Columns fit their content, so the budget is met but for a
725 // fallback font's drift.
726 let budget_of = |col: usize| (((edges[col + 1] - edges[col]) - 12.0) / char_w).floor() as usize;
727 let fit = |s: String, budget: usize| -> String {
728 if budget < 3 || s.chars().count() <= budget {
729 return s;
730 }
731 let mut out: String = s.chars().take(budget - 1).collect();
732 out.push('\u{2026}');
733 out
734 };
735 for (i, header) in self.headers.iter().enumerate() {
736 if !col_visible(i) {
737 continue;
738 }
739 let cx = xoff + edges[i] + 8.0;
740 let budget = budget_of(i);
741 match self.sort {
742 Some((col, ascending)) if col == i => {
743 // The sorted column: its name, then the `chevron-up` or
744 // `chevron-down` glyph a cell after it — the room of
745 // the " ▲" it was until 2026-10-05, taken out of the
746 // name's budget.
747 let b = budget.saturating_sub(2);
748 let shown: String = if b < 3 || header.chars().count() <= b {
749 header.clone()
750 } else {
751 let mut out: String = header.chars().take(b - 1).collect();
752 out.push('\u{2026}');
753 out
754 };
755 let tx = cx + shown.chars().count() as f32 * char_w + 0.5 * char_w;
756 ctx.text_with(shown, cx, y + 6.0, 12.0, [0xff, 0xff, 0xff], None, col_bounds(i, y, HEADER_H));
757 const SIDE: f32 = 8.0;
758 let r = Rect { x: tx, y: y + 0.5 * (HEADER_H - SIDE), width: SIDE, height: SIDE };
759 ctx.icon(if ascending { "chevron-up" } else { "chevron-down" }, r, [1.0, 1.0, 1.0, 1.0]);
760 }
761 _ => ctx.text_with(fit(header.clone(), budget), cx, y + 6.0, 12.0, [0xdd, 0xdd, 0xee], None, col_bounds(i, y, HEADER_H)),
762 }
763 }
764 // A row half scrolled under the header or off the bottom draws its
765 // text CUT at the body band, as its zebra fill is. Until 2026-09-30
766 // it drew no text until it was wholly inside, so a scrolling row's
767 // band arrived empty and its values popped in a row's height late.
768 for (i, &src) in self.order.iter().enumerate() {
769 let ry = y + HEADER_H + i as f32 * ROW_H - scroll;
770 let top = ry.max(body_top);
771 let bottom = (ry + ROW_H).min(body_bottom);
772 if bottom <= top {
773 continue;
774 }
775 // Written here, for the cells on screen only (`SheetColumn`).
776 for (col_idx, column) in self.columns.iter().enumerate().take(n_cols) {
777 if !col_visible(col_idx) {
778 continue;
779 }
780 let cx = xoff + edges[col_idx] + 8.0;
781 ctx.text_with(fit(column.cell(src), budget_of(col_idx)), cx, ry + 6.0, 12.0, [0xbb, 0xbb, 0xcc], None, col_bounds(col_idx, top, bottom - top));
782 }
783 }
784 }
785 }
786
787 impl Input for Spreadsheet {
788 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
789 match event {
790 Event::PointerMove { x: px, y: py, .. } => {
791 let r = ectx.rect;
792 let was_hovered = self.hovered;
793 self.hovered =
794 *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
795
796 // Over a bar: hover only SUSTAINS a raised bar — a sunk one
797 // is behind the plate, and the pointer is on the plate.
798 let over = self.over_vbar(*px, *py, r) || self.over_hbar(*px, *py, r);
799 self.activity.set_hover(over);
800 let flipped = self.recompute_bars(r);
801 let was_header = self.header_hover_col;
802 self.header_hover_col = self.header_col_at(*px, *py, r);
803 was_hovered != self.hovered || flipped || was_header != self.header_hover_col
804 }
805 // A left press on a column header cycles that column's sort:
806 // ascending → descending → back to natural order.
807 Event::MouseButton {
808 button: MouseButton::Left,
809 state: ElementState::Pressed,
810 x: px,
811 y: py,
812 ..
813 } => {
814 let Some(col) = self.header_col_at(*px, *py, ectx.rect) else {
815 // Not the header: a row of the body, or nothing of ours.
816 let Some(place) = self.body_row_at(*px, *py, ectx.rect) else {
817 return false;
818 };
819 let had = self.selection_changed;
820 self.selection_changed = false;
821 self.press_row(place, self.ctrl, self.shift);
822 let changed = self.selection_changed;
823 self.selection_changed |= had;
824 return changed;
825 };
826 self.sort = match self.sort {
827 Some((c, true)) if c == col => Some((col, false)),
828 Some((c, false)) if c == col => None,
829 _ => Some((col, true)),
830 };
831 self.apply_sort();
832 true
833 }
834 // Hit-gated by the adapter (which also rejects hidden widgets).
835 Event::MouseWheel { delta, .. } => {
836 // Delta signs follow the ScrollRegion/TextBox convention (negate the
837 // event delta); natural scroll is already applied upstream by libinput.
838 let (dx, dy) = ScrollMotion::delta_px(delta, (ROW_H, ROW_H));
839 let by = self.geom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
840 let bx = self.hgeom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
841 // Claimed whenever the pointed axis can scroll at all (an
842 // overflowing table swallows its wheel), moved or not.
843 let used = (dy != 0.0 && by.hi > 0.0) || (dx != 0.0 && bx.hi > 0.0);
844 self.motion.reconcile(self.scroll_x, self.scroll_y);
845 let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
846 self.motion.apply_px(dx, dy, discrete, bx, by);
847 self.scroll_x = self.motion.x.pos();
848 self.scroll_y = self.motion.y.pos();
849 if used {
850 // A scroll is what brings the bars to the fore.
851 self.activity.bump();
852 self.recompute_bars(ectx.rect);
853 }
854 used
855 }
856 Event::KeyInput(key_event) => {
857 if key_event.state != ElementState::Pressed {
858 return false;
859 }
860 let Some(g) = self.geom(ectx.rect) else {
861 return false;
862 };
863 // Steps glide, and a held key accumulates from the glide's
864 // target rather than the offset drawn this frame.
865 self.motion.reconcile(self.scroll_x, self.scroll_y);
866 let by = Bounds::max(g.max_scroll);
867 let old = by.clamp(self.motion.y.target());
868 let new = match &key_event.logical_key {
869 Key::Named(NamedKey::ArrowDown) => old + ROW_H,
870 Key::Named(NamedKey::ArrowUp) => old - ROW_H,
871 Key::Named(NamedKey::PageDown) => old + g.visible_h,
872 Key::Named(NamedKey::PageUp) => old - g.visible_h,
873 Key::Named(NamedKey::Home) => 0.0,
874 Key::Named(NamedKey::End) => g.max_scroll,
875 _ => return false,
876 };
877 let moved = self.motion.y.scroll_to(new, by, &scroll_settings());
878 self.scroll_y = self.motion.y.pos();
879 if moved {
880 self.activity.bump();
881 self.recompute_bars(ectx.rect);
882 }
883 moved
884 }
885 _ => false,
886 }
887 }
888
889 fn scrollable(&self) -> bool {
890 true
891 }
892
893 fn set_modifiers(&mut self, ctrl: bool, shift: bool, _alt: bool) {
894 self.ctrl = ctrl;
895 self.shift = shift;
896 }
897
898 // --- Scrollbar drag, host-driven (the designer checks `draggable()` on the pressed widget
899 // and then streams `drag_update` at it). `drag_begin` decides whether the press actually
900 // landed on a RAISED scrollbar; a body press, or one on a bar sunk behind the plate,
901 // starts no drag.
902
903 fn draggable(&self, rect: Rect) -> bool {
904 self.geom(rect).is_some() || self.hgeom(rect).is_some()
905 }
906
907 fn is_dragging(&self) -> bool {
908 self.dragging_scrollbar || self.dragging_hscrollbar
909 }
910
911 fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
912 if !self.activity.raised() {
913 return;
914 }
915 // A grab or release cancels any glide/coast in flight.
916 self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
917 // The vertical bar owns the middle of the cross, where the two meet.
918 if let Some(g) = self.geom(rect) {
919 if self.over_vbar(px, py, rect) {
920 self.dragging_scrollbar = true;
921 if py >= g.thumb_y && py <= g.thumb_y + g.thumb_h {
922 self.drag_offset_y = py - g.thumb_y;
923 } else {
924 // Track click: jump the thumb's center to the pointer.
925 self.drag_offset_y = g.thumb_h / 2.0;
926 self.scroll_to_thumb(&g, py - self.drag_offset_y);
927 }
928 return;
929 }
930 }
931 if let Some(g) = self.hgeom(rect) {
932 if self.over_hbar(px, py, rect) {
933 self.dragging_hscrollbar = true;
934 if px >= g.thumb_x && px <= g.thumb_x + g.thumb_w {
935 self.drag_offset_x = px - g.thumb_x;
936 } else {
937 self.drag_offset_x = g.thumb_w / 2.0;
938 self.hscroll_to_thumb(&g, px - self.drag_offset_x);
939 }
940 }
941 }
942 }
943
944 fn drag_update(&mut self, px: f32, py: f32, rect: Rect) -> bool {
945 if self.dragging_scrollbar {
946 self.motion.y.jump_to(self.scroll_y);
947 if let Some(g) = self.geom(rect) {
948 let old = g.scroll;
949 self.scroll_to_thumb(&g, py - self.drag_offset_y);
950 return (self.scroll_y - old).abs() > 0.01;
951 }
952 return false;
953 }
954 if self.dragging_hscrollbar {
955 self.motion.x.jump_to(self.scroll_x);
956 if let Some(g) = self.hgeom(rect) {
957 let old = g.scroll;
958 self.hscroll_to_thumb(&g, px - self.drag_offset_x);
959 return (self.scroll_x - old).abs() > 0.01;
960 }
961 }
962 false
963 }
964
965 fn drag_end(&mut self) {
966 if self.dragging_scrollbar || self.dragging_hscrollbar {
967 // A release holds the bars up for the hold window, as a scroll does.
968 self.activity.bump();
969 }
970 self.dragging_scrollbar = false;
971 self.dragging_hscrollbar = false;
972 // A grab or release cancels any glide/coast in flight.
973 self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
974 }
975
976 // --- Smooth scroll: the wheel/finger feed the shared motion; each frame advances it.
977
978 fn tick(&mut self, dt: f32, rect: Rect) -> bool {
979 self.motion.reconcile(self.scroll_x, self.scroll_y);
980 let mut moved = false;
981 if self.motion.is_animating() {
982 let by = self.geom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
983 let bx = self.hgeom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
984 moved = self.motion.tick(dt, bx, by);
985 self.scroll_x = self.motion.x.pos();
986 self.scroll_y = self.motion.y.pos();
987 if moved {
988 // A glide or a coast is scrolling too: the bars stay up for
989 // the whole of it, not only the hold after its first event.
990 self.activity.bump();
991 }
992 }
993 // The raise/sink latch and its fade. Frames keep coming while the
994 // hold runs and while the fade chases the latch, so the sink is
995 // actually drawn rather than frozen at the last input event.
996 let holding = self.activity.holding();
997 let shown = self.scrollbars_shown(rect);
998 let dragging = self.dragging_scrollbar || self.dragging_hscrollbar;
999 let flipped = self.activity.tick(dt, shown, dragging);
1000 let fade = self.activity.fade();
1001 let fading = if self.activity.raised() { fade < 1.0 } else { fade > 0.0 };
1002 moved || self.motion.is_animating() || holding || flipped || fading
1003 }
1004
1005 fn wants_tick(&self) -> bool {
1006 true
1007 }
1008
1009 }
1010
1011 impl Spreadsheet {
1012 /// Take a table: its headers, its columns and how many rows they hold.
1013 fn set_columns(&mut self, headers: Vec<String>, columns: Vec<SheetColumn>, rows: usize) {
1014 // Each column's width, from its header and its values: a refill
1015 // is a scan of the values it already copies.
1016 // The columns' widths are independent, so a large table works
1017 // them out a share of columns a thread (since 2026-10-07: at a
1018 // million values this pass was most of what a refill cost).
1019 let cells: Vec<usize> = {
1020 let values: usize = columns.iter().map(SheetColumn::len).sum();
1021 let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(columns.len());
1022 if values < 200_000 || threads < 2 {
1023 columns.iter().map(SheetColumn::max_chars).collect()
1024 } else {
1025 let per = columns.len().div_ceil(threads);
1026 std::thread::scope(|scope| {
1027 let pieces: Vec<_> = columns
1028 .chunks(per)
1029 .map(|share| scope.spawn(move || share.iter().map(SheetColumn::max_chars).collect::<Vec<_>>()))
1030 .collect();
1031 pieces.into_iter().flat_map(|p| p.join().expect("a column's width")).collect()
1032 })
1033 }
1034 };
1035 self.col_chars = headers
1036 .iter()
1037 .enumerate()
1038 .map(|(i, h)| (h.chars().count() + SORT_MARK_CHARS).max(cells.get(i).copied().unwrap_or(0)))
1039 .collect();
1040 self.headers = headers;
1041 self.columns = columns;
1042 self.row_count = rows;
1043 // Re-derive the display order so an active sort survives a data refresh
1044 // (the designer re-sets the whole table on selection/param changes).
1045 self.apply_sort();
1046 // The raw scroll may now exceed the new content; every consumer clamps through
1047 // `geom()`, and the next scroll write re-clamps it for real.
1048 //
1049 // The selection is of rows by index, and stands across a refresh:
1050 // the designer re-sets the table on every frame of a playback, and
1051 // the rows selected are still the elements they were. What a
1052 // shorter table no longer has goes.
1053 let n = self.row_count;
1054 let kept = self.selected.len();
1055 self.selected.retain(|&r| r < n);
1056 if self.selected.len() != kept {
1057 self.selection_changed = true;
1058 }
1059 if self.anchor.is_some_and(|a| a >= n) {
1060 self.anchor = None;
1061 }
1062 }
1063 }
1064
1065 impl SpreadsheetController for Spreadsheet {
1066 fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
1067 // Rows of text become text columns, a cell a row; a short row's
1068 // missing cells are empty.
1069 let width = headers.len();
1070 let mut columns: Vec<Vec<String>> = (0..width).map(|_| Vec::with_capacity(rows.len())).collect();
1071 for row in &rows {
1072 for (c, column) in columns.iter_mut().enumerate() {
1073 column.push(row.get(c).cloned().unwrap_or_default());
1074 }
1075 }
1076 self.set_columns(headers, columns.into_iter().map(SheetColumn::Text).collect(), rows.len());
1077 }
1078
1079 fn set_spreadsheet_columns(&mut self, headers: Vec<String>, columns: Vec<SheetColumn>) {
1080 let rows = columns.iter().map(SheetColumn::len).max().unwrap_or(0);
1081 self.set_columns(headers, columns, rows);
1082 }
1083
1084 fn selected_rows(&self) -> Vec<usize> {
1085 self.selected.iter().copied().collect()
1086 }
1087
1088 fn set_selected_rows(&mut self, rows: &[usize]) {
1089 let n = self.row_count;
1090 let next: std::collections::BTreeSet<usize> = rows.iter().copied().filter(|&r| r < n).collect();
1091 if next != self.selected {
1092 self.selected = next;
1093 self.selection_changed = true;
1094 }
1095 if self.anchor.is_some_and(|a| !self.selected.contains(&a)) {
1096 self.anchor = None;
1097 }
1098 }
1099
1100 fn take_selection_change(&mut self) -> bool {
1101 std::mem::take(&mut self.selection_changed)
1102 }
1103 }
1104
1105 #[cfg(test)]
1106 mod tests {
1107 use super::*;
1108 use crate::context::UiContext;
1109 use crate::widget::WidgetHost;
1110
1111 fn filled(rows: usize) -> Adapted<Spreadsheet> {
1112 let mut s = Spreadsheet::new();
1113 s.set_visible(true);
1114 WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0); // viewport: 100 = ~4 rows of 24
1115 let data: Vec<Vec<String>> =
1116 (0..rows).map(|i| vec![format!("r{i}"), format!("v{i}")]).collect();
1117 SpreadsheetController::set_spreadsheet_data(&mut *s, vec!["a".into(), "b".into()], data);
1118 s
1119 }
1120
1121 fn wide(cols: usize) -> Adapted<Spreadsheet> {
1122 let mut s = Spreadsheet::new();
1123 s.set_visible(true);
1124 WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
1125 let headers: Vec<String> = (0..cols).map(|i| format!("c{i}")).collect();
1126 let rows = vec![(0..cols).map(|i| format!("v{i}")).collect::<Vec<String>>(); 2];
1127 SpreadsheetController::set_spreadsheet_data(&mut *s, headers, rows);
1128 s
1129 }
1130
1131 /// A row half scrolled out at either edge of the body draws its text cut
1132 /// at the band — it used to draw none until it was wholly inside.
1133 #[test]
1134 fn a_half_scrolled_row_draws_its_text_cut_at_the_body() {
1135 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1136 let mut s = filled(20);
1137 s.scroll_y = ROW_H * 0.5;
1138 let mut pc = crate::scene::paint::PaintCtx::new();
1139 Paint::paint(&*s, rect, &mut pc);
1140 let items = pc.finish().items;
1141 let cell = |want: &str| {
1142 items.iter().find_map(|item| match &item.prim {
1143 crate::scene::paint::Prim::Text { text, bounds, .. } if text == want => *bounds,
1144 _ => None,
1145 })
1146 };
1147 let body_top = HEADER_H;
1148 let body_bottom = rect.height;
1149 // r0 spans -12..12 of the body; r4 runs 12 px past its bottom.
1150 let top = cell("r0").expect("the row under the header draws its text");
1151 assert_eq!((top[1], top[3]), (body_top, body_top + ROW_H * 0.5), "cut at the header");
1152 let bottom = cell("r4").expect("the row off the bottom draws its text");
1153 assert_eq!(bottom[3], body_bottom, "cut at the pane's bottom");
1154 assert!(bottom[1] < bottom[3]);
1155 assert!(cell("r5").is_none(), "a row wholly out of the body draws nothing");
1156 }
1157
1158 /// Each column is as wide as its content — its header with room for the
1159 /// sort glyph, or its widest cell — and not a share of the pane: a
1160 /// narrow table leaves the rest of a wide pane empty, a wide one scrolls.
1161 #[test]
1162 fn columns_fit_their_content_and_overflow_scrolls() {
1163 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1164 let cw = Spreadsheet::char_w();
1165 let mut s = Spreadsheet::new();
1166 s.set_visible(true);
1167 WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
1168 SpreadsheetController::set_spreadsheet_columns(
1169 &mut *s,
1170 vec!["#".into(), "a_long_header_name".into(), "x".into()],
1171 vec![
1172 SheetColumn::Int((0..1000).collect()),
1173 SheetColumn::Int(vec![0, 1]),
1174 SheetColumn::Float { values: vec![-1.5, 12.25], decimals: 4 },
1175 ],
1176 );
1177 let edges = (*s).col_edges();
1178 let w = |c: usize| edges[c + 1] - edges[c];
1179 // "999" is three; "#" and its sort room three too.
1180 assert!((w(0) - (3.0 * cw + CELL_PAD)).abs() < 0.01);
1181 // The header, not the 0 and 1, sizes the second.
1182 assert!((w(1) - ((18 + SORT_MARK_CHARS) as f32 * cw + CELL_PAD)).abs() < 0.01);
1183 // "-1.5000" and "12.2500" are seven.
1184 assert!((w(2) - (7.0 * cw + CELL_PAD)).abs() < 0.01);
1185 let g = (*s).hgeom(rect).expect("a run wider than 200 px scrolls");
1186 assert!((g.max_scroll - (edges[3] - 200.0)).abs() < 0.01);
1187
1188 let fits = wide(2);
1189 assert!((*fits).hgeom(rect).is_none(), "2 short columns fit, no h-scroll");
1190 assert!(*(*fits).col_edges().last().unwrap() < 200.0, "and do not stretch to the pane");
1191 }
1192
1193 /// A column's widest cell, from its values: a whole number's least or
1194 /// greatest, a float's sign and integer digits plus its decimals, and
1195 /// the non-finite spellings.
1196 #[test]
1197 fn a_columns_widest_cell_is_worked_out_from_its_values() {
1198 assert_eq!(SheetColumn::Int(vec![-12, 5, 300]).max_chars(), 3);
1199 assert_eq!(SheetColumn::Int(vec![-1200, 5]).max_chars(), 5);
1200 assert_eq!(SheetColumn::Float { values: vec![0.5, -0.25], decimals: 4 }.max_chars(), 7);
1201 assert_eq!(SheetColumn::Float { values: vec![0.5, f32::NEG_INFINITY], decimals: 1 }.max_chars(), 4);
1202 assert_eq!(SheetColumn::Float { values: vec![f32::NAN], decimals: 4 }.max_chars(), 3);
1203 assert_eq!(SheetColumn::Text(vec!["ab".into(), "abcd".into()]).max_chars(), 4);
1204 assert_eq!(SheetColumn::Int(vec![]).max_chars(), 0);
1205 for col in [SheetColumn::Int(vec![-7, 42]), SheetColumn::Float { values: vec![-3.25, 120.0], decimals: 3 }] {
1206 let written = (0..col.len()).map(|r| col.cell(r).chars().count()).max().unwrap();
1207 assert_eq!(col.max_chars(), written, "as `cell` writes them");
1208 }
1209 }
1210
1211 /// The sort hit-test must look up columns through the scrolled origin, or
1212 /// clicking a header would sort the column that USED to be under the pointer.
1213 #[test]
1214 fn header_hit_test_tracks_horizontal_scroll() {
1215 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1216 let mut s = wide(6);
1217 assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(0));
1218 s.scroll_x = (*s).col_edges()[1];
1219 assert_eq!((*s).header_col_at(10.0, 5.0, rect), Some(1));
1220 }
1221
1222 /// A horizontal wheel feeds hscroll velocity, tick integrates and decays it,
1223 /// and the scroll clamps inside the overflow.
1224 #[test]
1225 fn horizontal_wheel_integrates_and_decays_through_tick() {
1226 let mut ctx = UiContext::new();
1227 let s = ctx.insert(wide(6));
1228
1229 let wheel = Event::MouseWheel {
1230 delta: MouseScrollDelta::LineDelta(-2.0, 0.0),
1231 x: 50.0,
1232 y: 60.0,
1233 local_x: 50.0,
1234 local_y: 60.0,
1235 };
1236 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&wheel, ctx)).unwrap(), "in-rect horizontal wheel consumed");
1237 assert!(ctx.lend_h(s, |w, ctx| WidgetHost::tick(w, 0.016, ctx)).unwrap(), "first tick moves the h-scroll");
1238 let mut guard = 0;
1239 while ctx.lend_h(s, |w, ctx| WidgetHost::tick(w, 0.016, ctx)).unwrap() {
1240 guard += 1;
1241 assert!(guard < 1000, "h-inertia must decay to a stop");
1242 }
1243 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1244 let max = (*ctx[s]).hgeom(rect).unwrap().max_scroll;
1245 assert!(ctx[s].scroll_x >= 0.0 && ctx[s].scroll_x <= max, "h-scroll stays clamped");
1246 assert!(ctx[s].scroll_x > 0.0, "negative dx scrolled the columns (ScrollRegion sign convention)");
1247
1248 // A pane whose columns fit ignores horizontal wheels.
1249 let fits = ctx.insert(wide(2));
1250 assert!(!ctx.lend_h(fits, |w, ctx| w.handle_event(&wheel, ctx)).unwrap(), "no overflow, wheel passes through");
1251 }
1252
1253 #[test]
1254 fn wheel_velocity_integrates_and_decays_through_tick() {
1255 let mut ctx = UiContext::new();
1256 let s = ctx.insert(filled(50));
1257
1258 // A wheel over the body feeds velocity (negated delta, the ScrollRegion
1259 // convention: a negative line delta scrolls the view down)…
1260 let wheel = Event::MouseWheel {
1261 delta: MouseScrollDelta::LineDelta(0.0, -2.0),
1262 x: 50.0,
1263 y: 60.0,
1264 local_x: 50.0,
1265 local_y: 60.0,
1266 };
1267 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&wheel, ctx)).unwrap(), "in-rect wheel consumed");
1268
1269 // …which tick integrates into scroll movement and decays to a stop.
1270 assert!(ctx.lend_h(s, |w, ctx| WidgetHost::tick(w, 0.016, ctx)).unwrap(), "first tick moves the scroll");
1271 let mut guard = 0;
1272 while ctx.lend_h(s, |w, ctx| WidgetHost::tick(w, 0.016, ctx)).unwrap() {
1273 guard += 1;
1274 assert!(guard < 1000, "inertia must decay to a stop");
1275 }
1276
1277 // Hidden spreadsheets are not hittable, so the wheel passes through.
1278 ctx[s].set_visible(false);
1279 assert!(!ctx.lend_h(s, |w, ctx| w.handle_event(&wheel, ctx)).unwrap(), "hidden widget ignores wheel");
1280 }
1281
1282 #[test]
1283 fn scrollbar_drag_and_keys_move_the_scroll() {
1284 let mut ctx = UiContext::new();
1285 let s = ctx.insert(filled(50));
1286 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1287
1288 // content 1200, viewport 100 -> overflowing, so the host may drag it.
1289 assert!(ctx[s].draggable());
1290
1291 // Sunk behind the plate, the bar takes no press.
1292 ctx[s].drag_begin(100.0, 80.0);
1293 assert!(!ctx[s].is_dragging(), "a sunk bar is not grabbed");
1294
1295 // Raised, a press on its track (the pane'ctx[s] centre line) jumps the
1296 // thumb and engages the drag.
1297 ctx[s].inner_mut().activity.bump();
1298 ctx[s].inner_mut().recompute_bars(rect);
1299 ctx[s].drag_begin(100.0, 80.0);
1300 assert!(ctx[s].is_dragging());
1301 assert!(ctx[s].drag_update(100.0, 90.0), "thumb drag scrolls");
1302 let dragged_to = ctx[s].inner().geom(rect).unwrap().scroll;
1303 assert!(dragged_to > 0.0);
1304 ctx[s].drag_end();
1305 assert!(!ctx[s].is_dragging());
1306
1307 // A body press (left of the scrollbar) engages no drag.
1308 ctx[s].drag_begin(50.0, 60.0);
1309 assert!(!ctx[s].is_dragging(), "body press is not a scrollbar drag");
1310
1311 // End key jumps to max; Home returns to zero. (Keys route via keyboard_input.)
1312 let end = crate::widget::KeyEvent {
1313 state: ElementState::Pressed,
1314 logical_key: Key::Named(NamedKey::End),
1315 text: None,
1316 repeat: false,
1317 ctrl: false,
1318 shift: false,
1319 alt: false,
1320 };
1321 assert!(ctx.lend_h(s, |w, ctx| w.keyboard_input(&end, ctx)).unwrap());
1322 // The key glides: run the motion out before reading the offset.
1323 for _ in 0..1000 {
1324 if !Input::tick(&mut *ctx[s].inner_mut(), 1.0 / 60.0, rect) {
1325 break;
1326 }
1327 }
1328 let g = ctx[s].inner().geom(rect).unwrap();
1329 assert_eq!(g.scroll, g.max_scroll);
1330
1331 // Hidden: the focused-widget keyboard path must not consume keys.
1332 ctx[s].set_visible(false);
1333 assert!(!ctx.lend_h(s, |w, ctx| w.keyboard_input(&end, ctx)).unwrap(), "hidden widget ignores keys");
1334 }
1335
1336 /// The middle of column `c`'s header — columns are as wide as their
1337 /// content, so a press is placed by the edges, not by a share of the pane.
1338 fn mid(s: &Adapted<Spreadsheet>, c: usize) -> f32 {
1339 let e = s.inner().col_edges();
1340 (e[c] + e[c + 1]) * 0.5
1341 }
1342
1343 fn header_click(x: f32) -> Event {
1344 Event::MouseButton {
1345 button: MouseButton::Left,
1346 state: ElementState::Pressed,
1347 x,
1348 y: 10.0,
1349 local_x: x,
1350 local_y: 10.0,
1351 }
1352 }
1353
1354 #[test]
1355 fn header_click_cycles_ascending_descending_natural() {
1356 let mut ctx = UiContext::new();
1357 let s = ctx.insert(Spreadsheet::new());
1358 ctx[s].set_visible(true);
1359 WidgetHost::set_rect(&mut ctx[s], 0.0, 0.0, 200.0, 124.0);
1360 // Numeric strings out of lexicographic order: "10" must sort after "9".
1361 let rows = vec![
1362 vec!["10".to_string(), "b".to_string()],
1363 vec!["9".to_string(), "c".to_string()],
1364 vec!["2".to_string(), "a".to_string()],
1365 ];
1366 SpreadsheetController::set_spreadsheet_data(&mut *ctx[s], vec!["n".into(), "s".into()], rows);
1367 assert_eq!(ctx[s].inner().order, vec![0, 1, 2], "unsorted = natural order");
1368
1369 // Click 1 on column 0: ascending, numeric.
1370 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap());
1371 assert_eq!(ctx[s].inner().sort, Some((0, true)));
1372 assert_eq!(ctx[s].inner().order, vec![2, 1, 0], "2 < 9 < 10 numerically");
1373
1374 // Click 2: descending.
1375 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap());
1376 assert_eq!(ctx[s].inner().sort, Some((0, false)));
1377 assert_eq!(ctx[s].inner().order, vec![0, 1, 2]);
1378
1379 // Click 3: back to natural order.
1380 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap());
1381 assert_eq!(ctx[s].inner().sort, None);
1382 assert_eq!(ctx[s].inner().order, vec![0, 1, 2]);
1383
1384 // Column 1 (lexicographic), then a body click changes nothing.
1385 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 1)), ctx)).unwrap());
1386 assert_eq!(ctx[s].inner().order, vec![2, 0, 1], "a < b < c");
1387 let body = Event::MouseButton {
1388 button: MouseButton::Left,
1389 state: ElementState::Pressed,
1390 x: 50.0,
1391 y: 60.0,
1392 local_x: 50.0,
1393 local_y: 60.0,
1394 };
1395 ctx.lend_h(s, |w, ctx| w.handle_event(&body, ctx)).unwrap();
1396 assert_eq!(ctx[s].inner().sort, Some((1, true)), "body press is not a sort");
1397 }
1398
1399 fn body_click(y: f32) -> Event {
1400 Event::MouseButton {
1401 button: MouseButton::Left,
1402 state: ElementState::Pressed,
1403 x: 50.0,
1404 y,
1405 local_x: 50.0,
1406 local_y: y,
1407 }
1408 }
1409
1410 /// A press selects a row, ctrl toggles one, shift extends from the last
1411 /// pressed; the rows are named by their place in the DATA, so a sort
1412 /// moves the highlight and not what is selected; and a press on the
1413 /// scrollbar is not a press on a row.
1414 #[test]
1415 fn rows_select_alone_toggled_and_in_runs() {
1416 let mut ctx = UiContext::new();
1417 let s = ctx.insert(filled(50));
1418 // Rows are 24 tall under a 24 header: row k spans 24 + 24k.
1419 let row_y = |k: usize| 24.0 + 24.0 * k as f32 + 12.0;
1420
1421 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(1)), ctx)).unwrap());
1422 assert_eq!(ctx[s].inner().selected_rows(), vec![1]);
1423 assert!(ctx[s].inner_mut().take_selection_change());
1424 assert!(!ctx[s].inner_mut().take_selection_change(), "taken once");
1425
1426 // Plain press elsewhere replaces it.
1427 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(2)), ctx)).unwrap();
1428 assert_eq!(ctx[s].inner().selected_rows(), vec![2]);
1429
1430 // Ctrl adds and removes.
1431 crate::widget::WidgetHostExt::set_modifiers(&mut ctx[s], true, false, false);
1432 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(0)), ctx)).unwrap();
1433 assert_eq!(ctx[s].inner().selected_rows(), vec![0, 2]);
1434 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(2)), ctx)).unwrap();
1435 assert_eq!(ctx[s].inner().selected_rows(), vec![0]);
1436
1437 // Shift runs from the last row pressed without it (row 2, the ctrl
1438 // press) to this one.
1439 crate::widget::WidgetHostExt::set_modifiers(&mut ctx[s], false, true, false);
1440 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(0)), ctx)).unwrap();
1441 assert_eq!(ctx[s].inner().selected_rows(), vec![0, 1, 2]);
1442
1443 // A plain press on the one selected row clears it.
1444 crate::widget::WidgetHostExt::set_modifiers(&mut ctx[s], false, false, false);
1445 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(3)), ctx)).unwrap();
1446 ctx.lend_h(s, |w, ctx| w.handle_event(&body_click(row_y(3)), ctx)).unwrap();
1447 assert!(ctx[s].inner().selected_rows().is_empty());
1448
1449 // A RAISED scrollbar'ctx[s] lane is the drag surface'ctx[s].
1450 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1451 ctx[s].inner_mut().activity.bump();
1452 ctx[s].inner_mut().recompute_bars(rect);
1453 let press = Event::MouseButton {
1454 button: MouseButton::Left,
1455 state: ElementState::Pressed,
1456 x: 100.0,
1457 y: row_y(1),
1458 local_x: 100.0,
1459 local_y: row_y(1),
1460 };
1461 ctx.lend_h(s, |w, ctx| w.handle_event(&press, ctx));
1462 assert!(ctx[s].inner().selected_rows().is_empty(), "a scrollbar press selects nothing");
1463
1464 // Under a sort the row pressed is the row SHOWN there, and a shift
1465 // run is the rows shown between.
1466 let t = ctx.insert(Spreadsheet::new());
1467 ctx[t].set_visible(true);
1468 WidgetHost::set_rect(&mut ctx[t], 0.0, 0.0, 200.0, 124.0);
1469 let rows = vec![
1470 vec!["10".to_string(), "b".to_string()],
1471 vec!["9".to_string(), "c".to_string()],
1472 vec!["2".to_string(), "a".to_string()],
1473 ];
1474 SpreadsheetController::set_spreadsheet_data(&mut *ctx[t], vec!["n".into(), "s".into()], rows.clone());
1475 ctx.lend_h(t, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap(); // ascending: 2, 9, 10 = rows 2, 1, 0
1476 ctx.lend_h(t, |w, ctx| w.handle_event(&body_click(row_y(0)), ctx)).unwrap();
1477 assert_eq!(ctx[t].inner().selected_rows(), vec![2], "the first row shown is the data's third");
1478 crate::widget::WidgetHostExt::set_modifiers(&mut ctx[t], false, true, false);
1479 ctx.lend_h(t, |w, ctx| w.handle_event(&body_click(row_y(1)), ctx)).unwrap();
1480 assert_eq!(ctx[t].inner().selected_rows(), vec![1, 2]);
1481
1482 // A refresh keeps what is selected, less what the table lost.
1483 SpreadsheetController::set_spreadsheet_data(&mut *ctx[t], vec!["n".into(), "s".into()], rows[..2].to_vec());
1484 assert_eq!(ctx[t].inner().selected_rows(), vec![1]);
1485 ctx[t].inner_mut().set_selected_rows(&[]);
1486 assert!(ctx[t].inner().selected_rows().is_empty());
1487 assert!(ctx[t].inner_mut().take_selection_change());
1488 }
1489
1490 /// The two bars cross at the middle of the body; they idle behind the
1491 /// plate, where a press on them is the row's; a scroll brings them to the
1492 /// fore, a pointer over a raised bar holds it there past the hold, and
1493 /// with nothing holding them they sink again. Hover alone never raises.
1494 #[test]
1495 fn the_scrollbars_cross_at_the_body_and_sink_until_scrolled() {
1496 let mut ctx = UiContext::new();
1497 let s = ctx.insert(Spreadsheet::new());
1498 ctx[s].set_visible(true);
1499 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1500 WidgetHost::set_rect(&mut ctx[s], rect.x, rect.y, rect.width, rect.height);
1501 let headers: Vec<String> = (0..6).map(|i| format!("c{i}")).collect();
1502 let rows: Vec<Vec<String>> = (0..50).map(|r| (0..6).map(|c| format!("{r}.{c}")).collect()).collect();
1503 SpreadsheetController::set_spreadsheet_data(&mut *ctx[s], headers, rows);
1504
1505 // A cross: the vertical bar centred on the width, the horizontal on
1506 // the body'ctx[s] height, each spanning its axis less the inset.
1507 let v = ctx[s].inner().geom(rect).expect("50 rows overflow");
1508 let h = ctx[s].inner().hgeom(rect).expect("6 floored columns overflow");
1509 let body_mid = HEADER_H + (rect.height - HEADER_H) * 0.5;
1510 assert!((v.bar_x + v.bar_w * 0.5 - rect.width * 0.5).abs() < 0.01, "vertical bar on the centre line");
1511 assert!((h.bar_y + h.bar_h * 0.5 - body_mid).abs() < 0.01, "horizontal bar on the body's centre line");
1512 assert_eq!((v.track_y, v.track_h), (HEADER_H + TRACK_INSET, rect.height - HEADER_H - 2.0 * TRACK_INSET));
1513 assert_eq!((h.track_x, h.track_w), (TRACK_INSET, rect.width - 2.0 * TRACK_INSET));
1514
1515 let at = |x: f32, y: f32| Event::PointerMove { x, y, local_x: x, local_y: y };
1516 let mid = (rect.width * 0.5, body_mid);
1517
1518 // Sunk: hovering raises nothing, and a press on the middle of the
1519 // cross is a press on the row there.
1520 assert!(!ctx[s].inner().scrollbars_raised());
1521 ctx.lend_h(s, |w, ctx| w.handle_event(&at(mid.0, mid.1), ctx)).unwrap();
1522 assert!(!ctx[s].inner().scrollbars_raised(), "hover never raises a sunk bar");
1523 assert!(ctx[s].inner().body_row_at(mid.0, mid.1, rect).is_some(), "a sunk bar's lane is the row's");
1524
1525 // A scroll raises both; the lane is the bars' now, and the fore copy
1526 // fades in over the next frames.
1527 let wheel = Event::MouseWheel {
1528 delta: MouseScrollDelta::LineDelta(0.0, -1.0),
1529 x: 30.0,
1530 y: 40.0,
1531 local_x: 30.0,
1532 local_y: 40.0,
1533 };
1534 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&wheel, ctx)).unwrap());
1535 assert!(ctx[s].inner().scrollbars_raised(), "a scroll raises the bars");
1536 assert!(ctx[s].inner().body_row_at(mid.0, mid.1, rect).is_none(), "a raised bar's lane is the drag's");
1537 assert!(ctx[s].inner().body_row_at(mid.0, mid.1 + 30.0, rect).is_none(), "the vertical bar off the middle too");
1538 for _ in 0..20 {
1539 Input::tick(&mut *ctx[s].inner_mut(), 0.016, rect);
1540 }
1541 assert_eq!(ctx[s].inner().scrollbar_fade(), 1.0, "faded all the way in");
1542
1543 // The pointer on a bar holds it up long past the hold…
1544 ctx.lend_h(s, |w, ctx| w.handle_event(&at(mid.0, mid.1 + 30.0), ctx)).unwrap();
1545 for _ in 0..200 {
1546 Input::tick(&mut *ctx[s].inner_mut(), 0.016, rect);
1547 }
1548 assert!(ctx[s].inner().scrollbars_raised(), "hovered, a raised bar stays in front");
1549
1550 // …and off it, the bars sink once the hold runs out, and fade away.
1551 ctx.lend_h(s, |w, ctx| w.handle_event(&at(30.0, 40.0), ctx)).unwrap();
1552 let mut guard = 0;
1553 while Input::tick(&mut *ctx[s].inner_mut(), 0.016, rect) {
1554 guard += 1;
1555 assert!(guard < 1000, "the sink settles");
1556 }
1557 assert!(!ctx[s].inner().scrollbars_raised(), "unheld, the bars sink");
1558 assert_eq!(ctx[s].inner().scrollbar_fade(), 0.0);
1559
1560 // Painted: nothing of the bars while sunk (the host draws that copy
1561 // behind its plate), four pills while raised.
1562 let pills = |s: &Adapted<Spreadsheet>| {
1563 let mut pc = crate::scene::paint::PaintCtx::new();
1564 Paint::paint(&**s, rect, &mut pc);
1565 pc.finish().items.iter().filter(|i| matches!(i.prim, crate::scene::paint::Prim::RoundedRect { .. })).count()
1566 };
1567 assert_eq!(pills(&ctx[s]), 0, "a sunk bar is not painted over the cells");
1568 ctx.lend_h(s, |w, ctx| w.handle_event(&wheel, ctx)).unwrap();
1569 for _ in 0..20 {
1570 Input::tick(&mut *ctx[s].inner_mut(), 0.016, rect);
1571 }
1572 assert_eq!(pills(&ctx[s]), 4, "two tracks and two thumbs");
1573 }
1574
1575 /// A table large enough to work its columns' widths out on several
1576 /// threads gets the widths one thread works out.
1577 #[test]
1578 fn a_large_tables_widths_are_worked_out_in_parallel_alike() {
1579 let n = 60_000;
1580 let columns: Vec<SheetColumn> = (0..6)
1581 .map(|c| {
1582 if c % 2 == 0 {
1583 SheetColumn::Float { values: (0..n).map(|r| ((r * 7919 + c * 13) % 100_003) as f32 * if c == 2 { -0.37 } else { 0.011 }).collect(), decimals: 4 }
1584 } else {
1585 SheetColumn::Int((0..n as i64).map(|r| r * (c as i64) - 1000).collect())
1586 }
1587 })
1588 .collect();
1589 let headers: Vec<String> = (0..6).map(|c| format!("c{c}")).collect();
1590 let want: Vec<usize> = headers.iter().zip(&columns).map(|(h, c)| (h.chars().count() + SORT_MARK_CHARS).max(c.max_chars())).collect();
1591 let mut s = Spreadsheet::new();
1592 SpreadsheetController::set_spreadsheet_columns(&mut *s, headers, columns);
1593 assert_eq!(s.inner().col_chars, want);
1594 }
1595
1596 /// A column's width in characters is its widest cell's, numbers by
1597 /// their range: over negatives, positives, values that round to zero
1598 /// either side, both zeros, the non-finite spellings, and integers.
1599 #[test]
1600 fn max_chars_is_the_widest_cell() {
1601 let widest = |c: &SheetColumn| (0..c.len()).map(|r| c.cell(r).chars().count()).max().unwrap_or(0);
1602 let floats = [
1603 vec![0.0, -0.0],
1604 vec![-0.0, 0.0],
1605 vec![1.5, -0.00001, 2.0],
1606 vec![123.456, -9.99996, 0.5],
1607 vec![-1234.5, 99999.0, 0.0],
1608 vec![f32::NAN, 1.0],
1609 vec![f32::NEG_INFINITY, 0.25],
1610 vec![f32::INFINITY],
1611 vec![],
1612 ];
1613 for values in floats {
1614 for decimals in [0, 2, 4] {
1615 let c = SheetColumn::Float { values: values.clone(), decimals };
1616 assert_eq!(c.max_chars(), widest(&c), "{values:?} at {decimals}");
1617 }
1618 }
1619 for ints in [vec![], vec![0], vec![-7, 3, 1200], vec![i64::MIN, 5], vec![-1, -100]] {
1620 let c = SheetColumn::Int(ints.clone());
1621 assert_eq!(c.max_chars(), widest(&c), "{ints:?}");
1622 }
1623 }
1624
1625 /// A table of columns: the cells on screen are written as they are
1626 /// painted — to a float column's decimals, an integer as one — and only
1627 /// those; a sort compares the values, so 10 sorts after 9 without a
1628 /// cell being parsed, and an integer column likewise.
1629 #[test]
1630 fn a_column_table_is_written_as_painted_and_sorts_by_value() {
1631 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
1632 let mut ctx = UiContext::new();
1633 let s = ctx.insert(Spreadsheet::new());
1634 ctx[s].set_visible(true);
1635 WidgetHost::set_rect(&mut ctx[s], 0.0, 0.0, 200.0, 124.0);
1636 let n = 1000;
1637 SpreadsheetController::set_spreadsheet_columns(
1638 &mut *ctx[s],
1639 vec!["i".into(), "x".into()],
1640 vec![
1641 SheetColumn::Int((0..n as i64).collect()),
1642 SheetColumn::Float { values: (0..n).map(|r| ((r * 7919) % n) as f32 * 0.5 - 3.25).collect(), decimals: 4 },
1643 ],
1644 );
1645 assert_eq!(ctx[s].inner().row_count, n);
1646 let texts = |s: &Adapted<Spreadsheet>| -> Vec<String> {
1647 let mut pc = crate::scene::paint::PaintCtx::new();
1648 Paint::paint(&**s, rect, &mut pc);
1649 pc.finish()
1650 .items
1651 .iter()
1652 .filter_map(|item| match &item.prim {
1653 crate::scene::paint::Prim::Text { text, .. } => Some(text.clone()),
1654 _ => None,
1655 })
1656 .collect()
1657 };
1658 let shown = texts(&ctx[s]);
1659 assert!(shown.contains(&"0".to_string()) && shown.contains(&"-3.2500".to_string()), "{shown:?}");
1660 assert!(!shown.contains(&"999".to_string()), "a row off screen is not written");
1661
1662 // Sorted by x, ascending: the least value first, by value.
1663 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 1)), ctx)).unwrap());
1664 let order = ctx[s].inner().order.clone();
1665 let x = |r: usize| ((r * 7919) % n) as f32 * 0.5 - 3.25;
1666 assert!(order.windows(2).all(|w| x(w[0]) <= x(w[1])));
1667 // And by i, descending (two more clicks on the first header).
1668 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap());
1669 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 0)), ctx)).unwrap());
1670 assert_eq!(ctx[s].inner().order[0], n - 1, "999 sorts after 99 and 100");
1671 assert_eq!(SheetColumn::Float { values: vec![1.0 / 3.0], decimals: 2 }.cell(0), "0.33");
1672 assert_eq!(SheetColumn::Int(vec![-4]).cell(0), "-4");
1673 assert_eq!(SheetColumn::Int(vec![]).cell(3), "", "past the end is empty");
1674 }
1675
1676 #[test]
1677 fn data_refresh_reapplies_sort_and_column_shrink_clears_it() {
1678 let mut ctx = UiContext::new();
1679 let s = ctx.insert(Spreadsheet::new());
1680 ctx[s].set_visible(true);
1681 WidgetHost::set_rect(&mut ctx[s], 0.0, 0.0, 200.0, 124.0);
1682 SpreadsheetController::set_spreadsheet_data(
1683 &mut *ctx[s],
1684 vec!["a".into(), "b".into()],
1685 vec![vec!["1".into(), "x".into()], vec!["2".into(), "y".into()]],
1686 );
1687 assert!(ctx.lend_h(s, |w, ctx| w.handle_event(&header_click(mid(&*w, 1)), ctx)).unwrap()); // sort col 1 asc
1688
1689 // A refresh with new rows keeps the sort and re-derives the order.
1690 SpreadsheetController::set_spreadsheet_data(
1691 &mut *ctx[s],
1692 vec!["a".into(), "b".into()],
1693 vec![vec!["1".into(), "z".into()], vec!["2".into(), "w".into()]],
1694 );
1695 assert_eq!(ctx[s].inner().sort, Some((1, true)));
1696 assert_eq!(ctx[s].inner().order, vec![1, 0], "w < z");
1697
1698 // A refresh that drops the sorted column clears the sort.
1699 SpreadsheetController::set_spreadsheet_data(
1700 &mut *ctx[s],
1701 vec!["a".into()],
1702 vec![vec!["1".into()], vec!["2".into()]],
1703 );
1704 assert_eq!(ctx[s].inner().sort, None);
1705 assert_eq!(ctx[s].inner().order, vec![0, 1]);
1706 }
1707 }