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 }