git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit674a81a94713bf8e2807ecaa8bec8c72db549e9d
parent80f28cc660
authorLucas Galante <[email protected]>
date2026-09-01 12:36
page scrollbar: designer raise/sink treatment + live thumb tracking

The page bar now straddles the translucent window plate the way the
designer's parameter pane bar does: idle it sinks behind the plate (dimly
visible through it, non-interactive), a wheel/keyboard scroll raises it
over the page content, hover sustains it, and it sinks back 0.7s after
the last activity (cce-ui's shared ScrollbarActivity). Geometry follows
the same look: 1.6x the DE scrollbar width, stood off the window's right
edge by layout::scrollbar_inset() instead of hugging it at 2px.

This also fixes the thumb only moving after scrolling stopped: the bar
was baked into the rebuilt layout, and the wheel fast path shifts cached
geometry without a rebuild, so the thumb froze mid-scroll. display_list
now emits the bar fresh every frame (sunk layer under the plate, raised
layer over the content), and the fast path feeds it scroll + raise
directly.

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

 src/input_handler.rs |  8 +++++
 src/main.rs          | 41 ++++++++++++++++++++++++
 src/renderer.rs      | 20 ++++++++----
 src/scroll_bar.rs    | 89 +++++++++++++++++++++++++++++++++++++++++-----------
 4 files changed, 134 insertions(+), 24 deletions(-)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index ccef91a..0a6d003 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -327,6 +327,11 @@ impl SystemInterface {
                     btn.base_mut().y -= actual_dy;
                 }
                 self.last_scroll_y = self.scroll_y;
+                // The fast path skips the rebuild, so feed the scrollbar here:
+                // sync the thumb and raise the bar from behind the window plate
+                // (display_list emits it fresh each frame from this state).
+                self.page_scroll_bar.scroll_y = self.scroll_y;
+                self.page_scroll_bar.on_scroll();
                 return true;
             }
         }
@@ -660,6 +665,9 @@ impl SystemInterface {
                     _ => {}
                 }
                 if (self.scroll_y - old_scroll).abs() > 0.01 {
+                    // Keyboard scrolling raises the bar like the wheel does.
+                    self.page_scroll_bar.scroll_y = self.scroll_y;
+                    self.page_scroll_bar.on_scroll();
                     self.needs_rebuild = true;
                     key_handled = true;
                 }
diff --git a/src/main.rs b/src/main.rs
index 7fcc973..545e7c5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -283,6 +283,32 @@ impl cce_ui::engine::Application for SystemInterface {
         use cce_ui::scene::layout::Rect;
         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 
+        // The page scrollbar straddles the window plate (the designer
+        // parameter-pane treatment) and is emitted fresh EVERY frame — never
+        // baked into the rebuilt layout, so the thumb tracks the wheel fast
+        // path's scrolls, which shift cached geometry without a rebuild and
+        // used to leave the bar frozen until scrolling stopped. Sync first:
+        // a thumb drag drives the page, anything else drives the thumb.
+        if self.page_scroll_bar.dragging {
+            self.scroll_y = self.page_scroll_bar.scroll_y;
+        } else {
+            self.page_scroll_bar.scroll_y = self.scroll_y;
+        }
+        let page_bar = {
+            use cce_ui::widget::WidgetHost;
+            let (bx, by, bw, bh) = self.page_scroll_bar.rect();
+            self.page_scroll_bar
+                .layer_quads(Rect { x: bx, y: by, width: bw, height: bh })
+        };
+        // Sunk layer: under the translucent window plate, so idle the bar
+        // reads as sunk INTO the window rather than gone, and the plate
+        // occludes it from input. Track and thumb are pills (the designer look).
+        if !self.page_scroll_bar.raised() {
+            for &(r, c) in &page_bar {
+                pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
+            }
+        }
+
         // One glass slab (data-editor's idiom): the beveled window plate, with the
         // status bar carved into it as a step — everything else paints on top.
         {
@@ -462,6 +488,14 @@ impl cce_ui::engine::Application for SystemInterface {
             });
         }
 
+        // The page scrollbar's raised layer: over the page content while a
+        // scroll or drag holds it up (popovers still stack above it).
+        if self.page_scroll_bar.raised() {
+            for &(r, c) in &page_bar {
+                pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
+            }
+        }
+
         cce_ui::widget::hover_animation::post_render_check();
         if let Some((qx, qy, qw, qh, qc)) = cce_ui::widget::hover_animation::get_quad() {
             pc.quad(Rect { x: qx, y: qy - self.scroll_y, width: qw, height: qh }, qc);
@@ -560,6 +594,13 @@ impl SystemInterface {
         if hover_animation::tick(dt) {
             needs_redraw = true;
         }
+        // Raise/sink upkeep for the page scrollbar: true while the post-scroll
+        // hold runs (keeps frames coming so the sink actually renders) and on
+        // the raised flip itself. A redraw re-emits the bar at its new depth —
+        // no layout rebuild needed, display_list draws it fresh each frame.
+        if self.page_scroll_bar.tick_activity(dt) {
+            needs_redraw = true;
+        }
         if self.ui_context.tick(dt) {
             needs_redraw = true;
             self.needs_rebuild = true;
diff --git a/src/renderer.rs b/src/renderer.rs
index c6231f4..4aa2e66 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -157,12 +157,15 @@ impl SystemInterface {
         } else {
             logical_sh - self.header_height - self.status_height
         };
-        // The page scrollbar (the only geometry the dissolved Page subtree ever emitted):
-        // placed exactly as Page::layout did, updated with LAST frame's content height —
-        // the legacy window pass also ran before this frame's content was measured.
-        let sb_w = cce_ui::layout::scrollbar_width();
+        // The page scrollbar, on the designer parameter-pane geometry: the DE
+        // width widened (the bar rides over page content and reads too slim at
+        // stock width), stood off the window's right edge by the configured
+        // inset instead of hugging it. Updated with LAST frame's content
+        // height — the legacy window pass also ran before this frame's content
+        // was measured.
+        let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
         self.page_scroll_bar.set_rect(
-            self.sidebar_width + (logical_sw - self.sidebar_width) - sb_w - 2.0,
+            logical_sw - sb_w - cce_ui::layout::scrollbar_inset(),
             self.header_height + 4.0,
             sb_w,
             switcher_h - 8.0,
@@ -187,7 +190,12 @@ impl SystemInterface {
             let mut rounded: Vec<RectTuple> = Vec::new();
             let mut wtexts: Vec<TextTuple> = Vec::new();
 
-            collect_window_child(&self.page_scroll_bar, &self.ui_context, logical_sw, logical_sh, plate_radius, &mut plain, &mut rounded, &mut wtexts);
+            // The page scrollbar is NOT collected here: baked into the rebuilt
+            // layout, its thumb froze for every wheel tick the scroll fast path
+            // absorbed (the fast path shifts cached geometry without a rebuild,
+            // so the bar only moved once scrolling stopped and something else
+            // rebuilt). display_list emits it fresh each frame instead — under
+            // the window plate while sunk, over the page content while raised.
 
             // The status bar has no background of its own anymore: the beveled window
             // plate shows through and display_list carves its recess (data-editor's
diff --git a/src/scroll_bar.rs b/src/scroll_bar.rs
index 390a7dc..62d63e7 100644
--- a/src/scroll_bar.rs
+++ b/src/scroll_bar.rs
@@ -7,7 +7,7 @@
 
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::PaintCtx;
-use cce_ui::widget::{Adapted, Event, EventCtx, MouseButton, ElementState};
+use cce_ui::widget::{Adapted, Event, EventCtx, MouseButton, ElementState, ScrollbarActivity};
 
 #[derive(Debug, Clone)]
 pub struct ScrollBar {
@@ -16,6 +16,10 @@ pub struct ScrollBar {
     pub viewport_h: f32,
     pub dragging: bool,
     hovered: bool,
+    /// The shared raise/sink hysteresis (the designer parameter-pane treatment):
+    /// idle the bar sinks behind the translucent window plate and takes no
+    /// input; a scroll raises it, hover sustains it, the hold decays in `tick`.
+    activity: ScrollbarActivity,
 }
 
 impl ScrollBar {
@@ -26,6 +30,7 @@ impl ScrollBar {
             viewport_h: 0.0,
             dragging: false,
             hovered: false,
+            activity: ScrollbarActivity::new(),
         })
     }
 
@@ -35,6 +40,54 @@ impl ScrollBar {
         self.viewport_h = viewport_h;
     }
 
+    fn overflowing(&self) -> bool {
+        self.content_h > self.viewport_h
+    }
+
+    /// Whether the bar currently rides in front of the content (and takes
+    /// input) rather than idling behind the window plate.
+    pub fn raised(&self) -> bool {
+        self.activity.raised()
+    }
+
+    /// A scroll landed (wheel fast path, keyboard): refresh the hold and raise
+    /// the bar in the same frame.
+    pub fn on_scroll(&mut self) {
+        self.activity.bump();
+        let visible = self.overflowing();
+        self.activity.recompute(visible, self.dragging);
+    }
+
+    /// Per-frame raise/sink upkeep; true = keep redrawing (hold running or the
+    /// bar just flipped depth). Named apart from the `Input`/`WidgetHost` tick
+    /// so the call through `Adapted`'s Deref can't collide.
+    pub fn tick_activity(&mut self, dt: f32) -> bool {
+        let holding = self.activity.holding();
+        let visible = self.overflowing();
+        self.activity.tick(dt, visible, self.dragging) || holding
+    }
+
+    /// The track + thumb quads for the host's two-layer emission: drawn under
+    /// the window plate while sunk, over the page content while raised. Colors
+    /// keep the widget's hover/drag tint.
+    pub fn layer_quads(&self, rect: Rect) -> Vec<(Rect, [f32; 4])> {
+        let mut out = Vec::new();
+        if self.content_h > self.viewport_h && rect.height > 0.0 {
+            out.push((rect, [0.15, 0.15, 0.20, 0.3]));
+            if let Some((tx, ty, tw, th)) = self.thumb_rect(rect) {
+                let thumb_color = if self.dragging {
+                    [0.70, 0.70, 0.75, 0.6]
+                } else if self.hovered && self.activity.raised() {
+                    [0.65, 0.65, 0.70, 0.5]
+                } else {
+                    [0.60, 0.60, 0.65, 0.4]
+                };
+                out.push((Rect { x: tx, y: ty, width: tw, height: th }, thumb_color));
+            }
+        }
+        out
+    }
+
     fn thumb_rect(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
         if self.content_h <= self.viewport_h || self.viewport_h <= 0.0 || rect.height <= 0.0 {
             return None;
@@ -79,23 +132,11 @@ impl cce_ui::widget::Paint for ScrollBar {
         [0.0, 0.0, 0.0, 0.0]
     }
 
-    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        if self.content_h > self.viewport_h && rect.height > 0.0 {
-            // Track and thumb are pills — half-width radius (the designer look).
-            let all = (true, true, true, true);
-            ctx.rounded_rect(rect, rect.width.min(rect.height) * 0.5, all, [0.15, 0.15, 0.20, 0.3]);
-
-            if let Some((tx, ty, tw, th)) = self.thumb_rect(rect) {
-                let thumb_color = if self.dragging {
-                    [0.70, 0.70, 0.75, 0.6]
-                } else if self.hovered {
-                    [0.65, 0.65, 0.70, 0.5]
-                } else {
-                    [0.60, 0.60, 0.65, 0.4]
-                };
-                ctx.rounded_rect(Rect { x: tx, y: ty, width: tw, height: th }, tw.min(th) * 0.5, all, thumb_color);
-            }
-        }
+    fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {
+        // Deliberately empty: the bar straddles the window plate (sunk under it
+        // idle, over the page content while raised), so the host emits it as two
+        // possible layers in `display_list` via [`ScrollBar::layer_quads`] — a
+        // single in-tree paint could only ever sit at one depth.
     }
 }
 
@@ -113,7 +154,12 @@ impl cce_ui::widget::Input for ScrollBar {
         match event {
             // Presses arrive hit-gated (margin hit): grab the thumb and jump-scroll to the
             // press point, like the legacy `mouse_input` → `on_cursor_moved` pair.
+            // Only a raised bar can be grabbed — sunk it sits behind the window
+            // plate, so the press falls through to whatever the plate carries.
             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, y, .. } => {
+                if !self.activity.raised() {
+                    return false;
+                }
                 self.dragging = true;
                 self.drag_track(*y, ectx.rect);
                 true
@@ -122,6 +168,9 @@ impl cce_ui::widget::Input for ScrollBar {
             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
                 if self.dragging {
                     self.dragging = false;
+                    // The release starts the hold window: the bar lingers
+                    // briefly, then sinks back behind the plate.
+                    self.activity.bump();
                     return true;
                 }
                 false
@@ -136,10 +185,14 @@ impl cce_ui::widget::Input for ScrollBar {
             }
             Event::MouseEnter => {
                 self.hovered = true;
+                // Hover only SUSTAINS a raised bar (recomputed in tick); it can
+                // never raise a sunk one — the plate is what the pointer is on.
+                self.activity.set_hover(true);
                 true
             }
             Event::MouseLeave => {
                 self.hovered = false;
+                self.activity.set_hover(false);
                 true
             }
             _ => false,