git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit85f98a19c222794267249a83d716fd8a9e265ce3
parent34ebdfbdb7
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 08:57
feat(scrollbars): the entry list's bar rides its centre line, behind the plate

The 4px display-only strip at the right edge becomes the DE's bar:
centred, centred_scrollbar_width() thick, pills in the shared colours,
idling under the list's translucent bg and fading in over the rows
while a scroll holds it (ScrollbarActivity). It gains a thumb drag,
taken only while raised. ListBar is the pure geometry, with two tests.

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

 src/main.rs | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 245 insertions(+), 14 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index c0190f0..b1c572d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,7 @@ use wayland_client::QueueHandle;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
     Bounds, Button, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
-    ScrollMotion, TextBox, WidgetHost, LINE_PX,
+    ScrollMotion, ScrollbarActivity, TextBox, WidgetHost, LINE_PX,
 };
 
 const LIST_W: f32 = 280.0;
@@ -606,6 +606,106 @@ async fn delete_item(
     Ok(())
 }
 
+// ── The entry list's scrollbar ────────────────────────────────────────────
+
+/// Pointer slop either side of the bar's strip, as the toolkit's bars take.
+const BAR_SLOP: f32 = 4.0;
+/// The track stops this far short of each end of the list.
+const BAR_TRACK_INSET: f32 = 4.0;
+/// The shortest thumb, as the toolkit's bars draw it.
+const BAR_MIN_THUMB: f32 = 20.0;
+
+/// The entry list's scrollbar: the DE's one design (cce-ui/CLAUDE.md, "Every
+/// scrollbar rides a centre line, behind the plate") — down the CENTRE of the
+/// list's width, over the rows, pills in the shared track and thumb colours.
+/// Pure geometry, so it is tested without a window.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ListBar {
+    x: f32,
+    w: f32,
+    track_y: f32,
+    track_h: f32,
+    thumb_y: f32,
+    thumb_h: f32,
+}
+
+impl ListBar {
+    /// The bar for a list at `list` (x, y, w, h) holding `content_h` of rows
+    /// scrolled to `scroll_y`, `w` thick; `None` when nothing overflows.
+    fn of(list: (f32, f32, f32, f32), content_h: f32, scroll_y: f32, w: f32) -> Option<Self> {
+        let (lx, ly, lw, lh) = list;
+        if content_h <= lh || lh <= 0.0 {
+            return None;
+        }
+        let track_y = ly + BAR_TRACK_INSET;
+        let track_h = (lh - 2.0 * BAR_TRACK_INSET).max(0.0);
+        let thumb_h = if track_h <= BAR_MIN_THUMB {
+            track_h
+        } else {
+            (track_h * lh / content_h).clamp(BAR_MIN_THUMB, track_h)
+        };
+        let ratio = (scroll_y / (content_h - lh)).clamp(0.0, 1.0);
+        Some(Self {
+            x: lx + (lw - w) * 0.5,
+            w,
+            track_y,
+            track_h,
+            thumb_y: track_y + ratio * (track_h - thumb_h),
+            thumb_h,
+        })
+    }
+
+    /// The bar's strip, slop included — what a pointer over it means. The
+    /// caller gates presses on the bar being RAISED: a sunk one is behind
+    /// the list's plate and a press on its lane is a press on the row.
+    fn hits(&self, px: f32, py: f32) -> bool {
+        px >= self.x - BAR_SLOP
+            && px <= self.x + self.w + BAR_SLOP
+            && py >= self.track_y
+            && py <= self.track_y + self.track_h
+    }
+
+    fn on_thumb(&self, py: f32) -> bool {
+        py >= self.thumb_y && py <= self.thumb_y + self.thumb_h
+    }
+
+    /// The scroll offset that puts the thumb's top at `thumb_top`.
+    fn scroll_for(&self, thumb_top: f32, max_scroll: f32) -> f32 {
+        let span = self.track_h - self.thumb_h;
+        if span <= 0.0 {
+            return 0.0;
+        }
+        ((thumb_top - self.track_y) / span).clamp(0.0, 1.0) * max_scroll
+    }
+
+    /// Track then thumb, as pills, their colours' alpha scaled by `alpha`:
+    /// 1 for the idle copy under the list's plate, the activity's fade for
+    /// the fore copy over the rows.
+    fn paint(&self, pc: &mut cce_ui::scene::paint::PaintCtx, alpha: f32) {
+        use cce_ui::scene::layout::Rect;
+        let a = alpha.clamp(0.0, 1.0);
+        if a <= 0.001 {
+            return;
+        }
+        let dim = |mut c: [f32; 4]| {
+            c[3] *= a;
+            c
+        };
+        let all = (true, true, true, true);
+        let track = Rect { x: self.x, y: self.track_y, width: self.w, height: self.track_h };
+        pc.rounded_rect(track, self.w.min(self.track_h) * 0.5, all, dim(cce_ui::color::scrollbar_track_color()));
+        let thumb = Rect { x: self.x, y: self.thumb_y, width: self.w, height: self.thumb_h };
+        pc.rounded_rect(thumb, self.w.min(self.thumb_h) * 0.5, all, dim(cce_ui::color::scrollbar_thumb_color()));
+    }
+}
+
+/// One frame of the bar's raise/sink: true while the frame loop must keep
+/// drawing — the latch flipped, the fade is moving, or the hold is still
+/// running (the sink has to be ticked to, or a still list never sinks).
+fn tick_bar(activity: &mut ScrollbarActivity, dt: f32, overflowing: bool, dragging: bool) -> bool {
+    activity.tick(dt, overflowing, dragging) || activity.holding()
+}
+
 // ── Application ───────────────────────────────────────────────────────────
 
 struct SecretsApp {
@@ -648,6 +748,11 @@ struct SecretsApp {
     /// by the motion on its next step.
     scroll_y: f32,
     scroll_motion: ScrollMotion,
+    /// The list's scrollbar idles behind the list's plate and a scroll
+    /// raises it ([`ListBar`]); this is its hold and fade.
+    bar_activity: ScrollbarActivity,
+    /// A thumb drag in progress: the pointer's offset from the thumb's top.
+    bar_grab: Option<f32>,
     /// List viewport (x, y, w, h), refreshed each paint for hit-testing.
     list_rect: (f32, f32, f32, f32),
     pointer: (f32, f32),
@@ -710,6 +815,24 @@ impl SecretsApp {
         (self.filtered().len() as f32 * ROW_H - self.list_rect.3).max(0.0)
     }
 
+    /// The list's scrollbar as it stands, `None` while nothing overflows.
+    fn list_scrollbar(&self) -> Option<ListBar> {
+        ListBar::of(
+            self.list_rect,
+            self.filtered().len() as f32 * ROW_H,
+            self.scroll_y,
+            cce_ui::layout::centred_scrollbar_width(),
+        )
+    }
+
+    /// A direct write to `scroll_y` (a clamp, the Escape reset) is a scroll
+    /// like any other: if it moved the list, the bar comes up.
+    fn note_scroll_from(&mut self, before: f32) {
+        if (self.scroll_y - before).abs() > 1e-3 {
+            self.bar_activity.bump();
+        }
+    }
+
     /// Advance the wheel glide / flick coast; true while the offset is moving
     /// (the frame loop keeps drawing). Hover follows the rows under the pointer.
     fn tick_scroll(&mut self, dt: f32) -> bool {
@@ -720,6 +843,8 @@ impl SecretsApp {
         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
         self.scroll_y = self.scroll_motion.y.pos();
         if moved {
+            // A glide or coast in motion keeps the bar raised.
+            self.bar_activity.bump();
             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
         }
         moved || self.scroll_motion.is_animating()
@@ -975,6 +1100,8 @@ impl Application for SecretsApp {
             otp_drawn_left: 0,
             scroll_y: 0.0,
             scroll_motion: ScrollMotion::new(),
+            bar_activity: ScrollbarActivity::new(),
+            bar_grab: None,
             // Placed by the first frame; empty until then so nothing hit-tests.
             list_rect: (0.0, 0.0, 0.0, 0.0),
             pointer: (0.0, 0.0),
@@ -1018,7 +1145,9 @@ impl Application for SecretsApp {
                 if self.selected_entry().is_none() {
                     self.selected = None;
                 }
+                let before = self.scroll_y;
                 self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+                self.note_scroll_from(before);
                 if self.syncing {
                     return;
                 }
@@ -1142,6 +1271,10 @@ impl Application for SecretsApp {
         if self.tick_scroll(dt) {
             *needs_rebuild = true;
         }
+        let overflowing = self.filtered().len() as f32 * ROW_H > self.list_rect.3;
+        if tick_bar(&mut self.bar_activity, dt, overflowing, self.bar_grab.is_some()) {
+            *needs_rebuild = true;
+        }
         if self.ensure_otp() {
             *needs_rebuild = true;
         }
@@ -1228,6 +1361,13 @@ impl Application for SecretsApp {
         let list_y = inset + SEARCH_H + gap;
         let list_h = (sh - list_y - STATUS_H - gap).max(0.0);
         self.list_rect = (inset, list_y, LIST_W, list_h);
+        // The scrollbar's idle copy, at full alpha UNDER the list's
+        // translucent plate, every frame — raised or not, since the fore copy
+        // fades in over it and dropping this at the latch would blink the bar.
+        let bar = self.list_scrollbar();
+        if let Some(bar) = bar {
+            bar.paint(&mut pc, 1.0);
+        }
         quad(&mut pc, inset, list_y, LIST_W, list_h, cce_ui::color::list_bg_color());
 
         let filtered = self.filtered();
@@ -1268,19 +1408,10 @@ impl Application for SecretsApp {
             }
         }
 
-        // Scrollbar (wheel-driven; thumb is display-only).
-        let content_h = filtered.len() as f32 * ROW_H;
-        if content_h > list_h {
-            let sb_w = 4.0;
-            // style: deliberate — the 4px thumb hugs the list's rim; a rung-wide
-            // gutter would read as a column of its own.
-            let sb_x = inset + LIST_W - sb_w - 3.0;
-            let visible_ratio = list_h / content_h;
-            let thumb_h = (list_h * visible_ratio).clamp(20.0, list_h);
-            let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
-            let thumb_y = list_y + scroll_ratio * (list_h - thumb_h);
-            quad(&mut pc, sb_x, list_y, sb_w, list_h, cce_ui::color::scrollbar_track_color());
-            quad(&mut pc, sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color());
+        // The scrollbar's fore copy, over the rows at the activity's fade:
+        // a scroll raises it out of the plate, and it sinks back once idle.
+        if let Some(bar) = bar {
+            bar.paint(&mut pc, self.bar_activity.fade());
         }
 
         // ── Right panel: detail view or the entry form ──
@@ -1423,6 +1554,17 @@ impl Application for SecretsApp {
 
     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
         self.pointer = (pos.x, pos.y);
+        let bar = self.list_scrollbar();
+        // Hover only SUSTAINS a raised bar; the activity ignores it on a sunk one.
+        self.bar_activity.set_hover(bar.is_some_and(|b| b.hits(pos.x, pos.y)));
+        if let (Some(grab), Some(bar)) = (self.bar_grab, bar) {
+            let before = self.scroll_y;
+            self.scroll_y = bar.scroll_for(pos.y - grab, self.max_scroll());
+            self.scroll_motion.y.jump_to(self.scroll_y);
+            if (self.scroll_y - before).abs() > 1e-3 {
+                *needs_rebuild = true;
+            }
+        }
         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
         let mut roots = vec![self.search_box.id()];
         roots.extend(self.buttons_mut().map(|b| b.id()));
@@ -1449,6 +1591,13 @@ impl Application for SecretsApp {
         let (lx, ly) = (pos.x, pos.y);
         let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
 
+        // A thumb drag ends wherever the button comes up; the release
+        // refreshes the hold, as a scroll does.
+        if button == MouseButton::Left && state == ElementState::Released && self.bar_grab.take().is_some() {
+            self.bar_activity.bump();
+            *needs_rebuild = true;
+        }
+
         // Buttons: propagate, then drain clicks into messages.
         let button_roots: Vec<_> = {
             let bs = self.buttons_mut();
@@ -1523,6 +1672,22 @@ impl Application for SecretsApp {
             }
         }
 
+        // The scrollbar takes a press only while RAISED: sunk, it is behind
+        // the list's plate and the press is the row's. On the thumb it grabs
+        // where it was pressed; on the track the thumb jumps under the pointer.
+        if button == MouseButton::Left && state == ElementState::Pressed && self.bar_activity.raised() {
+            if let Some(bar) = self.list_scrollbar().filter(|b| b.hits(lx, ly)) {
+                let grab = if bar.on_thumb(ly) { ly - bar.thumb_y } else { bar.thumb_h * 0.5 };
+                self.bar_grab = Some(grab);
+                self.scroll_y = bar.scroll_for(ly - grab, self.max_scroll());
+                self.scroll_motion.y.jump_to(self.scroll_y);
+                self.bar_activity.bump();
+                self.hover_row = self.row_at(lx, ly);
+                *needs_rebuild = true;
+                return None;
+            }
+        }
+
         // List selection only while browsing — the form keeps its state.
         if !self.editing() && button == MouseButton::Left && state == ElementState::Pressed {
             if let Some(row) = self.row_at(lx, ly) {
@@ -1547,6 +1712,8 @@ impl Application for SecretsApp {
         self.scroll_motion.reconcile(0.0, self.scroll_y);
         let moved = self.scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
         self.scroll_y = self.scroll_motion.y.pos();
+        // A wheel raises the bar, even one that meets the end of the list.
+        self.bar_activity.bump();
         if moved {
             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
             *needs_rebuild = true;
@@ -1565,7 +1732,9 @@ impl Application for SecretsApp {
                         self.search_box.text.clear();
                         self.search_box.edit_buffer.clear();
                         self.search_box.unfocus();
+                        let before = self.scroll_y;
                         self.scroll_y = 0.0;
+                        self.note_scroll_from(before);
                         *needs_rebuild = true;
                         return None;
                     }
@@ -1608,7 +1777,9 @@ impl Application for SecretsApp {
                 *needs_rebuild = true;
             }
         }
+        let before = self.scroll_y;
         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+        self.note_scroll_from(before);
         None
     }
 }
@@ -1653,6 +1824,66 @@ mod tests {
         assert_eq!(merged.len(), 5);
     }
 
+    #[test]
+    fn the_list_bar_rides_the_centre_line_and_only_when_it_overflows() {
+        let list = (10.0, 100.0, 280.0, 200.0);
+        assert_eq!(ListBar::of(list, 200.0, 0.0, 6.0), None, "nothing to scroll, no bar");
+
+        // 800 px of rows in a 200 px list.
+        let top = ListBar::of(list, 800.0, 0.0, 6.0).unwrap();
+        assert!((top.x + top.w * 0.5 - (10.0 + 140.0)).abs() < 1e-4, "down the list's centre line");
+        assert_eq!((top.track_y, top.track_h), (104.0, 192.0), "the track stops 4 px short of each end");
+        assert!((top.thumb_h - 48.0).abs() < 1e-4, "a quarter of the rows is a quarter of the track");
+        assert_eq!(top.thumb_y, top.track_y);
+
+        let end = ListBar::of(list, 800.0, 600.0, 6.0).unwrap();
+        assert!((end.thumb_y + end.thumb_h - (end.track_y + end.track_h)).abs() < 1e-4, "scrolled to the end");
+        assert!((end.scroll_for(end.thumb_y, 600.0) - 600.0).abs() < 1e-3);
+        assert!((top.scroll_for(top.thumb_y, 600.0)).abs() < 1e-3);
+
+        // A long list keeps the toolkit's shortest thumb.
+        let long = ListBar::of(list, 100_000.0, 0.0, 6.0).unwrap();
+        assert_eq!(long.thumb_h, BAR_MIN_THUMB);
+
+        // The strip with its slop, and nothing past the track's ends.
+        assert!(top.hits(top.x - BAR_SLOP, 150.0));
+        assert!(top.hits(top.x + top.w + BAR_SLOP, 150.0));
+        assert!(!top.hits(top.x - BAR_SLOP - 1.0, 150.0));
+        assert!(!top.hits(top.x + 1.0, top.track_y - 1.0));
+    }
+
+    #[test]
+    fn the_list_bar_keeps_frames_coming_until_it_has_sunk() {
+        let mut a = ScrollbarActivity::new();
+        // Hover never raises a sunk bar.
+        a.set_hover(true);
+        assert!(!tick_bar(&mut a, 0.016, true, false));
+        assert!(!a.raised());
+        a.set_hover(false);
+
+        // A scroll raises it, and frames keep coming until the sink has
+        // faded all the way out — then they stop.
+        a.bump();
+        let mut frames = 0;
+        while tick_bar(&mut a, 0.016, true, false) {
+            frames += 1;
+            assert!(frames < 1000, "the bar never settled");
+        }
+        assert!(frames > 0);
+        assert!(!a.raised());
+        assert_eq!(a.fade(), 0.0);
+
+        // A pointer over a RAISED bar holds it up past the hold.
+        a.bump();
+        tick_bar(&mut a, 0.016, true, false);
+        assert!(a.raised());
+        a.set_hover(true);
+        for _ in 0..200 {
+            tick_bar(&mut a, 0.016, true, false);
+        }
+        assert!(a.raised(), "hover sustains a raised bar");
+    }
+
     #[test]
     fn daemon_replies_parse() {
         assert_eq!(parse_otp_reply("otp 123456 17"), OtpReply::Code("123456".into(), 17));