file manager
git clone https://git.lucas.co/cce-files.git
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);
+ }
+}