git.lucas.co / cce-ui
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 }