git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

src/row_list.rs (30K)

  1 //! App-owned column list replacing the dissolved `List`/`ScrollBox` embedded bases
  2 //! (Phase 6z) — the column-mode flavor: columns (Flex/Absolute/RightOffset), rows with
  3 //! icon + cells, hover/press/selection overlays, click + double-click, and the scroll
  4 //! frame (wheel, scrollbar thumb drag / track jump). The geometry, colors, truncation,
  5 //! and hit math are the legacy `List` column branch verbatim; the search box that used
  6 //! to live inside the `List` is a standalone app widget now.
  7 //!
  8 //! One deliberate paint fix: `render_widget(List)` emitted the plain quads (scrollbar,
  9 //! row overlays) BEFORE the rounded background, washing them under the translucent bg —
 10 //! the same sandwich the settings lists had (Phase 6v). `push_prims` draws bg first.
 11 //!
 12 //! **That fix holds only because these prims go into the PAGE's `PageContent`.**
 13 //! `rebuild_layout` partitions `plain_pc`'s rects by radius and appends all the plain
 14 //! ones before all the rounded ones, so anything routed through there is re-sorted
 15 //! rather than drawn in call order. The bg here is rounded (`list_corner_radius`
 16 //! defaults to 4.0) while the scrollbar and row overlays are radius-0, so moving this
 17 //! emission to `plain_pc`/`window_pc` — as `PreviewPane::push_prims` does, which makes
 18 //! it look like the natural thing to do — would sort the bg back after them and
 19 //! reinstate the exact Phase 6v sandwich this note describes. It would also be silent:
 20 //! the bg is translucent, so the overlays wash out rather than disappear.
 21 //!
 22 //! The scrollbar leans on the same call order, twice (the DE's centre-line rule, see
 23 //! cce-ui's CLAUDE.md "Every scrollbar rides a centre line, behind the plate"): its
 24 //! IDLE copy goes out before the bg, so the translucent well dims it, and its FORE
 25 //! copy after the row overlays. Both are pills (rounded), so even a radius partition
 26 //! would keep the two copies either side of the rounded bg — but it would still sink
 27 //! the radius-0 row overlays under the bg, so the note above stands.
 28 
 29 use cce_ui::widget::{Bounds, Justification, MouseScrollDelta, ScrollMotion, ScrollbarActivity, LINE_PX};
 30 
 31 /// Column sizing (moved here with the cce-ui `List` deletion — RowList is the only
 32 /// remaining consumer of the column model).
 33 #[derive(Debug, Clone, Copy, PartialEq)]
 34 pub enum ColumnWidth {
 35     Flex,
 36     Absolute(f32),
 37     RightOffset(f32),
 38 }
 39 
 40 #[derive(Debug, Clone)]
 41 pub struct ListColumn {
 42     pub name: String,
 43     pub width: ColumnWidth,
 44     pub justification: Justification,
 45 }
 46 
 47 #[derive(Debug, Clone)]
 48 pub struct Row {
 49     pub cells: Vec<String>,
 50     /// The cce-icons glyph shown ahead of the first cell (`folder`,
 51     /// `file-image`, …), drawn in the first cell's colour.
 52     pub icon: Option<&'static str>,
 53     pub selected: bool,
 54 }
 55 
 56 #[derive(Debug, Clone)]
 57 pub struct RowList {
 58     pub x: f32,
 59     pub y: f32,
 60     pub w: f32,
 61     pub h: f32,
 62     /// Rows viewport: the full rect minus the in-frame search strip when it is open.
 63     pub viewport_h: f32,
 64     /// Row height with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
 65     pub item_height: f32,
 66     pub item_gap: f32,
 67     /// The DRAWN offset — `motion` glides it (wheel) or coasts it (trackpad
 68     /// flick); direct writes (thumb drag, scroll_into_view, the clamp) are
 69     /// adopted by the motion on its next step.
 70     pub scroll_y: f32,
 71     motion: ScrollMotion,
 72     pub content_h: f32,
 73     pub columns: Vec<ListColumn>,
 74     pub rows: Vec<Row>,
 75     pub hovered_row: Option<usize>,
 76     pub pressed_row: Option<usize>,
 77     clicked_row: Option<usize>,
 78     double_clicked_row: Option<usize>,
 79     last_click_time: Option<std::time::Instant>,
 80     pub dragging: bool,
 81     drag_offset_y: f32,
 82     /// The scrollbar's raise/sink latch and fade: it idles behind the well's
 83     /// translucent bg and takes no press there, until a scroll raises it.
 84     activity: ScrollbarActivity,
 85     /// Pointer focus (the app's well-focus tracking): the well renders as the
 86     /// tinted carve — accent ring replacing the relief lighting.
 87     pub focused: bool,
 88 }
 89 
 90 impl RowList {
 91     pub fn new(item_height: f32, item_gap: f32) -> Self {
 92         let (_, font_size) = cce_ui::layout::list_font_parsed();
 93         Self {
 94             x: 0.0,
 95             y: 0.0,
 96             w: 0.0,
 97             h: 0.0,
 98             viewport_h: 0.0,
 99             item_height: item_height.max(font_size + 14.0),
100             item_gap,
101             scroll_y: 0.0,
102             motion: ScrollMotion::new(),
103             content_h: 0.0,
104             columns: Vec::new(),
105             rows: Vec::new(),
106             hovered_row: None,
107             pressed_row: None,
108             clicked_row: None,
109             double_clicked_row: None,
110             last_click_time: None,
111             dragging: false,
112             drag_offset_y: 0.0,
113             activity: ScrollbarActivity::new(),
114             focused: false,
115         }
116     }
117 
118     /// `search_offset` shrinks the rows viewport from the bottom (the legacy
119     /// `List::set_rect` search reservation); the background still covers the full rect.
120     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32, search_offset: f32) {
121         self.x = x;
122         self.y = y;
123         self.w = w;
124         self.h = h;
125         self.viewport_h = (h - search_offset).max(0.0);
126     }
127 
128     /// `List::update_bounds_from_rows`: content height from the row count, scroll clamped.
129     pub fn update_bounds_from_rows(&mut self) {
130         self.content_h = self.rows.len() as f32 * (self.item_height + self.item_gap) + 4.0;
131         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
132     }
133 
134     fn max_scroll(&self) -> f32 {
135         (self.content_h - self.viewport_h).max(0.0)
136     }
137 
138     fn overflowing(&self) -> bool {
139         self.content_h > self.viewport_h
140     }
141 
142     /// A scroll happened: refresh the hold and latch at once, so the bar is
143     /// up (and hittable) in the same frame rather than a tick later.
144     fn raise(&mut self) {
145         self.activity.bump();
146         self.activity.recompute(self.overflowing(), self.dragging);
147     }
148 
149     /// Whether the scrollbar is raised in front of the rows and takes presses.
150     pub fn scrollbar_raised(&self) -> bool {
151         self.activity.raised()
152     }
153 
154     /// Keep `selected_idx`'s row inside the viewport (the browse view's auto-scroll —
155     /// what keyboard navigation scrolls the list through). A move raises the bar.
156     pub fn scroll_into_view(&mut self, selected_idx: usize) {
157         let item_y = selected_idx as f32 * (self.item_height + self.item_gap) + 2.0;
158         let old = self.scroll_y;
159         if self.viewport_h > 0.0 {
160             if item_y < self.scroll_y {
161                 self.scroll_y = item_y;
162             } else if item_y + self.item_height > self.scroll_y + self.viewport_h {
163                 self.scroll_y = item_y + self.item_height - self.viewport_h;
164             }
165         }
166         if (self.scroll_y - old).abs() > 0.01 {
167             self.raise();
168         }
169     }
170 
171     /// Whether (px, py) is inside the list's full rect — public for the app's
172     /// right-click menu on the empty space below the rows.
173     pub fn hit(&self, px: f32, py: f32) -> bool {
174         px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
175     }
176 
177     /// Screen y for row `idx`, or `None` when the row doesn't intersect the
178     /// viewport at all (the toolkit ScrollRegion's intersection contract):
179     /// partially visible rows ARE returned — their bg quads clamp and their
180     /// text is bounds-clipped in `push_prims`, so an edge row renders cut,
181     /// not culled.
182     fn get_item_draw_y(&self, idx: usize) -> Option<f32> {
183         let virtual_y = idx as f32 * (self.item_height + self.item_gap) + 2.0;
184         let draw_y = self.y + virtual_y - self.scroll_y;
185         if draw_y + self.item_height >= self.y - 1.0 && draw_y <= self.y + self.viewport_h + 1.0 {
186             Some(draw_y)
187         } else {
188             None
189         }
190     }
191 
192     /// The visible row index under (px, py) — the click/hover hit-test, public
193     /// for the app's right-click row menu. The viewport gate keeps the hidden
194     /// part of an edge-straddling row unhittable: only its visible sliver
195     /// matches, mirroring what `push_prims` draws.
196     pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
197         if py < self.y || py > self.y + self.viewport_h {
198             return None;
199         }
200         for idx in 0..self.rows.len() {
201             if let Some(draw_y) = self.get_item_draw_y(idx) {
202                 if px >= self.x + 2.0 && px <= self.x + self.w - 2.0 && py >= draw_y && py <= draw_y + self.item_height {
203                     return Some(idx);
204                 }
205             }
206         }
207         None
208     }
209 
210     /// `List::get_column_bounds`, verbatim: per-column (x-offset, width) for our width.
211     pub fn column_bounds(&self) -> Vec<(f32, f32)> {
212         let cols = &self.columns;
213         let list_w = self.w;
214         let mut bounds = vec![(0.0, 0.0); cols.len()];
215         let mut flex_indices = Vec::new();
216         let mut reserved_width = 0.0;
217 
218         for (i, col) in cols.iter().enumerate() {
219             match col.width {
220                 ColumnWidth::Absolute(w) => {
221                     bounds[i] = (0.0, w);
222                     reserved_width += w;
223                 }
224                 ColumnWidth::RightOffset(offset) => {
225                     let x = list_w - offset;
226                     let mut next_x = list_w;
227                     for j in (i + 1)..cols.len() {
228                         if let ColumnWidth::RightOffset(o) = cols[j].width {
229                             next_x = list_w - o;
230                             break;
231                         }
232                     }
233                     bounds[i] = (x, (next_x - x).max(0.0));
234                 }
235                 ColumnWidth::Flex => flex_indices.push(i),
236             }
237         }
238 
239         let current_x = 0.0;
240         let mut right_boundary = list_w;
241         for col in cols.iter() {
242             if let ColumnWidth::RightOffset(offset) = col.width {
243                 if list_w - offset < right_boundary {
244                     right_boundary = list_w - offset;
245                 }
246             }
247         }
248         let left_space = (right_boundary - current_x).max(0.0);
249         let flex_share = if !flex_indices.is_empty() {
250             (left_space - reserved_width).max(0.0) / flex_indices.len() as f32
251         } else {
252             0.0
253         };
254 
255         let mut cx = current_x;
256         for (i, col) in cols.iter().enumerate() {
257             match col.width {
258                 ColumnWidth::Absolute(w) => {
259                     bounds[i] = (cx, w);
260                     cx += w;
261                 }
262                 ColumnWidth::Flex => {
263                     bounds[i] = (cx, flex_share);
264                     cx += flex_share;
265                 }
266                 ColumnWidth::RightOffset(_) => {}
267             }
268         }
269         bounds
270     }
271 
272     pub fn take_click(&mut self) -> Option<usize> {
273         self.clicked_row.take()
274     }
275 
276     pub fn take_double_click(&mut self) -> Option<usize> {
277         self.double_clicked_row.take()
278     }
279 
280     // ── Scrollbar: the DE's centre-line bar (cce-ui's sink-behind `ScrollRegion`) ──
281 
282     /// (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h): centred on the list's
283     /// width, over the rows — no column reserves a lane for it.
284     fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
285         let sb_w = cce_ui::layout::centred_scrollbar_width();
286         let sb_x = self.x + (self.w - sb_w) * 0.5;
287         let track_h = self.viewport_h - 8.0;
288         let track_y = self.y + 4.0;
289         let visible_ratio = self.viewport_h / self.content_h.max(1.0);
290         let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
291         let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
292         let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
293         (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
294     }
295 
296     /// A press or hover on the bar. A SUNK bar is behind the well's bg and
297     /// is not hit: a press on its lane is a press on the row under it.
298     fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
299         if !self.overflowing() || !self.activity.raised() {
300             return false;
301         }
302         let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
303         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
304     }
305 
306     // ── Input ──
307 
308     /// Scrollbar drag + row hover (`List::on_cursor_moved` + `ScrollBox` drag).
309     pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
310         let mut changed = false;
311         // Gated on raised (`hit_scrollbar`): hover holds a raised bar up and
312         // never raises a sunk one.
313         self.activity.set_hover(self.hit_scrollbar(px, py));
314         if self.dragging {
315             let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
316             let target = py - self.drag_offset_y;
317             let ratio = if track_h - thumb_h > 0.0 {
318                 ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
319             } else {
320                 0.0
321             };
322             let old = self.scroll_y;
323             self.scroll_y = ratio * self.max_scroll();
324             if (self.scroll_y - old).abs() > 0.01 {
325                 changed = true;
326             }
327         }
328         let new_hovered = self.row_at(px, py);
329         if new_hovered != self.hovered_row {
330             self.hovered_row = new_hovered;
331             changed = true;
332         }
333         changed
334     }
335 
336     /// `List::mouse_input` for left press/release: scrollbar thumb grab / track jump,
337     /// then row press → click (+ double-click within 400ms). Returns handled.
338     pub fn mouse_input(&mut self, pressed: bool, px: f32, py: f32) -> bool {
339         if pressed {
340             if self.hit_scrollbar(px, py) {
341                 self.dragging = true;
342                 let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
343                 let click_offset = py - thumb_y;
344                 if click_offset >= 0.0 && click_offset <= thumb_h {
345                     self.drag_offset_y = click_offset;
346                 } else {
347                     self.drag_offset_y = thumb_h / 2.0;
348                     let target = py - self.drag_offset_y;
349                     let ratio = if track_h - thumb_h > 0.0 {
350                         ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
351                     } else {
352                         0.0
353                     };
354                     self.scroll_y = ratio * self.max_scroll();
355                 }
356                 return true;
357             }
358             self.dragging = false;
359             if let Some(idx) = self.row_at(px, py) {
360                 self.pressed_row = Some(idx);
361                 return true;
362             }
363             false
364         } else {
365             let was_dragging = std::mem::take(&mut self.dragging);
366             if was_dragging {
367                 // The hold starts at the release, so the bar lingers.
368                 self.activity.bump();
369             }
370             let mut clicked = false;
371             if let Some(idx) = self.row_at(px, py) {
372                 if self.pressed_row == Some(idx) {
373                     let now = std::time::Instant::now();
374                     if let Some(last) = self.last_click_time {
375                         if now.duration_since(last) < std::time::Duration::from_millis(400) {
376                             self.double_clicked_row = Some(idx);
377                         }
378                     }
379                     self.last_click_time = Some(now);
380                     self.clicked_row = Some(idx);
381                     clicked = true;
382                 }
383             }
384             self.pressed_row = None;
385             clicked || was_dragging
386         }
387     }
388 
389     /// Hit-scoped wheel (`ScrollBox::mouse_wheel`). A wheel notch moves the
390     /// target and [`Self::tick`] glides the offset there; a trackpad finger
391     /// moves the offset now. True when either moved (repaint).
392     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
393         if !self.hit(px, py) {
394             return false;
395         }
396         self.motion.reconcile(0.0, self.scroll_y);
397         let moved = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
398         self.scroll_y = self.motion.y.pos();
399         if moved {
400             self.raise();
401         }
402         moved
403     }
404 
405     /// Advance the wheel glide / flick coast and the scrollbar's raise/sink;
406     /// true while the offset is moving, the bar's hold is running or its fade
407     /// is moving, so the host keeps frames coming until all of it settles.
408     pub fn tick(&mut self, dt: f32) -> bool {
409         self.motion.reconcile(0.0, self.scroll_y);
410         let mut moved = false;
411         if self.motion.is_animating() {
412             moved = self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
413             self.scroll_y = self.motion.y.pos();
414             if moved {
415                 // A glide or coast in motion is a scroll: it keeps the bar up.
416                 self.activity.bump();
417             }
418         }
419         let animating = self.motion.is_animating();
420         let holding = self.activity.holding();
421         let flipped = self.activity.tick(dt, self.overflowing(), self.dragging);
422         moved || animating || holding || flipped
423     }
424 
425     /// The pill track and thumb at `alpha` (track/thumb colours scaled).
426     fn push_scrollbar(&self, pc: &mut crate::pages::PageContent, alpha: f32) {
427         use cce_ui::layout::RenderTarget;
428         let a = alpha.clamp(0.0, 1.0);
429         if !self.overflowing() || a <= 0.001 {
430             return;
431         }
432         let dim = |mut c: [f32; 4]| {
433             c[3] *= a;
434             c
435         };
436         let all = (true, true, true, true);
437         let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
438         pc.rect_with_radius_corners(
439             dim(cce_ui::color::scrollbar_track_color()),
440             sb_x,
441             track_y,
442             sb_w,
443             track_h,
444             sb_w.min(track_h) * 0.5,
445             all,
446         );
447         pc.rect_with_radius_corners(
448             dim(cce_ui::color::scrollbar_thumb_color()),
449             sb_x,
450             thumb_y,
451             sb_w,
452             thumb_h,
453             sb_w.min(thumb_h) * 0.5,
454             all,
455         );
456     }
457 
458     // ── Paint ──
459 
460     /// The legacy frame, single-drawn and in the right order: the scrollbar's idle copy,
461     /// the rounded bg (this list ran with `show_border = false`), row overlays, the
462     /// scrollbar's fore copy, then the row text — the `List` column-branch cell layout
463     /// (icon column, primary/secondary sizes and tints, char-estimate truncation,
464     /// viewport-inset clip bounds) verbatim.
465     pub fn push_prims(&self, pc: &mut crate::pages::PageContent) {
466         use cce_ui::layout::RenderTarget;
467         let (x, y, w, h) = (self.x, self.y, self.w, self.h);
468         // The idle copy, at full alpha and every frame, raised or not: the
469         // translucent bg over it is what sinks it, and the fore copy fades in
470         // over it, so dropping it at the latch would blink the bar.
471         self.push_scrollbar(pc, 1.0);
472         pc.rect_with_radius_corners(
473             cce_ui::color::list_bg_color(),
474             x,
475             y,
476             w,
477             h,
478             cce_ui::layout::list_corner_radius(),
479             (true, true, true, true),
480         );
481         // Recessed well like a text box: the list floor sits below the pane
482         // surface, its wall carved over the bg and row overlays. Focused, the
483         // well swaps that lighting for the accent ring (the tinted carve).
484         if self.focused {
485             pc.relief_recessed_focused(x, y, w, h, cce_ui::layout::list_corner_radius());
486         } else {
487             pc.relief_recessed(x, y, w, h, cce_ui::layout::list_corner_radius());
488         }
489 
490 
491         let col_bounds = self.column_bounds();
492         let (_, config_size) = cce_ui::layout::list_font_parsed();
493         let primary_size = config_size;
494         let secondary_size = (config_size - 1.0).max(8.0);
495         let list_font = cce_ui::layout::list_font();
496         let font = if list_font.is_empty() { None } else { Some(list_font) };
497 
498         let view_min = y + 4.0;
499         let view_max = y + self.viewport_h - 4.0;
500         let clip = Some([x, view_min, x + w, view_max]);
501 
502         let fg = [230.0 / 255.0, 230.0 / 255.0, 242.0 / 255.0, 1.0];
503         let plain_fg = [178.0 / 255.0, 178.0 / 255.0, 191.0 / 255.0, 1.0];
504         let dim = [140.0 / 255.0, 140.0 / 255.0, 153.0 / 255.0, 1.0];
505 
506         for (idx, row) in self.rows.iter().enumerate() {
507             let Some(draw_y) = self.get_item_draw_y(idx) else { continue };
508             let is_hovered = self.hovered_row == Some(idx);
509             let is_pressed = self.pressed_row == Some(idx);
510             let bg = if row.selected {
511                 if is_pressed {
512                     [0.30, 0.52, 0.78, 0.6]
513                 } else if is_hovered {
514                     [0.30, 0.52, 0.78, 0.5]
515                 } else {
516                     [0.20, 0.40, 0.65, 0.4]
517                 }
518             } else if is_pressed {
519                 [0.20, 0.20, 0.25, 0.25]
520             } else if is_hovered {
521                 [0.20, 0.20, 0.25, 0.15]
522             } else {
523                 [0.0, 0.0, 0.0, 0.0]
524             };
525             if bg[3] > 0.001 {
526                 // Clamped to the viewport: `get_item_draw_y` returns partial
527                 // rows, and an unclamped hover/selection quad would bleed out
528                 // of the well (exact for a flat quad).
529                 let qy = draw_y.max(y);
530                 let qb = (draw_y + self.item_height).min(y + self.viewport_h);
531                 if qb > qy {
532                     pc.rect(bg, x + 2.0, qy, w - 4.0, qb - qy);
533                 }
534             }
535 
536             let row_fg = if row.selected { fg } else { plain_fg };
537             let row_dim = if row.selected { fg } else { dim };
538             let y_primary = cce_ui::layout::center_text_y(draw_y, self.item_height, primary_size);
539             let y_secondary = cce_ui::layout::center_text_y(draw_y, self.item_height, secondary_size);
540 
541             let mut start_text_offset = 8.0;
542             if let Some(icon) = row.icon {
543                 if !col_bounds.is_empty() {
544                     // A glyph the size of the name's text, centred on the row.
545                     let side = primary_size;
546                     let gy = draw_y + (self.item_height - side) * 0.5;
547                     pc.icon_bounded(icon, x + col_bounds[0].0 + 12.0, gy, side, side, row_fg, clip);
548                     start_text_offset = 32.0;
549                 }
550             }
551 
552             for (c_idx, cell_text) in row.cells.iter().enumerate() {
553                 if c_idx >= col_bounds.len() {
554                     break;
555                 }
556                 let (col_x, col_w) = col_bounds[c_idx];
557                 if col_w <= 0.0 {
558                     continue;
559                 }
560                 let cell_color = if c_idx == 0 { row_fg } else { row_dim };
561                 let cell_y = if c_idx == 0 { y_primary } else { y_secondary };
562                 let cell_size = if c_idx == 0 { primary_size } else { secondary_size };
563                 let cell_draw_x = if c_idx == 0 { x + col_x + start_text_offset } else { x + col_x };
564                 let max_w = if c_idx == 0 { col_w - start_text_offset - 8.0 } else { col_w - 8.0 };
565                 let char_w = cell_size * 0.65;
566                 let max_chars = (max_w / char_w).max(4.0) as usize;
567                 let truncated = if cell_text.chars().count() > max_chars {
568                     let mut s: String = cell_text.chars().take(max_chars.saturating_sub(3)).collect();
569                     s.push_str("...");
570                     s
571                 } else {
572                     cell_text.clone()
573                 };
574                 push_text(pc, &truncated, cell_draw_x, cell_y, cell_size, cell_color, &font, clip);
575             }
576         }
577 
578         // The fore copy, after the row overlays, at the fade: it keeps drawing
579         // all the way out rather than stopping at the latch. (A part's icons and
580         // reliefs are emitted after all its rects, and text after everything,
581         // so the row glyphs, the well's wall and the labels still land over it.)
582         self.push_scrollbar(pc, self.activity.fade());
583     }
584 }
585 
586 fn push_text(
587     pc: &mut crate::pages::PageContent,
588     text: &str,
589     x: f32,
590     y: f32,
591     size: f32,
592     color: [f32; 4],
593     font: &Option<String>,
594     bounds: Option<[f32; 4]>,
595 ) {
596     use cce_ui::layout::RenderTarget;
597     match font {
598         Some(f) => pc.text_with_font_and_bounds(text, x, y, size, color, f, bounds),
599         None => pc.text_with_bounds(text, x, y, size, color, bounds),
600     }
601 }
602 
603 #[cfg(test)]
604 mod tests {
605     use super::*;
606     use cce_ui::widget::Justification;
607 
608     fn list_with_rows(n: usize) -> RowList {
609         let mut l = RowList::new(30.0, 2.0);
610         l.set_rect(10.0, 20.0, 400.0, 200.0, 0.0);
611         l.columns = vec![
612             ListColumn { name: "Name".into(), width: ColumnWidth::Flex, justification: Justification::Left },
613             ListColumn { name: "Size".into(), width: ColumnWidth::RightOffset(120.0), justification: Justification::Left },
614         ];
615         l.rows = (0..n)
616             .map(|i| Row { cells: vec![format!("f{i}"), "1 KiB".into()], icon: Some("file"), selected: false })
617             .collect();
618         l.update_bounds_from_rows();
619         l
620     }
621 
622     #[test]
623     fn column_bounds_flex_and_right_offset() {
624         let l = list_with_rows(1);
625         let b = l.column_bounds();
626         // RightOffset(120) claims the last 120px; Flex takes what's left of the row.
627         assert_eq!(b[1], (280.0, 120.0));
628         assert_eq!(b[0].0, 0.0);
629         assert!((b[0].1 - 280.0).abs() < 0.01);
630     }
631 
632     #[test]
633     fn click_and_double_click() {
634         let mut l = list_with_rows(5);
635         let ih = l.item_height;
636         let row1_y = 20.0 + 1.0 * (ih + 2.0) + 2.0 + ih / 2.0;
637         assert!(l.mouse_input(true, 50.0, row1_y));
638         assert!(l.mouse_input(false, 50.0, row1_y));
639         assert_eq!(l.take_click(), Some(1));
640         assert!(l.take_double_click().is_none());
641         // Second click within 400ms → double.
642         l.mouse_input(true, 50.0, row1_y);
643         l.mouse_input(false, 50.0, row1_y);
644         assert_eq!(l.take_double_click(), Some(1));
645     }
646 
647     #[test]
648     fn wheel_scrolls_and_scroll_into_view_clamps() {
649         let mut l = list_with_rows(50);
650         assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
651         // Two notches glide to 2 * LINE_PX; settle the motion first.
652         for _ in 0..600 {
653             if !l.tick(1.0 / 60.0) {
654                 break;
655             }
656         }
657         assert_eq!(l.scroll_y, 48.0);
658         l.scroll_into_view(0);
659         assert_eq!(l.scroll_y, 2.0);
660         l.scroll_into_view(49);
661         let expect = 49.0 * (l.item_height + 2.0) + 2.0 + l.item_height - l.viewport_h;
662         assert!((l.scroll_y - expect).abs() < 0.01);
663     }
664 
665     #[test]
666     fn the_scrollbar_rides_the_centre_and_sinks_until_scrolled() {
667         let mut l = list_with_rows(50);
668         let ih = l.item_height;
669         // Centred on the list's width (x 10, w 400), centred-bar thick.
670         let (sb_x, track_y, sb_w, _, _, _) = l.scrollbar_geom();
671         assert!((sb_x + sb_w * 0.5 - 210.0).abs() < 0.01);
672         assert!((sb_w - cce_ui::layout::centred_scrollbar_width()).abs() < 0.01);
673         let lane_x = sb_x + sb_w * 0.5;
674 
675         // Sunk: a press on the lane is a press on the row under it.
676         assert!(!l.scrollbar_raised());
677         let row1_y = 20.0 + 1.0 * (ih + 2.0) + 2.0 + ih / 2.0;
678         assert!(l.mouse_input(true, lane_x, row1_y));
679         assert!(!l.dragging);
680         assert!(l.mouse_input(false, lane_x, row1_y));
681         assert_eq!(l.take_click(), Some(1));
682 
683         // Sunk, the idle copy is drawn before the bg and the fore copy not
684         // at all (fade 0); both pills.
685         let mut pc = crate::pages::PageContent::new();
686         l.push_prims(&mut pc);
687         let bg = cce_ui::color::list_bg_color();
688         let bg_at = pc.rects.iter().position(|r| r.0 == bg && r.1 == l.x).expect("bg");
689         let bars: Vec<usize> = (0..pc.rects.len()).filter(|&i| pc.rects[i].1 == sb_x).collect();
690         assert_eq!(bars.len(), 2, "track and thumb, idle copy only");
691         assert!(bars.iter().all(|&i| i < bg_at && pc.rects[i].5 > 0.0));
692 
693         // A wheel raises it; the fade brings the fore copy in after the rows.
694         assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
695         assert!(l.scrollbar_raised());
696         for _ in 0..30 {
697             l.tick(1.0 / 60.0);
698         }
699         assert!(l.activity.fade() > 0.99);
700         let mut pc = crate::pages::PageContent::new();
701         l.push_prims(&mut pc);
702         let bars: Vec<usize> = (0..pc.rects.len()).filter(|&i| pc.rects[i].1 == sb_x).collect();
703         assert_eq!(bars.len(), 4, "idle copy and fore copy");
704         assert_eq!(bars[2], pc.rects.len() - 2, "the fore copy is the last thing drawn");
705 
706         // Raised, a press on the thumb grabs it, not the row under it.
707         let (_, _, _, _, thumb_y, thumb_h) = l.scrollbar_geom();
708         assert!(l.mouse_input(true, lane_x, thumb_y + thumb_h * 0.5));
709         assert!(l.dragging);
710         assert!(l.pressed_row.is_none());
711         l.mouse_input(false, lane_x, thumb_y + thumb_h * 0.5);
712         assert!(!l.dragging);
713 
714         // A pointer over the raised bar holds it up past the hold…
715         l.cursor_moved(lane_x, track_y + 10.0);
716         for _ in 0..180 {
717             l.tick(1.0 / 60.0);
718         }
719         assert!(l.scrollbar_raised(), "hover holds a raised bar");
720 
721         // …and once it leaves, the bar sinks and its fore copy fades out.
722         l.cursor_moved(50.0, 50.0);
723         let mut ticked_while_sinking = false;
724         for _ in 0..180 {
725             ticked_while_sinking |= l.tick(1.0 / 60.0);
726         }
727         assert!(ticked_while_sinking, "the hold and the fade keep frames coming");
728         assert!(!l.scrollbar_raised());
729         assert_eq!(l.activity.fade(), 0.0);
730         assert!(!l.tick(1.0 / 60.0), "settled: no more frames");
731 
732         // Hover never raises a sunk bar.
733         l.cursor_moved(lane_x, track_y + 10.0);
734         l.tick(1.0 / 60.0);
735         assert!(!l.scrollbar_raised());
736 
737         // A programmatic scroll (keyboard navigation's scroll_into_view) raises it.
738         l.scroll_into_view(49);
739         assert!(l.scrollbar_raised());
740     }
741 }