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