git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit4f4ab24710fab6777afec4c972bb4227fbe29601
parent9eae50b646
authorLucas Galante <[email protected]>
date2026-09-19 00:05
scroll-region: the raise is a cross-fade, not a flip

A sink-behind bar swapped depth in one frame. Give ScrollbarActivity a
0..1 fade that chases the latch over SCROLL_FADE_SECS, so the fore copy
ramps in and back out while the LATCH still flips at once — input never
waits on the animation, and hosts keep gating hit-tests on raised().

push_scrollbar_prims_alpha draws the bar at a given opacity (the plain
call is alpha 1.0), and the prim path drives it from the fade instead of
the latch: gating on raised() would cut the fade off half way out. The
tuple path keeps the hard flip — its hosts have no frosted plate for a
sunk bar to show through, so there is nothing to cross-fade against.

The fade is advanced only by tick(), so a host driving the latch through
recompute() alone is unchanged.

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

 src/widget/scroll_region.rs | 108 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 100 insertions(+), 8 deletions(-)

diff --git a/src/widget/scroll_region.rs b/src/widget/scroll_region.rs
index 6f547b5..9b5f32d 100644
--- a/src/widget/scroll_region.rs
+++ b/src/widget/scroll_region.rs
@@ -29,6 +29,11 @@ use crate::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
 /// scroll or drag release.
 pub const SCROLL_ACTIVE_HOLD: f32 = 0.7;
 
+/// How long (seconds) the raise and the sink take to cross-fade. Coming to the
+/// fore is a fade, not a flip: the bar reads as rising through the host's
+/// frosted plate rather than being swapped for a copy of itself.
+pub const SCROLL_FADE_SECS: f32 = 0.18;
+
 /// The raise/sink hysteresis for scrollbars that idle BEHIND their host's
 /// translucent plate — the designer parameter-pane treatment, shared so every
 /// app's bar behaves the same way. The bar has two depths: *raised* it draws in
@@ -52,6 +57,10 @@ pub struct ScrollbarActivity {
     activity: f32,
     hover: bool,
     raised: bool,
+    /// How far the FORE copy has faded in, 0..=1. Chases `raised` over
+    /// [`SCROLL_FADE_SECS`]; only [`Self::tick`] advances it, so a host that
+    /// drives the latch through `recompute` alone keeps the old hard flip.
+    fade: f32,
 }
 
 impl ScrollbarActivity {
@@ -60,11 +69,19 @@ impl ScrollbarActivity {
     }
 
     /// Whether the bar is currently raised in front of the plate. While false
-    /// it sits behind the plate and must not take input.
+    /// it sits behind the plate and must not take input. This is the LATCH —
+    /// it flips at once, so input never waits on the fade.
     pub fn raised(&self) -> bool {
         self.raised
     }
 
+    /// Opacity of the fore copy, 0..=1: 0 while fully sunk (only the copy
+    /// behind the plate shows), 1 once risen. Paint with this; gate input on
+    /// [`Self::raised`].
+    pub fn fade(&self) -> f32 {
+        self.fade
+    }
+
     /// Refresh the hold window: call on a wheel/keyboard scroll and on a drag
     /// release.
     pub fn bump(&mut self) {
@@ -99,7 +116,21 @@ impl ScrollbarActivity {
         if self.activity > 0.0 {
             self.activity = (self.activity - dt).max(0.0);
         }
-        self.recompute(visible, dragging)
+        let flipped = self.recompute(visible, dragging);
+        // Chase the latch. The step is over the WHOLE range, so a fade
+        // reversed halfway takes proportionally less time rather than
+        // restarting — a flick-scroll-flick does not stutter.
+        let target = if self.raised { 1.0 } else { 0.0 };
+        let step = if SCROLL_FADE_SECS > 0.0 { dt / SCROLL_FADE_SECS } else { 1.0 };
+        let moved = if (self.fade - target).abs() <= step {
+            let done = self.fade != target;
+            self.fade = target;
+            done
+        } else {
+            self.fade += step * (target - self.fade).signum();
+            true
+        };
+        flipped || moved
     }
 }
 
@@ -520,6 +551,16 @@ impl ScrollRegion {
         !self.sink_behind || self.activity.raised()
     }
 
+    /// Opacity of the fore copy of the bar, 0..=1 — see
+    /// [`ScrollbarActivity::fade`]. Always 1 for a region that never sinks.
+    pub fn scrollbar_fade(&self) -> f32 {
+        if self.sink_behind {
+            self.activity.fade()
+        } else {
+            1.0
+        }
+    }
+
     /// Per-frame raise/sink upkeep for sink-behind regions; a `true` return is
     /// the host's repaint signal. True while the post-scroll hold is running,
     /// not just on the flip: the demand-driven frame loop only keeps ticking
@@ -626,8 +667,13 @@ impl ScrollRegion {
         // sink-behind region draws no sunk layer here — its rows sit directly
         // on the host's plate, so the host owns the under-plate emission via
         // `push_scrollbar_prims`.
-        if self.scrollbar_raised() {
-            self.push_scrollbar_prims(pc);
+        // The fore copy fades rather than flips, and keeps drawing all the way
+        // out — gating this on `scrollbar_raised` would cut the fade off at the
+        // latch. The tuple path below is deliberately left on the hard flip:
+        // its hosts have no frosted plate for a sunk bar to show through.
+        let fade = self.scrollbar_fade();
+        if fade > 0.001 {
+            self.push_scrollbar_prims_alpha(pc, fade);
         }
     }
 
@@ -635,18 +681,34 @@ impl ScrollRegion {
     /// host calls it. A sink-behind host emits this twice a frame at most:
     /// under its plate while the bar is sunk, over the content while raised.
     pub fn push_scrollbar_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
+        self.push_scrollbar_prims_alpha(pc, 1.0);
+    }
+
+    /// [`Self::push_scrollbar_prims`] with the track and thumb scaled to
+    /// `alpha` — what a host draws the FORE copy with while it fades in and
+    /// out. The copy that idles behind the plate is drawn at full alpha; the
+    /// plate over it is what dims and frosts it.
+    pub fn push_scrollbar_prims_alpha(&self, pc: &mut dyn crate::layout::RenderTarget, alpha: f32) {
+        let a = alpha.clamp(0.0, 1.0);
+        if a <= 0.001 {
+            return;
+        }
+        let dim = |mut c: [f32; 4]| {
+            c[3] *= a;
+            c
+        };
         if self.content_h > self.viewport_h {
             // Track and thumb are pills — half-width radius (the designer look).
             let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
             let all = (true, true, true, true);
-            pc.rect_with_radius_corners(crate::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(crate::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
+            pc.rect_with_radius_corners(dim(crate::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(crate::color::scrollbar_thumb_color()), sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
         }
         if self.h_scroll_active() {
             let (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w) = self.h_scrollbar_geom();
             let all = (true, true, true, true);
-            pc.rect_with_radius_corners(crate::color::scrollbar_track_color(), track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
-            pc.rect_with_radius_corners(crate::color::scrollbar_thumb_color(), thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
+            pc.rect_with_radius_corners(dim(crate::color::scrollbar_track_color()), track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
+            pc.rect_with_radius_corners(dim(crate::color::scrollbar_thumb_color()), thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
         }
     }
 
@@ -688,6 +750,36 @@ impl ScrollRegion {
 mod tests {
     use super::*;
 
+    /// Coming to the fore is a fade, not a flip: the latch moves at once (so
+    /// input never waits) while the drawn opacity ramps, in and back out.
+    #[test]
+    fn scrollbar_fade_ramps_instead_of_flipping() {
+        let mut a = ScrollbarActivity::new();
+        a.bump();
+        // dt = SCROLL_FADE_SECS / 3, so one tick is a third of the way in.
+        let dt = SCROLL_FADE_SECS / 3.0;
+        a.tick(dt, true, false);
+        assert!(a.raised(), "the latch flips immediately");
+        assert!(a.fade() > 0.0 && a.fade() < 1.0, "part-way faded in, got {}", a.fade());
+        for _ in 0..3 {
+            a.tick(dt, true, false);
+        }
+        assert_eq!(a.fade(), 1.0, "fully in after the fade duration");
+
+        // Let the post-scroll hold expire: the latch drops, then the fade
+        // runs back out rather than vanishing with it.
+        let ticks = (SCROLL_ACTIVE_HOLD / dt).ceil() as i32 + 1;
+        for _ in 0..ticks {
+            a.tick(dt, true, false);
+        }
+        assert!(!a.raised(), "hold expired");
+        assert!(a.fade() < 1.0 && a.fade() >= 0.0, "fading out, got {}", a.fade());
+        for _ in 0..4 {
+            a.tick(dt, true, false);
+        }
+        assert_eq!(a.fade(), 0.0, "fully out");
+    }
+
     fn region() -> ScrollRegion {
         // item_height clamps to list_font + 14, so pick one comfortably above any config.
         let mut r = ScrollRegion::new(40.0, 4.0);