file manager
git clone https://git.lucas.co/cce-files.git
src/row_list.rs (22.2K)
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 use cce_ui::widget::{Bounds, Justification, MouseScrollDelta, ScrollMotion, LINE_PX};
23
24 /// Column sizing (moved here with the cce-ui `List` deletion — RowList is the only
25 /// remaining consumer of the column model).
26 #[derive(Debug, Clone, Copy, PartialEq)]
27 pub enum ColumnWidth {
28 Flex,
29 Absolute(f32),
30 RightOffset(f32),
31 }
32
33 #[derive(Debug, Clone)]
34 pub struct ListColumn {
35 pub name: String,
36 pub width: ColumnWidth,
37 pub justification: Justification,
38 }
39
40 #[derive(Debug, Clone)]
41 pub struct Row {
42 pub cells: Vec<String>,
43 pub icon: Option<String>,
44 pub selected: bool,
45 }
46
47 #[derive(Debug, Clone)]
48 pub struct RowList {
49 pub x: f32,
50 pub y: f32,
51 pub w: f32,
52 pub h: f32,
53 /// Rows viewport: the full rect minus the in-frame search strip when it is open.
54 pub viewport_h: f32,
55 /// Row height with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
56 pub item_height: f32,
57 pub item_gap: f32,
58 /// The DRAWN offset — `motion` glides it (wheel) or coasts it (trackpad
59 /// flick); direct writes (thumb drag, scroll_into_view, the clamp) are
60 /// adopted by the motion on its next step.
61 pub scroll_y: f32,
62 motion: ScrollMotion,
63 pub content_h: f32,
64 pub columns: Vec<ListColumn>,
65 pub rows: Vec<Row>,
66 pub hovered_row: Option<usize>,
67 pub pressed_row: Option<usize>,
68 clicked_row: Option<usize>,
69 double_clicked_row: Option<usize>,
70 last_click_time: Option<std::time::Instant>,
71 pub dragging: bool,
72 drag_offset_y: f32,
73 /// Pointer focus (the app's well-focus tracking): the well renders as the
74 /// tinted carve — accent ring replacing the relief lighting.
75 pub focused: bool,
76 }
77
78 impl RowList {
79 pub fn new(item_height: f32, item_gap: f32) -> Self {
80 let (_, font_size) = cce_ui::layout::list_font_parsed();
81 Self {
82 x: 0.0,
83 y: 0.0,
84 w: 0.0,
85 h: 0.0,
86 viewport_h: 0.0,
87 item_height: item_height.max(font_size + 14.0),
88 item_gap,
89 scroll_y: 0.0,
90 motion: ScrollMotion::new(),
91 content_h: 0.0,
92 columns: Vec::new(),
93 rows: Vec::new(),
94 hovered_row: None,
95 pressed_row: None,
96 clicked_row: None,
97 double_clicked_row: None,
98 last_click_time: None,
99 dragging: false,
100 drag_offset_y: 0.0,
101 focused: false,
102 }
103 }
104
105 /// `search_offset` shrinks the rows viewport from the bottom (the legacy
106 /// `List::set_rect` search reservation); the background still covers the full rect.
107 pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32, search_offset: f32) {
108 self.x = x;
109 self.y = y;
110 self.w = w;
111 self.h = h;
112 self.viewport_h = (h - search_offset).max(0.0);
113 }
114
115 /// `List::update_bounds_from_rows`: content height from the row count, scroll clamped.
116 pub fn update_bounds_from_rows(&mut self) {
117 self.content_h = self.rows.len() as f32 * (self.item_height + self.item_gap) + 4.0;
118 self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
119 }
120
121 fn max_scroll(&self) -> f32 {
122 (self.content_h - self.viewport_h).max(0.0)
123 }
124
125 /// Keep `selected_idx`'s row inside the viewport (the browse view's auto-scroll).
126 pub fn scroll_into_view(&mut self, selected_idx: usize) {
127 let item_y = selected_idx as f32 * (self.item_height + self.item_gap) + 2.0;
128 if self.viewport_h > 0.0 {
129 if item_y < self.scroll_y {
130 self.scroll_y = item_y;
131 } else if item_y + self.item_height > self.scroll_y + self.viewport_h {
132 self.scroll_y = item_y + self.item_height - self.viewport_h;
133 }
134 }
135 }
136
137 fn hit(&self, px: f32, py: f32) -> bool {
138 px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
139 }
140
141 /// Screen y for row `idx`, or `None` when the row doesn't intersect the
142 /// viewport at all (the toolkit ScrollRegion's intersection contract):
143 /// partially visible rows ARE returned — their bg quads clamp and their
144 /// text is bounds-clipped in `push_prims`, so an edge row renders cut,
145 /// not culled.
146 fn get_item_draw_y(&self, idx: usize) -> Option<f32> {
147 let virtual_y = idx as f32 * (self.item_height + self.item_gap) + 2.0;
148 let draw_y = self.y + virtual_y - self.scroll_y;
149 if draw_y + self.item_height >= self.y - 1.0 && draw_y <= self.y + self.viewport_h + 1.0 {
150 Some(draw_y)
151 } else {
152 None
153 }
154 }
155
156 /// The visible row index under (px, py) — the click/hover hit-test, public
157 /// for the app's right-click row menu. The viewport gate keeps the hidden
158 /// part of an edge-straddling row unhittable: only its visible sliver
159 /// matches, mirroring what `push_prims` draws.
160 pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
161 if py < self.y || py > self.y + self.viewport_h {
162 return None;
163 }
164 for idx in 0..self.rows.len() {
165 if let Some(draw_y) = self.get_item_draw_y(idx) {
166 if px >= self.x + 2.0 && px <= self.x + self.w - 2.0 && py >= draw_y && py <= draw_y + self.item_height {
167 return Some(idx);
168 }
169 }
170 }
171 None
172 }
173
174 /// `List::get_column_bounds`, verbatim: per-column (x-offset, width) for our width.
175 pub fn column_bounds(&self) -> Vec<(f32, f32)> {
176 let cols = &self.columns;
177 let list_w = self.w;
178 let mut bounds = vec![(0.0, 0.0); cols.len()];
179 let mut flex_indices = Vec::new();
180 let mut reserved_width = 0.0;
181
182 for (i, col) in cols.iter().enumerate() {
183 match col.width {
184 ColumnWidth::Absolute(w) => {
185 bounds[i] = (0.0, w);
186 reserved_width += w;
187 }
188 ColumnWidth::RightOffset(offset) => {
189 let x = list_w - offset;
190 let mut next_x = list_w;
191 for j in (i + 1)..cols.len() {
192 if let ColumnWidth::RightOffset(o) = cols[j].width {
193 next_x = list_w - o;
194 break;
195 }
196 }
197 bounds[i] = (x, (next_x - x).max(0.0));
198 }
199 ColumnWidth::Flex => flex_indices.push(i),
200 }
201 }
202
203 let current_x = 0.0;
204 let mut right_boundary = list_w;
205 for col in cols.iter() {
206 if let ColumnWidth::RightOffset(offset) = col.width {
207 if list_w - offset < right_boundary {
208 right_boundary = list_w - offset;
209 }
210 }
211 }
212 let left_space = (right_boundary - current_x).max(0.0);
213 let flex_share = if !flex_indices.is_empty() {
214 (left_space - reserved_width).max(0.0) / flex_indices.len() as f32
215 } else {
216 0.0
217 };
218
219 let mut cx = current_x;
220 for (i, col) in cols.iter().enumerate() {
221 match col.width {
222 ColumnWidth::Absolute(w) => {
223 bounds[i] = (cx, w);
224 cx += w;
225 }
226 ColumnWidth::Flex => {
227 bounds[i] = (cx, flex_share);
228 cx += flex_share;
229 }
230 ColumnWidth::RightOffset(_) => {}
231 }
232 }
233 bounds
234 }
235
236 pub fn take_click(&mut self) -> Option<usize> {
237 self.clicked_row.take()
238 }
239
240 pub fn take_double_click(&mut self) -> Option<usize> {
241 self.double_clicked_row.take()
242 }
243
244 // ── Scrollbar (`ScrollBox` geometry, verbatim) ──
245
246 fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
247 let sb_w = cce_ui::layout::scrollbar_width();
248 let sb_x = self.x + self.w - sb_w - 4.0;
249 let track_h = self.viewport_h - 8.0;
250 let track_y = self.y + 4.0;
251 let visible_ratio = self.viewport_h / self.content_h.max(1.0);
252 let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
253 let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
254 let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
255 (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
256 }
257
258 fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
259 if self.content_h <= self.viewport_h {
260 return false;
261 }
262 let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
263 px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
264 }
265
266 // ── Input ──
267
268 /// Scrollbar drag + row hover (`List::on_cursor_moved` + `ScrollBox` drag).
269 pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
270 let mut changed = false;
271 if self.dragging {
272 let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
273 let target = py - self.drag_offset_y;
274 let ratio = if track_h - thumb_h > 0.0 {
275 ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
276 } else {
277 0.0
278 };
279 let old = self.scroll_y;
280 self.scroll_y = ratio * self.max_scroll();
281 if (self.scroll_y - old).abs() > 0.01 {
282 changed = true;
283 }
284 }
285 let new_hovered = self.row_at(px, py);
286 if new_hovered != self.hovered_row {
287 self.hovered_row = new_hovered;
288 changed = true;
289 }
290 changed
291 }
292
293 /// `List::mouse_input` for left press/release: scrollbar thumb grab / track jump,
294 /// then row press → click (+ double-click within 400ms). Returns handled.
295 pub fn mouse_input(&mut self, pressed: bool, px: f32, py: f32) -> bool {
296 if pressed {
297 if self.hit_scrollbar(px, py) {
298 self.dragging = true;
299 let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
300 let click_offset = py - thumb_y;
301 if click_offset >= 0.0 && click_offset <= thumb_h {
302 self.drag_offset_y = click_offset;
303 } else {
304 self.drag_offset_y = thumb_h / 2.0;
305 let target = py - self.drag_offset_y;
306 let ratio = if track_h - thumb_h > 0.0 {
307 ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
308 } else {
309 0.0
310 };
311 self.scroll_y = ratio * self.max_scroll();
312 }
313 return true;
314 }
315 self.dragging = false;
316 if let Some(idx) = self.row_at(px, py) {
317 self.pressed_row = Some(idx);
318 return true;
319 }
320 false
321 } else {
322 let was_dragging = std::mem::take(&mut self.dragging);
323 let mut clicked = false;
324 if let Some(idx) = self.row_at(px, py) {
325 if self.pressed_row == Some(idx) {
326 let now = std::time::Instant::now();
327 if let Some(last) = self.last_click_time {
328 if now.duration_since(last) < std::time::Duration::from_millis(400) {
329 self.double_clicked_row = Some(idx);
330 }
331 }
332 self.last_click_time = Some(now);
333 self.clicked_row = Some(idx);
334 clicked = true;
335 }
336 }
337 self.pressed_row = None;
338 clicked || was_dragging
339 }
340 }
341
342 /// Hit-scoped wheel (`ScrollBox::mouse_wheel`). A wheel notch moves the
343 /// target and [`Self::tick`] glides the offset there; a trackpad finger
344 /// moves the offset now. True when either moved (repaint).
345 pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
346 if !self.hit(px, py) {
347 return false;
348 }
349 self.motion.reconcile(0.0, self.scroll_y);
350 let moved = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
351 self.scroll_y = self.motion.y.pos();
352 moved
353 }
354
355 /// Advance the wheel glide / flick coast; true while the offset is
356 /// moving, so the host keeps frames coming until it settles.
357 pub fn tick(&mut self, dt: f32) -> bool {
358 self.motion.reconcile(0.0, self.scroll_y);
359 if !self.motion.is_animating() {
360 return false;
361 }
362 let moved = self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
363 self.scroll_y = self.motion.y.pos();
364 moved || self.motion.is_animating()
365 }
366
367 // ── Paint ──
368
369 /// The legacy frame, single-drawn and in the right order: rounded bg (this list ran
370 /// with `show_border = false`), scrollbar, row overlays, then the row text — the
371 /// `List` column-branch cell layout (icon column, primary/secondary sizes and tints,
372 /// char-estimate truncation, viewport-inset clip bounds) verbatim.
373 pub fn push_prims(&self, pc: &mut crate::pages::PageContent) {
374 use cce_ui::layout::RenderTarget;
375 let (x, y, w, h) = (self.x, self.y, self.w, self.h);
376 pc.rect_with_radius_corners(
377 cce_ui::color::list_bg_color(),
378 x,
379 y,
380 w,
381 h,
382 cce_ui::layout::list_corner_radius(),
383 (true, true, true, true),
384 );
385 // Recessed well like a text box: the list floor sits below the pane
386 // surface, its wall carved over the bg and row overlays. Focused, the
387 // well swaps that lighting for the accent ring (the tinted carve).
388 if self.focused {
389 pc.relief_recessed_focused(x, y, w, h, cce_ui::layout::list_corner_radius());
390 } else {
391 pc.relief_recessed(x, y, w, h, cce_ui::layout::list_corner_radius());
392 }
393
394 if self.content_h > self.viewport_h {
395 let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
396 pc.rect(cce_ui::color::scrollbar_track_color(), sb_x, track_y, sb_w, track_h);
397 pc.rect(cce_ui::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h);
398 }
399
400 let col_bounds = self.column_bounds();
401 let (_, config_size) = cce_ui::layout::list_font_parsed();
402 let primary_size = config_size;
403 let secondary_size = (config_size - 1.0).max(8.0);
404 let list_font = cce_ui::layout::list_font();
405 let font = if list_font.is_empty() { None } else { Some(list_font) };
406
407 let view_min = y + 4.0;
408 let view_max = y + self.viewport_h - 4.0;
409 let clip = Some([x, view_min, x + w, view_max]);
410
411 let fg = [230.0 / 255.0, 230.0 / 255.0, 242.0 / 255.0, 1.0];
412 let plain_fg = [178.0 / 255.0, 178.0 / 255.0, 191.0 / 255.0, 1.0];
413 let dim = [140.0 / 255.0, 140.0 / 255.0, 153.0 / 255.0, 1.0];
414
415 for (idx, row) in self.rows.iter().enumerate() {
416 let Some(draw_y) = self.get_item_draw_y(idx) else { continue };
417 let is_hovered = self.hovered_row == Some(idx);
418 let is_pressed = self.pressed_row == Some(idx);
419 let bg = if row.selected {
420 if is_pressed {
421 [0.30, 0.52, 0.78, 0.6]
422 } else if is_hovered {
423 [0.30, 0.52, 0.78, 0.5]
424 } else {
425 [0.20, 0.40, 0.65, 0.4]
426 }
427 } else if is_pressed {
428 [0.20, 0.20, 0.25, 0.25]
429 } else if is_hovered {
430 [0.20, 0.20, 0.25, 0.15]
431 } else {
432 [0.0, 0.0, 0.0, 0.0]
433 };
434 if bg[3] > 0.001 {
435 // Clamped to the viewport: `get_item_draw_y` returns partial
436 // rows, and an unclamped hover/selection quad would bleed out
437 // of the well (exact for a flat quad).
438 let qy = draw_y.max(y);
439 let qb = (draw_y + self.item_height).min(y + self.viewport_h);
440 if qb > qy {
441 pc.rect(bg, x + 2.0, qy, w - 4.0, qb - qy);
442 }
443 }
444
445 let row_fg = if row.selected { fg } else { plain_fg };
446 let row_dim = if row.selected { fg } else { dim };
447 let y_primary = cce_ui::layout::center_text_y(draw_y, self.item_height, primary_size);
448 let y_secondary = cce_ui::layout::center_text_y(draw_y, self.item_height, secondary_size);
449
450 let mut start_text_offset = 8.0;
451 if let Some(ref icon) = row.icon {
452 if !col_bounds.is_empty() {
453 push_text(pc, icon, x + col_bounds[0].0 + 12.0, y_primary, primary_size, row_fg, &font, clip);
454 start_text_offset = 32.0;
455 }
456 }
457
458 for (c_idx, cell_text) in row.cells.iter().enumerate() {
459 if c_idx >= col_bounds.len() {
460 break;
461 }
462 let (col_x, col_w) = col_bounds[c_idx];
463 if col_w <= 0.0 {
464 continue;
465 }
466 let cell_color = if c_idx == 0 { row_fg } else { row_dim };
467 let cell_y = if c_idx == 0 { y_primary } else { y_secondary };
468 let cell_size = if c_idx == 0 { primary_size } else { secondary_size };
469 let cell_draw_x = if c_idx == 0 { x + col_x + start_text_offset } else { x + col_x };
470 let max_w = if c_idx == 0 { col_w - start_text_offset - 8.0 } else { col_w - 8.0 };
471 let char_w = cell_size * 0.65;
472 let max_chars = (max_w / char_w).max(4.0) as usize;
473 let truncated = if cell_text.chars().count() > max_chars {
474 let mut s: String = cell_text.chars().take(max_chars.saturating_sub(3)).collect();
475 s.push_str("...");
476 s
477 } else {
478 cell_text.clone()
479 };
480 push_text(pc, &truncated, cell_draw_x, cell_y, cell_size, cell_color, &font, clip);
481 }
482 }
483 }
484 }
485
486 fn push_text(
487 pc: &mut crate::pages::PageContent,
488 text: &str,
489 x: f32,
490 y: f32,
491 size: f32,
492 color: [f32; 4],
493 font: &Option<String>,
494 bounds: Option<[f32; 4]>,
495 ) {
496 use cce_ui::layout::RenderTarget;
497 match font {
498 Some(f) => pc.text_with_font_and_bounds(text, x, y, size, color, f, bounds),
499 None => pc.text_with_bounds(text, x, y, size, color, bounds),
500 }
501 }
502
503 #[cfg(test)]
504 mod tests {
505 use super::*;
506 use cce_ui::widget::Justification;
507
508 fn list_with_rows(n: usize) -> RowList {
509 let mut l = RowList::new(30.0, 2.0);
510 l.set_rect(10.0, 20.0, 400.0, 200.0, 0.0);
511 l.columns = vec![
512 ListColumn { name: "Name".into(), width: ColumnWidth::Flex, justification: Justification::Left },
513 ListColumn { name: "Size".into(), width: ColumnWidth::RightOffset(120.0), justification: Justification::Left },
514 ];
515 l.rows = (0..n)
516 .map(|i| Row { cells: vec![format!("f{i}"), "1 KiB".into()], icon: Some("D".into()), selected: false })
517 .collect();
518 l.update_bounds_from_rows();
519 l
520 }
521
522 #[test]
523 fn column_bounds_flex_and_right_offset() {
524 let l = list_with_rows(1);
525 let b = l.column_bounds();
526 // RightOffset(120) claims the last 120px; Flex takes what's left of the row.
527 assert_eq!(b[1], (280.0, 120.0));
528 assert_eq!(b[0].0, 0.0);
529 assert!((b[0].1 - 280.0).abs() < 0.01);
530 }
531
532 #[test]
533 fn click_and_double_click() {
534 let mut l = list_with_rows(5);
535 let ih = l.item_height;
536 let row1_y = 20.0 + 1.0 * (ih + 2.0) + 2.0 + ih / 2.0;
537 assert!(l.mouse_input(true, 50.0, row1_y));
538 assert!(l.mouse_input(false, 50.0, row1_y));
539 assert_eq!(l.take_click(), Some(1));
540 assert!(l.take_double_click().is_none());
541 // Second click within 400ms → double.
542 l.mouse_input(true, 50.0, row1_y);
543 l.mouse_input(false, 50.0, row1_y);
544 assert_eq!(l.take_double_click(), Some(1));
545 }
546
547 #[test]
548 fn wheel_scrolls_and_scroll_into_view_clamps() {
549 let mut l = list_with_rows(50);
550 assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
551 // Two notches glide to 2 * LINE_PX; settle the motion first.
552 for _ in 0..600 {
553 if !l.tick(1.0 / 60.0) {
554 break;
555 }
556 }
557 assert_eq!(l.scroll_y, 48.0);
558 l.scroll_into_view(0);
559 assert_eq!(l.scroll_y, 2.0);
560 l.scroll_into_view(49);
561 let expect = 49.0 * (l.item_height + 2.0) + 2.0 + l.item_height - l.viewport_h;
562 assert!((l.scroll_y - expect).abs() < 0.01);
563 }
564 }