git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commit2f7d208410fce9c37059e9cce629c1738d0037db
parent2a214f1d5b
authorLucas Galante <[email protected]>
date2026-09-01 12:49
scrollbars: designer raise/sink treatment on both panes

The email list adopts the toolkit ScrollRegion's sink-behind mode: idle
the bar sinks under the list's translucent bg fill (dimly visible
through it), a scroll raises it over the cards, and it sinks back 0.7s
after the last activity; stood off the list edge by the DE scrollbar
inset. The hand-rolled detail-pane bar gets the same hysteresis via the
shared ScrollbarActivity — the window bg behind it is opaque, so its
sunk state is simply hidden (an overlay bar that appears on scroll) —
plus the page-bar geometry (1.6x width, inset from the window edge).

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

 src/main.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 61 insertions(+), 7 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 0efc209..4a2eca2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -254,6 +254,10 @@ struct ClearEmailApp {
     detail_hovered: bool,
     body_sb_dragging: bool,
     body_sb_drag_offset: f32,
+    /// Raise/sink hysteresis for the detail-pane bar (shared toolkit type).
+    /// The window bg behind the pane is opaque, so "sunk" here is fully
+    /// hidden — an overlay bar that appears on scroll and idles away.
+    body_sb_activity: cce_ui::widget::ScrollbarActivity,
 
     // HTML mail view (feature `wpe`). The host boots at startup (cheap: no
     // WebKit processes until the first message renders); the fetched HTML is
@@ -3304,8 +3308,10 @@ impl ClearEmailApp {
         if max_scroll <= 0.0 {
             return None;
         }
-        let sb_w = cce_ui::layout::scrollbar_width();
-        let sb_x = w - sb_w - 4.0;
+        // Page-level bar geometry (the settings page look): the DE width
+        // widened, stood off the window's right edge by the configured inset.
+        let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
+        let sb_x = w - sb_w - cce_ui::layout::scrollbar_inset();
         let thumb_h = (body_h * body_h / self.body_content_h).clamp(20.0, body_h);
         let thumb_y = body_y + (self.body_scroll / max_scroll) * (body_h - thumb_h);
         Some((sb_x, body_y, sb_w, body_h, thumb_y, thumb_h))
@@ -3314,6 +3320,10 @@ impl ClearEmailApp {
     /// Left press on the scrollbar strip (±4px slop like ScrollRegion): grab the
     /// thumb where it was clicked, or jump the track and drag from the thumb center.
     fn body_sb_press(&mut self, px: f32, py: f32) -> bool {
+        // A sunk bar is not drawn and takes no input; the press falls through.
+        if !self.body_sb_activity.raised() {
+            return false;
+        }
         let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.body_scrollbar_geom() else {
             return false;
         };
@@ -3630,7 +3640,12 @@ impl Application for ClearEmailApp {
             .with_placeholder("Search mail — sender, subject or body");
         search_box.font_size = 11.0;
 
-        let email_list = ScrollRegion::new(54.0, 4.0);
+        // Designer raise/sink treatment: idle the bar sinks under the list's
+        // translucent bg fill (dimly visible through it), a scroll raises it
+        // over the cards; stood off the list's right edge by the DE inset.
+        let email_list = ScrollRegion::new(54.0, 4.0)
+            .with_sink_behind(true)
+            .with_edge_inset(cce_ui::layout::scrollbar_inset());
 
         let accounts = load_accounts();
         // Last-used account wins (sidecar file), else the configured default:
@@ -3735,6 +3750,7 @@ impl Application for ClearEmailApp {
             detail_hovered: false,
             body_sb_dragging: false,
             body_sb_drag_offset: 0.0,
+            body_sb_activity: cce_ui::widget::ScrollbarActivity::new(),
             #[cfg(feature = "wpe")]
             webview: wpe::MailWebView::new((800, 600)),
             #[cfg(feature = "wpe")]
@@ -4358,6 +4374,20 @@ impl Application for ClearEmailApp {
             self.needs_rebuild = true;
         }
 
+        // Raise/sink upkeep for the two scrollbars (email list, detail pane):
+        // true while the post-scroll hold runs or on the depth flip, keeping
+        // frames coming so the sink actually renders.
+        if self.email_list.tick(dt) {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+        let body_holding = self.body_sb_activity.holding();
+        let body_visible = self.body_scrollbar_geom().is_some();
+        if self.body_sb_activity.tick(dt, body_visible, self.body_sb_dragging) || body_holding {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+
         // A sync skipped for a missing password retries itself, so unlocking
         // the vault brings mail in without the user having to ask again.
         // No redraw is requested here: a failed retry changes nothing on
@@ -4752,10 +4782,15 @@ impl Application for ClearEmailApp {
                         let max_scroll = (content_h - body_h).max(0.0);
                         self.body_scroll = self.body_scroll.clamp(0.0, max_scroll);
 
-                        // Scrollbar (ScrollRegion's colors) when the body overflows.
-                        if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.body_scrollbar_geom() {
-                            quads.push((sb_x, track_y, sb_w, track_h, cce_ui::color::scrollbar_track_color()));
-                            quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
+                        // Scrollbar (ScrollRegion's colors) while raised: a
+                        // scroll shows it, 0.7s of quiet hides it (the window
+                        // bg is opaque — no plate for a sunk layer to show
+                        // through, so sunk is simply not drawn).
+                        if self.body_sb_activity.raised() {
+                            if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.body_scrollbar_geom() {
+                                quads.push((sb_x, track_y, sb_w, track_h, cce_ui::color::scrollbar_track_color()));
+                                quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
+                            }
                         }
 
                         quads.pc.text_boxed(
@@ -4925,6 +4960,20 @@ impl Application for ClearEmailApp {
             }
         }
 
+        // Detail-bar hover bookkeeping: hover only SUSTAINS a raised bar (the
+        // hysteresis contract), so raw strip geometry is the right input.
+        if !self.compose_open {
+            let over = self
+                .body_scrollbar_geom()
+                .is_some_and(|(sb_x, track_y, sb_w, track_h, _, _)| {
+                    px >= sb_x - 4.0
+                        && px <= sb_x + sb_w + 4.0
+                        && py >= track_y
+                        && py <= track_y + track_h
+                });
+            self.body_sb_activity.set_hover(over);
+        }
+
         // Active split drag: the separator follows the pointer, clamped so
         // neither pane collapses. Clamp into the stored value (not just at
         // paint) so the release persists what the user actually sees.
@@ -5099,6 +5148,8 @@ impl Application for ClearEmailApp {
                 }
                 ElementState::Released => {
                     if std::mem::take(&mut self.body_sb_dragging) {
+                        // Drag release starts the hold before the bar hides.
+                        self.body_sb_activity.bump();
                         changed = true;
                     }
                     if std::mem::take(&mut self.split_dragging) {
@@ -5427,6 +5478,9 @@ impl Application for ClearEmailApp {
                     let old = self.body_scroll;
                     self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
                     if (self.body_scroll - old).abs() > 0.01 {
+                        // The scroll raises the bar in the same frame.
+                        self.body_sb_activity.bump();
+                        self.body_sb_activity.recompute(true, self.body_sb_dragging);
                         changed = true;
                     }
                 }