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

commit36e93c28e8b0757c4c306edccccaf97c7ab4797f
parent8a3ac7b7a4
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 08:57
feat(scrollbars): the browse list's bar rides its centre line, behind the well

The bar is centred, centred_scrollbar_width() thick, in pills, and
idles under the list's translucent bg every frame with a fore copy
faded in after the rows (ScrollbarActivity). A wheel, a glide or coast,
scroll_into_view and a thumb drag raise it; a pointer over a raised bar
holds it; sunk it takes no press, so its lane is the rows'.
the_scrollbar_rides_the_centre_and_sinks_until_scrolled is the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |   3 +
 src/row_list.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 195 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3143901..095d15c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,6 +51,9 @@ There are no composite container widgets. Every widget is owned outright by the
 - **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
 The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`). The dropdown's labels name the *visualization* ("List"/"Graph"/"Space") and are a separate list from `Page::label()` ("Browse"/"Network"/"Space") — but **its order must track `Page::ALL`**, because the selected index is indexed straight into it.
 
+### The list's scrollbar rides its centre line, behind the well
+Browse's row list (`row_list.rs`, `RowList`) follows the DE's one scrollbar rule (cce-ui's CLAUDE.md, "Every scrollbar rides a centre line, behind the plate"; since 2026-10-06 — it was a flat bar at the right edge, always on top). The bar is a pair of pills down the middle of the list's width, `centred_scrollbar_width()` thick, over the rows: no column reserves a lane for it. A `ScrollbarActivity` decides its depth. Sunk, it is drawn before the well's translucent `list_bg_color` fill, which dims it, and it takes no press: a press on its lane goes to the row under it. A wheel, a glide or coast in motion, `scroll_into_view` (how keyboard navigation scrolls the list) and a thumb drag's release raise it, and a pointer over a raised bar holds it up. `RowList::tick`, which the app's `tick` already runs for the glide, ticks the activity too and returns true while the hold or the fade is running, so frames keep coming until the bar has sunk. `push_prims` draws the idle copy at full alpha every frame and the fore copy after the row overlays at `fade()`. That order holds because the list's prims go into the PAGE's `PageContent`, whose rects are replayed in call order (only `plain_pc` is partitioned by radius — see the note at the top of `row_list.rs`). A part's icons and reliefs are replayed after all its rects, so the row glyphs and the well's wall still draw over the fore copy. `the_scrollbar_rides_the_centre_and_sinks_until_scrolled` is the test.
+
 ### The Space treemap
 Unlike the other two pages, Space needs data no other page has: the recursive size of everything below the current directory. `services/scan.rs` walks it on the FsService's blocking pool (`FsRequest::ScanTree`), never following symlinks and never crossing a device boundary — the latter is what keeps a scan of `/` out of `/proc`, `/sys`, and mounted drives. Progress is reported every 150 ms; the finished tree arrives as `SpaceMessage::Scanned`.
 
diff --git a/src/row_list.rs b/src/row_list.rs
index b0c2cfa..8c9512e 100644
--- a/src/row_list.rs
+++ b/src/row_list.rs
@@ -18,8 +18,15 @@
 //! it look like the natural thing to do — would sort the bg back after them and
 //! reinstate the exact Phase 6v sandwich this note describes. It would also be silent:
 //! the bg is translucent, so the overlays wash out rather than disappear.
+//!
+//! The scrollbar leans on the same call order, twice (the DE's centre-line rule, see
+//! cce-ui's CLAUDE.md "Every scrollbar rides a centre line, behind the plate"): its
+//! IDLE copy goes out before the bg, so the translucent well dims it, and its FORE
+//! copy after the row overlays. Both are pills (rounded), so even a radius partition
+//! would keep the two copies either side of the rounded bg — but it would still sink
+//! the radius-0 row overlays under the bg, so the note above stands.
 
-use cce_ui::widget::{Bounds, Justification, MouseScrollDelta, ScrollMotion, LINE_PX};
+use cce_ui::widget::{Bounds, Justification, MouseScrollDelta, ScrollMotion, ScrollbarActivity, LINE_PX};
 
 /// Column sizing (moved here with the cce-ui `List` deletion — RowList is the only
 /// remaining consumer of the column model).
@@ -72,6 +79,9 @@ pub struct RowList {
     last_click_time: Option<std::time::Instant>,
     pub dragging: bool,
     drag_offset_y: f32,
+    /// The scrollbar's raise/sink latch and fade: it idles behind the well's
+    /// translucent bg and takes no press there, until a scroll raises it.
+    activity: ScrollbarActivity,
     /// Pointer focus (the app's well-focus tracking): the well renders as the
     /// tinted carve — accent ring replacing the relief lighting.
     pub focused: bool,
@@ -100,6 +110,7 @@ impl RowList {
             last_click_time: None,
             dragging: false,
             drag_offset_y: 0.0,
+            activity: ScrollbarActivity::new(),
             focused: false,
         }
     }
@@ -124,9 +135,27 @@ impl RowList {
         (self.content_h - self.viewport_h).max(0.0)
     }
 
-    /// Keep `selected_idx`'s row inside the viewport (the browse view's auto-scroll).
+    fn overflowing(&self) -> bool {
+        self.content_h > self.viewport_h
+    }
+
+    /// A scroll happened: refresh the hold and latch at once, so the bar is
+    /// up (and hittable) in the same frame rather than a tick later.
+    fn raise(&mut self) {
+        self.activity.bump();
+        self.activity.recompute(self.overflowing(), self.dragging);
+    }
+
+    /// Whether the scrollbar is raised in front of the rows and takes presses.
+    pub fn scrollbar_raised(&self) -> bool {
+        self.activity.raised()
+    }
+
+    /// Keep `selected_idx`'s row inside the viewport (the browse view's auto-scroll —
+    /// what keyboard navigation scrolls the list through). A move raises the bar.
     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;
+        let old = self.scroll_y;
         if self.viewport_h > 0.0 {
             if item_y < self.scroll_y {
                 self.scroll_y = item_y;
@@ -134,6 +163,9 @@ impl RowList {
                 self.scroll_y = item_y + self.item_height - self.viewport_h;
             }
         }
+        if (self.scroll_y - old).abs() > 0.01 {
+            self.raise();
+        }
     }
 
     /// Whether (px, py) is inside the list's full rect — public for the app's
@@ -245,11 +277,13 @@ impl RowList {
         self.double_clicked_row.take()
     }
 
-    // ── Scrollbar (`ScrollBox` geometry, verbatim) ──
+    // ── Scrollbar: the DE's centre-line bar (cce-ui's sink-behind `ScrollRegion`) ──
 
+    /// (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h): centred on the list's
+    /// width, over the rows — no column reserves a lane for it.
     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 sb_w = cce_ui::layout::centred_scrollbar_width();
+        let sb_x = self.x + (self.w - sb_w) * 0.5;
         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);
@@ -259,8 +293,10 @@ impl RowList {
         (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
     }
 
+    /// A press or hover on the bar. A SUNK bar is behind the well's bg and
+    /// is not hit: a press on its lane is a press on the row under it.
     fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
-        if self.content_h <= self.viewport_h {
+        if !self.overflowing() || !self.activity.raised() {
             return false;
         }
         let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
@@ -272,6 +308,9 @@ impl RowList {
     /// 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;
+        // Gated on raised (`hit_scrollbar`): hover holds a raised bar up and
+        // never raises a sunk one.
+        self.activity.set_hover(self.hit_scrollbar(px, py));
         if self.dragging {
             let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
             let target = py - self.drag_offset_y;
@@ -324,6 +363,10 @@ impl RowList {
             false
         } else {
             let was_dragging = std::mem::take(&mut self.dragging);
+            if was_dragging {
+                // The hold starts at the release, so the bar lingers.
+                self.activity.bump();
+            }
             let mut clicked = false;
             if let Some(idx) = self.row_at(px, py) {
                 if self.pressed_row == Some(idx) {
@@ -353,30 +396,79 @@ impl RowList {
         self.motion.reconcile(0.0, self.scroll_y);
         let moved = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
         self.scroll_y = self.motion.y.pos();
+        if moved {
+            self.raise();
+        }
         moved
     }
 
-    /// Advance the wheel glide / flick coast; true while the offset is
-    /// moving, so the host keeps frames coming until it settles.
+    /// Advance the wheel glide / flick coast and the scrollbar's raise/sink;
+    /// true while the offset is moving, the bar's hold is running or its fade
+    /// is moving, so the host keeps frames coming until all of it settles.
     pub fn tick(&mut self, dt: f32) -> bool {
         self.motion.reconcile(0.0, self.scroll_y);
-        if !self.motion.is_animating() {
-            return false;
+        let mut moved = false;
+        if self.motion.is_animating() {
+            moved = self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
+            self.scroll_y = self.motion.y.pos();
+            if moved {
+                // A glide or coast in motion is a scroll: it keeps the bar up.
+                self.activity.bump();
+            }
         }
-        let moved = self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
-        self.scroll_y = self.motion.y.pos();
-        moved || self.motion.is_animating()
+        let animating = self.motion.is_animating();
+        let holding = self.activity.holding();
+        let flipped = self.activity.tick(dt, self.overflowing(), self.dragging);
+        moved || animating || holding || flipped
+    }
+
+    /// The pill track and thumb at `alpha` (track/thumb colours scaled).
+    fn push_scrollbar(&self, pc: &mut crate::pages::PageContent, alpha: f32) {
+        use cce_ui::layout::RenderTarget;
+        let a = alpha.clamp(0.0, 1.0);
+        if !self.overflowing() || a <= 0.001 {
+            return;
+        }
+        let dim = |mut c: [f32; 4]| {
+            c[3] *= a;
+            c
+        };
+        let all = (true, true, true, true);
+        let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+        pc.rect_with_radius_corners(
+            dim(cce_ui::color::scrollbar_track_color()),
+            sb_x,
+            track_y,
+            sb_w,
+            track_h,
+            sb_w.min(track_h) * 0.5,
+            all,
+        );
+        pc.rect_with_radius_corners(
+            dim(cce_ui::color::scrollbar_thumb_color()),
+            sb_x,
+            thumb_y,
+            sb_w,
+            thumb_h,
+            sb_w.min(thumb_h) * 0.5,
+            all,
+        );
     }
 
     // ── 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.
+    /// The legacy frame, single-drawn and in the right order: the scrollbar's idle copy,
+    /// the rounded bg (this list ran with `show_border = false`), row overlays, the
+    /// scrollbar's fore copy, 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);
+        // The idle copy, at full alpha and every frame, raised or not: the
+        // translucent bg over it is what sinks it, and the fore copy fades in
+        // over it, so dropping it at the latch would blink the bar.
+        self.push_scrollbar(pc, 1.0);
         pc.rect_with_radius_corners(
             cce_ui::color::list_bg_color(),
             x,
@@ -395,11 +487,6 @@ impl RowList {
             pc.relief_recessed(x, y, w, h, cce_ui::layout::list_corner_radius());
         }
 
-        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();
@@ -487,6 +574,12 @@ impl RowList {
                 push_text(pc, &truncated, cell_draw_x, cell_y, cell_size, cell_color, &font, clip);
             }
         }
+
+        // The fore copy, after the row overlays, at the fade: it keeps drawing
+        // all the way out rather than stopping at the latch. (A part's icons and
+        // reliefs are emitted after all its rects, and text after everything,
+        // so the row glyphs, the well's wall and the labels still land over it.)
+        self.push_scrollbar(pc, self.activity.fade());
     }
 }
 
@@ -568,4 +661,81 @@ mod tests {
         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);
     }
+
+    #[test]
+    fn the_scrollbar_rides_the_centre_and_sinks_until_scrolled() {
+        let mut l = list_with_rows(50);
+        let ih = l.item_height;
+        // Centred on the list's width (x 10, w 400), centred-bar thick.
+        let (sb_x, track_y, sb_w, _, _, _) = l.scrollbar_geom();
+        assert!((sb_x + sb_w * 0.5 - 210.0).abs() < 0.01);
+        assert!((sb_w - cce_ui::layout::centred_scrollbar_width()).abs() < 0.01);
+        let lane_x = sb_x + sb_w * 0.5;
+
+        // Sunk: a press on the lane is a press on the row under it.
+        assert!(!l.scrollbar_raised());
+        let row1_y = 20.0 + 1.0 * (ih + 2.0) + 2.0 + ih / 2.0;
+        assert!(l.mouse_input(true, lane_x, row1_y));
+        assert!(!l.dragging);
+        assert!(l.mouse_input(false, lane_x, row1_y));
+        assert_eq!(l.take_click(), Some(1));
+
+        // Sunk, the idle copy is drawn before the bg and the fore copy not
+        // at all (fade 0); both pills.
+        let mut pc = crate::pages::PageContent::new();
+        l.push_prims(&mut pc);
+        let bg = cce_ui::color::list_bg_color();
+        let bg_at = pc.rects.iter().position(|r| r.0 == bg && r.1 == l.x).expect("bg");
+        let bars: Vec<usize> = (0..pc.rects.len()).filter(|&i| pc.rects[i].1 == sb_x).collect();
+        assert_eq!(bars.len(), 2, "track and thumb, idle copy only");
+        assert!(bars.iter().all(|&i| i < bg_at && pc.rects[i].5 > 0.0));
+
+        // A wheel raises it; the fade brings the fore copy in after the rows.
+        assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+        assert!(l.scrollbar_raised());
+        for _ in 0..30 {
+            l.tick(1.0 / 60.0);
+        }
+        assert!(l.activity.fade() > 0.99);
+        let mut pc = crate::pages::PageContent::new();
+        l.push_prims(&mut pc);
+        let bars: Vec<usize> = (0..pc.rects.len()).filter(|&i| pc.rects[i].1 == sb_x).collect();
+        assert_eq!(bars.len(), 4, "idle copy and fore copy");
+        assert_eq!(bars[2], pc.rects.len() - 2, "the fore copy is the last thing drawn");
+
+        // Raised, a press on the thumb grabs it, not the row under it.
+        let (_, _, _, _, thumb_y, thumb_h) = l.scrollbar_geom();
+        assert!(l.mouse_input(true, lane_x, thumb_y + thumb_h * 0.5));
+        assert!(l.dragging);
+        assert!(l.pressed_row.is_none());
+        l.mouse_input(false, lane_x, thumb_y + thumb_h * 0.5);
+        assert!(!l.dragging);
+
+        // A pointer over the raised bar holds it up past the hold…
+        l.cursor_moved(lane_x, track_y + 10.0);
+        for _ in 0..180 {
+            l.tick(1.0 / 60.0);
+        }
+        assert!(l.scrollbar_raised(), "hover holds a raised bar");
+
+        // …and once it leaves, the bar sinks and its fore copy fades out.
+        l.cursor_moved(50.0, 50.0);
+        let mut ticked_while_sinking = false;
+        for _ in 0..180 {
+            ticked_while_sinking |= l.tick(1.0 / 60.0);
+        }
+        assert!(ticked_while_sinking, "the hold and the fade keep frames coming");
+        assert!(!l.scrollbar_raised());
+        assert_eq!(l.activity.fade(), 0.0);
+        assert!(!l.tick(1.0 / 60.0), "settled: no more frames");
+
+        // Hover never raises a sunk bar.
+        l.cursor_moved(lane_x, track_y + 10.0);
+        l.tick(1.0 / 60.0);
+        assert!(!l.scrollbar_raised());
+
+        // A programmatic scroll (keyboard navigation's scroll_into_view) raises it.
+        l.scroll_into_view(49);
+        assert!(l.scrollbar_raised());
+    }
 }