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

commit4f1b55894e3a8b7a105878c7c69fa525b409c596
parente70916e5ca
authorLucas Galante <[email protected]>
date2026-07-10 10:47
feat: List/ScrollBox DISSOLVED (Phase 6z) — app-owned RowList; cce-files embedded-base-FREE

The browse List was the column-mode flavor (Flex/RightOffset columns, icon +
cells, hover/press/selection overlays, click + double-click, scroll frame,
in-frame search strip). src/row_list.rs ports all of it verbatim as app state:
column_bounds, row virtualization/hit math, the 400ms double-click, the
scrollbar (wheel, thumb grab, track jump), and the cell layout (primary/
secondary sizes and tints, char-estimate truncation, viewport-inset clip
bounds). The search box is a standalone BrowseState TextBox now — open/close
shortcuts and take_change -> SearchChanged handled app-side; the close path
returns the empty SearchChanged the legacy just_changed flag produced.

Fixes on the way:
- render_widget(List) emitted the scrollbar and row overlays BEFORE the rounded
  bg (the 6v sandwich class) — single-drawn in the right order now.
- The dissolved List no longer blocks window drags via its registered ScrollBox,
  so is_movable_backplate_at vetoes the list rect app-side — without it every
  row press became a compositor window-move and the app never saw it (press
  swallowed, only the release arrived).

Live-verified: row click select (preview + details update), double-click
navigation, breadcrumb navigation, wheel scroll with selection retained, hover
tint, item count. NOTE the auto-scroll-into-view snaps the wheel back whenever
the selected row would leave the viewport — legacy behavior, kept. Search
typing is not headlessly drivable — user spot-check pending.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/lib.rs          |   1 +
 src/main.rs         |  74 ++++++--
 src/pages/browse.rs |  92 +++++-----
 src/row_list.rs     | 486 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 582 insertions(+), 71 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 4a19d45..e513306 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod pages;
+pub mod row_list;
 pub mod services;
 pub mod util;
 
diff --git a/src/main.rs b/src/main.rs
index 906f1c7..94ba1fd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -333,7 +333,7 @@ impl FilesystemApp {
 
         self.ui_context.clear_hierarchy();
         self.browse.save_name_box.prepare_text(&mut self.font_system);
-        self.browse.list_box.prepare_text(&mut self.font_system);
+        self.browse.search_box.prepare_text(&mut self.font_system);
 
         let mut widgets = Vec::new();
         let mut texts = Vec::new();
@@ -352,7 +352,6 @@ impl FilesystemApp {
 
 
         self.browse.save_name_box.clear_children(&mut self.ui_context); self.browse.save_name_box.set_parent(None, &mut self.ui_context);
-        self.browse.list_box.clear_children(&mut self.ui_context); self.browse.list_box.set_parent(None, &mut self.ui_context);
         self.browse.breadcrumb.clear_children(&mut self.ui_context); self.browse.breadcrumb.set_parent(None, &mut self.ui_context);
         self.network.breadcrumb.clear_children(&mut self.ui_context); self.network.breadcrumb.set_parent(None, &mut self.ui_context);
         self.network.graph.clear_children(&mut self.ui_context); self.network.graph.set_parent(None, &mut self.ui_context);
@@ -800,6 +799,13 @@ impl Application for FilesystemApp {
             if self.browse_split.dragging || self.browse_split.hovered {
                 return false;
             }
+            // The dissolved List blocked window drags via its registered ScrollBox
+            // (blocks_backplate_drag); veto app-side now or every row press starts a
+            // compositor window move and the app never sees it.
+            let l = &self.browse.list;
+            if px >= l.x && px <= l.x + l.w && py >= l.y && py <= l.y + l.h {
+                return false;
+            }
         } else if self.current_page == Page::Network {
             if self.network_split.dragging || self.network_split.hovered {
                 return false;
@@ -1204,7 +1210,10 @@ impl Application for FilesystemApp {
                     changed = true;
                 }
             }
-            if self.browse.list_box.cursor_moved(pos.x, pos.y, &mut self.ui_context) {
+            if self.browse.list.cursor_moved(pos.x, pos.y) {
+                changed = true;
+            }
+            if self.browse.search_visible && self.browse.search_box.cursor_moved(pos.x, pos.y, &mut self.ui_context) {
                 changed = true;
             }
             if self.browse.breadcrumb.on_cursor_moved(pos.x, pos.y, &mut self.ui_context) {
@@ -1478,13 +1487,23 @@ impl Application for FilesystemApp {
                     self.needs_rebuild = true;
                 }
             }
-            if self.browse.list_box.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
+            if self.browse.search_visible
+                && button == MouseButton::Left
+                && self.browse.search_box.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context)
+            {
+                self.ui_context.set_focused(&mut self.browse.search_box);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
-                if let Some(idx) = self.browse.list_box.take_double_click() {
+            }
+            if button == MouseButton::Left
+                && self.browse.list.mouse_input(state == ElementState::Pressed, pos.x, pos.y)
+            {
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+                if let Some(idx) = self.browse.list.take_double_click() {
                     return Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)));
                 }
-                if let Some(idx) = self.browse.list_box.take_click() {
+                if let Some(idx) = self.browse.list.take_click() {
                     return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(idx)));
                 }
             }
@@ -1597,10 +1616,10 @@ impl Application for FilesystemApp {
         }
 
         if state == ElementState::Pressed {
-            let clicked_search = self.current_page == Page::Browse && self.browse.list_box.search_enabled && self.browse.list_box.search_visible && self.browse.list_box.search_box.hit_test(pos.x, pos.y, &self.ui_context);
+            let clicked_search = self.current_page == Page::Browse && self.browse.search_visible && self.browse.search_box.hit_test(pos.x, pos.y, &self.ui_context);
             let clicked_save_name = self.select_mode && self.current_page == Page::Browse && self.browse.save_name_box.hit_test(pos.x, pos.y, &self.ui_context);
-            if !clicked_search && self.browse.list_box.search_enabled {
-                self.browse.list_box.search_box.unfocus();
+            if !clicked_search {
+                self.browse.search_box.unfocus();
             }
             if !clicked_save_name {
                 self.browse.save_name_box.unfocus();
@@ -1650,7 +1669,7 @@ impl Application for FilesystemApp {
         }
 
         if self.current_page == Page::Browse {
-            if self.browse.list_box.mouse_wheel(delta, pos.x, pos.y, &mut self.ui_context) {
+            if self.browse.list.wheel(delta, pos.x, pos.y) {
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1702,20 +1721,37 @@ impl Application for FilesystemApp {
             }
         }
 
-        // If the search textbox is focused, forward key inputs to it
+        // The dissolved List's search keys, app-side: the open shortcut shows the strip
+        // and focuses the box; the close shortcut hides it and clears the filter (the
+        // legacy List set just_changed after clearing, which surfaced as an empty
+        // SearchChanged); anything else goes to the box while it is open.
         if self.current_page == Page::Browse {
-            let is_open_search = !self.browse.list_box.search_visible && {
+            if !self.browse.search_visible {
                 let open_key = cce_ui::color::list_open_search_key();
-                event.state == ElementState::Pressed && cce_ui::widget::match_key_shortcut(event, &open_key)
-            };
-            
-            if self.browse.list_box.search_visible || is_open_search {
-                if self.browse.list_box.keyboard_input(event, &mut self.ui_context) {
+                if cce_ui::widget::match_key_shortcut(event, &open_key) {
+                    self.browse.search_visible = true;
+                    self.browse.search_box.focus();
+                    self.ui_context.set_focused(&mut self.browse.search_box);
+                    *needs_rebuild = true;
+                    self.needs_rebuild = true;
+                    return None;
+                }
+            } else {
+                let close_key = cce_ui::color::list_close_search_key();
+                if cce_ui::widget::match_key_shortcut(event, &close_key) {
+                    self.browse.search_visible = false;
+                    self.browse.search_box.unfocus();
+                    self.ui_context.clear_focus();
+                    *needs_rebuild = true;
+                    self.needs_rebuild = true;
+                    return Some(Message::Browse(pages::browse::BrowseMessage::SearchChanged(String::new())));
+                }
+                if self.browse.search_box.keyboard_input(event, &mut self.ui_context) {
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
-                    if self.browse.list_box.search_box.take_change() {
+                    if self.browse.search_box.take_change() {
                         return Some(Message::Browse(pages::browse::BrowseMessage::SearchChanged(
-                            self.browse.list_box.search_box.text.clone()
+                            self.browse.search_box.text.clone()
                         )));
                     }
                     return None;
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index 276e8cd..c92bd6f 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -22,7 +22,9 @@ pub struct BrowseState {
     pub all_entries: Vec<DirEntry>,
     pub entries: Vec<DirEntry>,
     pub show_hidden: bool,
-    pub list_box: cce_ui::widget::List,
+    pub list: crate::row_list::RowList,
+    pub search_visible: bool,
+    pub search_box: cce_ui::widget::Adapted<cce_ui::widget::TextBox>,
     pub selected: Option<usize>,
     pub breadcrumb: Adapted<Breadcrumb>,
     pub save_name_box: cce_ui::widget::Adapted<cce_ui::widget::TextBox>,
@@ -39,37 +41,15 @@ impl Default for BrowseState {
             all_entries: Vec::new(),
             entries: Vec::new(),
             show_hidden: false,
-            list_box: cce_ui::widget::List::new(cce_ui::layout::button_height(), 2.0)
-                .with_search(true)
-                .with_columns(
-                    vec![
-                        cce_ui::widget::ListColumn {
-                            name: "Name".to_string(),
-                            width: cce_ui::widget::ColumnWidth::Flex,
-                            justification: cce_ui::widget::Justification::Left,
-                        },
-                        cce_ui::widget::ListColumn {
-                            name: "Size".to_string(),
-                            width: cce_ui::widget::ColumnWidth::RightOffset(290.0),
-                            justification: cce_ui::widget::Justification::Left,
-                        },
-                        cce_ui::widget::ListColumn {
-                            name: "Permissions".to_string(),
-                            width: cce_ui::widget::ColumnWidth::RightOffset(210.0),
-                            justification: cce_ui::widget::Justification::Left,
-                        },
-                        cce_ui::widget::ListColumn {
-                            name: "Modified".to_string(),
-                            width: cce_ui::widget::ColumnWidth::RightOffset(120.0),
-                            justification: cce_ui::widget::Justification::Left,
-                        },
-                    ],
-                ),
+            list: crate::row_list::RowList::new(cce_ui::layout::button_height(), 2.0),
+            search_visible: false,
+            search_box: cce_ui::widget::TextBox::new(String::new())
+                .with_placeholder("Search...")
+                .with_update_on_type(true),
             selected: None,
             breadcrumb,
             save_name_box: cce_ui::widget::TextBox::new(String::new()).with_max_width(None),
         };
-        state.list_box.scroll_box.show_border = false;
         state.update_breadcrumb();
         state
     }
@@ -242,10 +222,10 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
             justification: cce_ui::widget::Justification::Left,
         });
     }
-    state.list_box.columns = Some(cols);
+    state.list.columns = cols;
 
     // Populate rows
-    state.list_box.rows = state.entries.iter().enumerate().map(|(idx, entry)| {
+    state.list.rows = state.entries.iter().enumerate().map(|(idx, entry)| {
         let size_str = if entry.is_dir {
             "—".to_string()
         } else {
@@ -264,31 +244,40 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
             cells.push(entry.modified.clone());
         }
 
-        cce_ui::widget::ListRow {
+        crate::row_list::Row {
             cells,
             icon: Some(entry_icon(entry.is_dir, &entry.name).to_string()),
             selected: state.selected == Some(idx),
         }
     }).collect();
 
-    state.list_box.update_bounds_from_rows();
+    // Dissolved List (Phase 6z): scroll state, rows, and frame prims are app-owned.
+    // When the search strip is open it reserves the bottom of the frame, exactly as
+    // the legacy List::set_rect carved its scroll frame.
+    let search_h = 26.0;
+    let search_margin_y = 6.0;
+    let search_offset = if state.search_visible { search_h + 2.0 * search_margin_y } else { 0.0 };
+    state.list.set_rect(list_x, list_y, list_w, list_h, search_offset);
+    state.list.update_bounds_from_rows();
 
     // Auto-scroll to keep selection in view
     if let Some(selected_idx) = state.selected {
-        let item_height_full = state.list_box.item_height + state.list_box.item_gap;
-        let item_y = selected_idx as f32 * item_height_full + 2.0;
-        let viewport_h = state.list_box.scroll_box.viewport_h;
-        if viewport_h > 0.0 {
-            if item_y < state.list_box.scroll_box.scroll_y {
-                state.list_box.scroll_box.scroll_y = item_y;
-            } else if item_y + state.list_box.item_height > state.list_box.scroll_box.scroll_y + viewport_h {
-                state.list_box.scroll_box.scroll_y = item_y + state.list_box.item_height - viewport_h;
-            }
-        }
+        state.list.scroll_into_view(selected_idx);
+    }
+
+    state.list.push_prims(&mut pc);
+    if state.search_visible {
+        cce_ui::layout::render_widget(
+            &mut pc,
+            &mut state.search_box,
+            list_x + 8.0,
+            list_y + list_h - search_offset + search_margin_y,
+            list_w - 16.0,
+            search_h,
+            ctx,
+        );
     }
 
-    cce_ui::layout::render_widget(&mut pc, &mut state.list_box, list_x, list_y, list_w, list_h, ctx);
-
     // Count label in the bottom right corner of the scrolling list
     let count_str = format!(
         "{} items{}",
@@ -313,10 +302,10 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
 // ── Update ──────────────────────────────────────────────────────────
 
 fn apply_filters(state: &mut BrowseState) {
-    let search_query = if state.list_box.search_box.editing {
-        state.list_box.search_box.edit_buffer.to_lowercase()
+    let search_query = if state.search_box.editing {
+        state.search_box.edit_buffer.to_lowercase()
     } else {
-        state.list_box.search_box.text.to_lowercase()
+        state.search_box.text.to_lowercase()
     };
     state.entries = state
         .all_entries
@@ -336,7 +325,7 @@ fn apply_filters(state: &mut BrowseState) {
 pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::services::fs::FsRequest> {
     match msg {
         BrowseMessage::SearchChanged(q) => {
-            state.list_box.search_box.text = q;
+            state.search_box.text = q;
             apply_filters(state);
             None
         }
@@ -358,9 +347,9 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
         BrowseMessage::DirectoryLoaded(path, entries) => {
             state.current_dir = path.clone();
             state.all_entries = entries;
-            state.list_box.search_box.text.clear();
-            state.list_box.search_box.edit_buffer.clear();
-            state.list_box.search_visible = false;
+            state.search_box.text.clear();
+            state.search_box.edit_buffer.clear();
+            state.search_visible = false;
             apply_filters(state);
             state.update_breadcrumb();
             Some(crate::services::fs::FsRequest::SaveLastDir(path))
@@ -431,7 +420,6 @@ mod tests {
                     modified: String::new(),
                 })
                 .collect(),
-            list_box: cce_ui::widget::List::new(cce_ui::layout::button_height(), 2.0).with_search(true),
             ..BrowseState::default()
         }
     }
diff --git a/src/row_list.rs b/src/row_list.rs
new file mode 100644
index 0000000..7fe9bb3
--- /dev/null
+++ b/src/row_list.rs
@@ -0,0 +1,486 @@
+//! App-owned column list replacing the dissolved `List`/`ScrollBox` embedded bases
+//! (Phase 6z) — the column-mode flavor: columns (Flex/Absolute/RightOffset), rows with
+//! icon + cells, hover/press/selection overlays, click + double-click, and the scroll
+//! frame (wheel, scrollbar thumb drag / track jump). The geometry, colors, truncation,
+//! and hit math are the legacy `List` column branch verbatim; the search box that used
+//! to live inside the `List` is a standalone app widget now.
+//!
+//! One deliberate paint fix: `render_widget(List)` emitted the plain quads (scrollbar,
+//! row overlays) BEFORE the rounded background, washing them under the translucent bg —
+//! the same sandwich the settings lists had (Phase 6v). `push_prims` draws bg first.
+
+use cce_ui::widget::{ColumnWidth, ListColumn, MouseScrollDelta};
+
+#[derive(Debug, Clone)]
+pub struct Row {
+    pub cells: Vec<String>,
+    pub icon: Option<String>,
+    pub selected: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct RowList {
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    /// Rows viewport: the full rect minus the in-frame search strip when it is open.
+    pub viewport_h: f32,
+    /// Row height with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
+    pub item_height: f32,
+    pub item_gap: f32,
+    pub scroll_y: f32,
+    pub content_h: f32,
+    pub columns: Vec<ListColumn>,
+    pub rows: Vec<Row>,
+    pub hovered_row: Option<usize>,
+    pub pressed_row: Option<usize>,
+    clicked_row: Option<usize>,
+    double_clicked_row: Option<usize>,
+    last_click_time: Option<std::time::Instant>,
+    pub dragging: bool,
+    drag_offset_y: f32,
+}
+
+impl RowList {
+    pub fn new(item_height: f32, item_gap: f32) -> Self {
+        let (_, font_size) = cce_ui::layout::list_font_parsed();
+        Self {
+            x: 0.0,
+            y: 0.0,
+            w: 0.0,
+            h: 0.0,
+            viewport_h: 0.0,
+            item_height: item_height.max(font_size + 14.0),
+            item_gap,
+            scroll_y: 0.0,
+            content_h: 0.0,
+            columns: Vec::new(),
+            rows: Vec::new(),
+            hovered_row: None,
+            pressed_row: None,
+            clicked_row: None,
+            double_clicked_row: None,
+            last_click_time: None,
+            dragging: false,
+            drag_offset_y: 0.0,
+        }
+    }
+
+    /// `search_offset` shrinks the rows viewport from the bottom (the legacy
+    /// `List::set_rect` search reservation); the background still covers the full rect.
+    pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32, search_offset: f32) {
+        self.x = x;
+        self.y = y;
+        self.w = w;
+        self.h = h;
+        self.viewport_h = (h - search_offset).max(0.0);
+    }
+
+    /// `List::update_bounds_from_rows`: content height from the row count, scroll clamped.
+    pub fn update_bounds_from_rows(&mut self) {
+        self.content_h = self.rows.len() as f32 * (self.item_height + self.item_gap) + 4.0;
+        self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+    }
+
+    fn max_scroll(&self) -> f32 {
+        (self.content_h - self.viewport_h).max(0.0)
+    }
+
+    /// Keep `selected_idx`'s row inside the viewport (the browse view's auto-scroll).
+    pub fn scroll_into_view(&mut self, selected_idx: usize) {
+        let item_y = selected_idx as f32 * (self.item_height + self.item_gap) + 2.0;
+        if self.viewport_h > 0.0 {
+            if item_y < self.scroll_y {
+                self.scroll_y = item_y;
+            } else if item_y + self.item_height > self.scroll_y + self.viewport_h {
+                self.scroll_y = item_y + self.item_height - self.viewport_h;
+            }
+        }
+    }
+
+    fn hit(&self, px: f32, py: f32) -> bool {
+        px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
+    }
+
+    /// `ScrollBox::get_item_draw_y` over the row index (rows fully visible only).
+    fn get_item_draw_y(&self, idx: usize) -> Option<f32> {
+        let virtual_y = idx as f32 * (self.item_height + self.item_gap) + 2.0;
+        let draw_y = self.y + virtual_y - self.scroll_y;
+        if draw_y >= self.y - 1.0 && draw_y + self.item_height <= self.y + self.viewport_h + 1.0 {
+            Some(draw_y)
+        } else {
+            None
+        }
+    }
+
+    fn row_at(&self, px: f32, py: f32) -> Option<usize> {
+        for idx in 0..self.rows.len() {
+            if let Some(draw_y) = self.get_item_draw_y(idx) {
+                if px >= self.x + 2.0 && px <= self.x + self.w - 2.0 && py >= draw_y && py <= draw_y + self.item_height {
+                    return Some(idx);
+                }
+            }
+        }
+        None
+    }
+
+    /// `List::get_column_bounds`, verbatim: per-column (x-offset, width) for our width.
+    pub fn column_bounds(&self) -> Vec<(f32, f32)> {
+        let cols = &self.columns;
+        let list_w = self.w;
+        let mut bounds = vec![(0.0, 0.0); cols.len()];
+        let mut flex_indices = Vec::new();
+        let mut reserved_width = 0.0;
+
+        for (i, col) in cols.iter().enumerate() {
+            match col.width {
+                ColumnWidth::Absolute(w) => {
+                    bounds[i] = (0.0, w);
+                    reserved_width += w;
+                }
+                ColumnWidth::RightOffset(offset) => {
+                    let x = list_w - offset;
+                    let mut next_x = list_w;
+                    for j in (i + 1)..cols.len() {
+                        if let ColumnWidth::RightOffset(o) = cols[j].width {
+                            next_x = list_w - o;
+                            break;
+                        }
+                    }
+                    bounds[i] = (x, (next_x - x).max(0.0));
+                }
+                ColumnWidth::Flex => flex_indices.push(i),
+            }
+        }
+
+        let current_x = 0.0;
+        let mut right_boundary = list_w;
+        for col in cols.iter() {
+            if let ColumnWidth::RightOffset(offset) = col.width {
+                if list_w - offset < right_boundary {
+                    right_boundary = list_w - offset;
+                }
+            }
+        }
+        let left_space = (right_boundary - current_x).max(0.0);
+        let flex_share = if !flex_indices.is_empty() {
+            (left_space - reserved_width).max(0.0) / flex_indices.len() as f32
+        } else {
+            0.0
+        };
+
+        let mut cx = current_x;
+        for (i, col) in cols.iter().enumerate() {
+            match col.width {
+                ColumnWidth::Absolute(w) => {
+                    bounds[i] = (cx, w);
+                    cx += w;
+                }
+                ColumnWidth::Flex => {
+                    bounds[i] = (cx, flex_share);
+                    cx += flex_share;
+                }
+                ColumnWidth::RightOffset(_) => {}
+            }
+        }
+        bounds
+    }
+
+    pub fn take_click(&mut self) -> Option<usize> {
+        self.clicked_row.take()
+    }
+
+    pub fn take_double_click(&mut self) -> Option<usize> {
+        self.double_clicked_row.take()
+    }
+
+    // ── Scrollbar (`ScrollBox` geometry, verbatim) ──
+
+    fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
+        let sb_w = cce_ui::layout::scrollbar_width();
+        let sb_x = self.x + self.w - sb_w - 4.0;
+        let track_h = self.viewport_h - 8.0;
+        let track_y = self.y + 4.0;
+        let visible_ratio = self.viewport_h / self.content_h.max(1.0);
+        let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
+        let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
+        let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
+        (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
+    }
+
+    fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
+        if self.content_h <= self.viewport_h {
+            return false;
+        }
+        let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
+        px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
+    }
+
+    // ── Input ──
+
+    /// Scrollbar drag + row hover (`List::on_cursor_moved` + `ScrollBox` drag).
+    pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        let mut changed = false;
+        if self.dragging {
+            let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
+            let target = py - self.drag_offset_y;
+            let ratio = if track_h - thumb_h > 0.0 {
+                ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
+            } else {
+                0.0
+            };
+            let old = self.scroll_y;
+            self.scroll_y = ratio * self.max_scroll();
+            if (self.scroll_y - old).abs() > 0.01 {
+                changed = true;
+            }
+        }
+        let new_hovered = self.row_at(px, py);
+        if new_hovered != self.hovered_row {
+            self.hovered_row = new_hovered;
+            changed = true;
+        }
+        changed
+    }
+
+    /// `List::mouse_input` for left press/release: scrollbar thumb grab / track jump,
+    /// then row press → click (+ double-click within 400ms). Returns handled.
+    pub fn mouse_input(&mut self, pressed: bool, px: f32, py: f32) -> bool {
+        if pressed {
+            if self.hit_scrollbar(px, py) {
+                self.dragging = true;
+                let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+                let click_offset = py - thumb_y;
+                if click_offset >= 0.0 && click_offset <= thumb_h {
+                    self.drag_offset_y = click_offset;
+                } else {
+                    self.drag_offset_y = thumb_h / 2.0;
+                    let target = py - self.drag_offset_y;
+                    let ratio = if track_h - thumb_h > 0.0 {
+                        ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
+                    } else {
+                        0.0
+                    };
+                    self.scroll_y = ratio * self.max_scroll();
+                }
+                return true;
+            }
+            self.dragging = false;
+            if let Some(idx) = self.row_at(px, py) {
+                self.pressed_row = Some(idx);
+                return true;
+            }
+            false
+        } else {
+            let was_dragging = std::mem::take(&mut self.dragging);
+            let mut clicked = false;
+            if let Some(idx) = self.row_at(px, py) {
+                if self.pressed_row == Some(idx) {
+                    let now = std::time::Instant::now();
+                    if let Some(last) = self.last_click_time {
+                        if now.duration_since(last) < std::time::Duration::from_millis(400) {
+                            self.double_clicked_row = Some(idx);
+                        }
+                    }
+                    self.last_click_time = Some(now);
+                    self.clicked_row = Some(idx);
+                    clicked = true;
+                }
+            }
+            self.pressed_row = None;
+            clicked || was_dragging
+        }
+    }
+
+    /// Hit-scoped wheel (`ScrollBox::mouse_wheel`).
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
+        if !self.hit(px, py) {
+            return false;
+        }
+        let dy = match delta {
+            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+        };
+        let old = self.scroll_y;
+        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
+        (self.scroll_y - old).abs() > 0.01
+    }
+
+    // ── Paint ──
+
+    /// The legacy frame, single-drawn and in the right order: rounded bg (this list ran
+    /// with `show_border = false`), scrollbar, row overlays, then the row text — the
+    /// `List` column-branch cell layout (icon column, primary/secondary sizes and tints,
+    /// char-estimate truncation, viewport-inset clip bounds) verbatim.
+    pub fn push_prims(&self, pc: &mut crate::pages::PageContent) {
+        use cce_ui::layout::RenderTarget;
+        let (x, y, w, h) = (self.x, self.y, self.w, self.h);
+        pc.rect_with_radius_corners(
+            cce_ui::color::list_bg_color(),
+            x,
+            y,
+            w,
+            h,
+            cce_ui::layout::list_corner_radius(),
+            (true, true, true, true),
+        );
+
+        if self.content_h > self.viewport_h {
+            let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+            pc.rect(cce_ui::color::scrollbar_track_color(), sb_x, track_y, sb_w, track_h);
+            pc.rect(cce_ui::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h);
+        }
+
+        let col_bounds = self.column_bounds();
+        let (_, config_size) = cce_ui::layout::list_font_parsed();
+        let primary_size = config_size;
+        let secondary_size = (config_size - 1.0).max(8.0);
+        let list_font = cce_ui::layout::list_font();
+        let font = if list_font.is_empty() { None } else { Some(list_font) };
+
+        let view_min = y + 4.0;
+        let view_max = y + self.viewport_h - 4.0;
+        let clip = Some([x, view_min, x + w, view_max]);
+
+        let fg = [230.0 / 255.0, 230.0 / 255.0, 242.0 / 255.0, 1.0];
+        let plain_fg = [178.0 / 255.0, 178.0 / 255.0, 191.0 / 255.0, 1.0];
+        let dim = [140.0 / 255.0, 140.0 / 255.0, 153.0 / 255.0, 1.0];
+
+        for (idx, row) in self.rows.iter().enumerate() {
+            let Some(draw_y) = self.get_item_draw_y(idx) else { continue };
+            let is_hovered = self.hovered_row == Some(idx);
+            let is_pressed = self.pressed_row == Some(idx);
+            let bg = if row.selected {
+                if is_pressed {
+                    [0.30, 0.52, 0.78, 0.6]
+                } else if is_hovered {
+                    [0.30, 0.52, 0.78, 0.5]
+                } else {
+                    [0.20, 0.40, 0.65, 0.4]
+                }
+            } else if is_pressed {
+                [0.20, 0.20, 0.25, 0.25]
+            } else if is_hovered {
+                [0.20, 0.20, 0.25, 0.15]
+            } else {
+                [0.0, 0.0, 0.0, 0.0]
+            };
+            if bg[3] > 0.001 {
+                pc.rect(bg, x + 2.0, draw_y, w - 4.0, self.item_height);
+            }
+
+            let row_fg = if row.selected { fg } else { plain_fg };
+            let row_dim = if row.selected { fg } else { dim };
+            let y_primary = cce_ui::layout::center_text_y(draw_y, self.item_height, primary_size);
+            let y_secondary = cce_ui::layout::center_text_y(draw_y, self.item_height, secondary_size);
+
+            let mut start_text_offset = 8.0;
+            if let Some(ref icon) = row.icon {
+                if !col_bounds.is_empty() {
+                    push_text(pc, icon, x + col_bounds[0].0 + 12.0, y_primary, primary_size, row_fg, &font, clip);
+                    start_text_offset = 32.0;
+                }
+            }
+
+            for (c_idx, cell_text) in row.cells.iter().enumerate() {
+                if c_idx >= col_bounds.len() {
+                    break;
+                }
+                let (col_x, col_w) = col_bounds[c_idx];
+                if col_w <= 0.0 {
+                    continue;
+                }
+                let cell_color = if c_idx == 0 { row_fg } else { row_dim };
+                let cell_y = if c_idx == 0 { y_primary } else { y_secondary };
+                let cell_size = if c_idx == 0 { primary_size } else { secondary_size };
+                let cell_draw_x = if c_idx == 0 { x + col_x + start_text_offset } else { x + col_x };
+                let max_w = if c_idx == 0 { col_w - start_text_offset - 8.0 } else { col_w - 8.0 };
+                let char_w = cell_size * 0.65;
+                let max_chars = (max_w / char_w).max(4.0) as usize;
+                let truncated = if cell_text.chars().count() > max_chars {
+                    let mut s: String = cell_text.chars().take(max_chars.saturating_sub(3)).collect();
+                    s.push_str("...");
+                    s
+                } else {
+                    cell_text.clone()
+                };
+                push_text(pc, &truncated, cell_draw_x, cell_y, cell_size, cell_color, &font, clip);
+            }
+        }
+    }
+}
+
+fn push_text(
+    pc: &mut crate::pages::PageContent,
+    text: &str,
+    x: f32,
+    y: f32,
+    size: f32,
+    color: [f32; 4],
+    font: &Option<String>,
+    bounds: Option<[f32; 4]>,
+) {
+    use cce_ui::layout::RenderTarget;
+    match font {
+        Some(f) => pc.text_with_font_and_bounds(text, x, y, size, color, f, bounds),
+        None => pc.text_with_bounds(text, x, y, size, color, bounds),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use cce_ui::widget::Justification;
+
+    fn list_with_rows(n: usize) -> RowList {
+        let mut l = RowList::new(30.0, 2.0);
+        l.set_rect(10.0, 20.0, 400.0, 200.0, 0.0);
+        l.columns = vec![
+            ListColumn { name: "Name".into(), width: ColumnWidth::Flex, justification: Justification::Left },
+            ListColumn { name: "Size".into(), width: ColumnWidth::RightOffset(120.0), justification: Justification::Left },
+        ];
+        l.rows = (0..n)
+            .map(|i| Row { cells: vec![format!("f{i}"), "1 KiB".into()], icon: Some("D".into()), selected: false })
+            .collect();
+        l.update_bounds_from_rows();
+        l
+    }
+
+    #[test]
+    fn column_bounds_flex_and_right_offset() {
+        let l = list_with_rows(1);
+        let b = l.column_bounds();
+        // RightOffset(120) claims the last 120px; Flex takes what's left of the row.
+        assert_eq!(b[1], (280.0, 120.0));
+        assert_eq!(b[0].0, 0.0);
+        assert!((b[0].1 - 280.0).abs() < 0.01);
+    }
+
+    #[test]
+    fn click_and_double_click() {
+        let mut l = list_with_rows(5);
+        let ih = l.item_height;
+        let row1_y = 20.0 + 1.0 * (ih + 2.0) + 2.0 + ih / 2.0;
+        assert!(l.mouse_input(true, 50.0, row1_y));
+        assert!(l.mouse_input(false, 50.0, row1_y));
+        assert_eq!(l.take_click(), Some(1));
+        assert!(l.take_double_click().is_none());
+        // Second click within 400ms → double.
+        l.mouse_input(true, 50.0, row1_y);
+        l.mouse_input(false, 50.0, row1_y);
+        assert_eq!(l.take_double_click(), Some(1));
+    }
+
+    #[test]
+    fn wheel_scrolls_and_scroll_into_view_clamps() {
+        let mut l = list_with_rows(50);
+        assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+        assert_eq!(l.scroll_y, 48.0);
+        l.scroll_into_view(0);
+        assert_eq!(l.scroll_y, 2.0);
+        l.scroll_into_view(49);
+        let expect = 49.0 * (l.item_height + 2.0) + 2.0 + l.item_height - l.viewport_h;
+        assert!((l.scroll_y - expect).abs() < 0.01);
+    }
+}