system settings
git clone https://git.lucas.co/cce-system-interface.git
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,