graphic design tool
git clone https://git.lucas.co/cce-designer.git
Dialog list: cce-mail's scrollbar — sink-behind pills on the plate's centre line
The Commands list scrolled by wheel, trackpad and keyboard and showed
nothing of where it was: no bar at all. It now has the bar cce-mail's
list and body carry, in the DE's sink-behind idiom (ScrollbarActivity):
pill track and thumb from one geometry function mirroring
ScrollRegion::scrollbar_geom — the page-level width the Settings half's
pane uses, so the two halves match; the track 4px short at each end;
the thumb no shorter than 20px — riding the plate's CENTRE line as both
of cce-mail's bars ride theirs, over the rows, reserving no lane.
A scroll or a keyboard move raises it and starts the hold; hover
sustains it; it drags by the thumb, and a press on the track jumps
there and drags from the thumb's centre; the release starts the hold
again, and after the hold it fades out. Sunk, it is not drawn and takes
no input — a press on its lane reaches the row beneath. The dialog's
plate is the host's, so there is no under-plate copy to show through
while sunk: sunk is simply not drawn, as cce-mail's body bar over its
opaque window. tick keeps frames coming through the hold and the fade so
the sink actually renders.
Tested end to end at the widget: sunk at rest, raised by a scroll,
dragged by the thumb, held on release, sunk after the hold, and a press
on the sunk lane activating the row underneath.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/dialog.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 48 +++++++++++++++++++
2 files changed, 196 insertions(+), 2 deletions(-)
diff --git a/src/dialog.rs b/src/dialog.rs
index 9d64dc6..a0a3089 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -172,6 +172,16 @@ pub struct Dialog {
/// the choppy commands list.
pub scroll_px: f32,
scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion,
+ /// The list's scrollbar, in the DE's sink-behind idiom (cce-mail's
+ /// body bar, `ScrollRegion` with `sink_behind`): pills at the list's
+ /// right edge that fade in on a scroll, stay while hovered or dragged,
+ /// and fade out after the hold. Sunk, it is not drawn and takes no
+ /// input — a press on its lane reaches the row beneath.
+ sb_activity: cce_ui::widget::ScrollbarActivity,
+ sb_dragging: bool,
+ /// Where in the thumb the drag grabbed it, so the thumb does not jump
+ /// to centre itself under the pointer.
+ sb_drag_offset: f32,
/// How many rows fit — pushed in from the layout, since `on_event` and the
/// app's key handling both need it and neither has the rect to hand.
page: usize,
@@ -198,6 +208,9 @@ impl Dialog {
selected: 0,
scroll_px: 0.0,
scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion::new(),
+ sb_activity: cce_ui::widget::ScrollbarActivity::new(),
+ sb_dragging: false,
+ sb_drag_offset: 0.0,
page: 1,
hover_row: None,
hover_tab: None,
@@ -254,6 +267,82 @@ impl Dialog {
self.scroll_motion.y.jump_to(self.scroll_px);
}
+ /// The scrollbar's geometry — `(sb_x, track_y, sb_w, track_h, thumb_y,
+ /// thumb_h)`, mirroring `ScrollRegion::scrollbar_geom` as cce-mail's
+ /// body bar does — or `None` when the rows fit and there is no bar. The
+ /// one source for paint, the press and the drag. The bar rides the
+ /// plate's CENTRE line, as both of cce-mail's bars ride theirs — over
+ /// the rows, reserving no lane, in front only while raised — at the
+ /// page-level width the Settings half's pane uses, so the two halves'
+ /// bars match; the track stops 4px short at each end like every toolkit
+ /// bar.
+ pub fn scrollbar_geom(&self, rect: Rect) -> Option<(f32, f32, f32, f32, f32, f32)> {
+ if !self.shows_list() {
+ return None;
+ }
+ let max_scroll = self.max_scroll_px();
+ if max_scroll <= 0.0 {
+ return None;
+ }
+ let list = list_rect(rect);
+ let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
+ let sb_x = rect.x + (rect.width - sb_w) * 0.5;
+ let track_y = list.y + 4.0;
+ let track_h = (list.height - 8.0).max(0.0);
+ let content_h = self.rows.len() as f32 * ROW_H;
+ let visible_ratio = list.height / content_h.max(1.0);
+ let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
+ let thumb_y = track_y + (self.scroll_px / max_scroll) * (track_h - thumb_h);
+ Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h))
+ }
+
+ fn over_scrollbar(&self, rect: Rect, px: f32, py: f32) -> bool {
+ self.scrollbar_geom(rect).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
+ })
+ }
+
+ /// A left press on the bar's strip (±4px slop, like `ScrollRegion`):
+ /// grab the thumb where it was clicked, or jump the track there and
+ /// drag from the thumb's centre. A sunk bar is not drawn and takes no
+ /// input — the press falls through to the row beneath.
+ fn sb_press(&mut self, rect: Rect, px: f32, py: f32) -> bool {
+ if !self.sb_activity.raised() {
+ return false;
+ }
+ let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) else {
+ return false;
+ };
+ if px < sb_x - 4.0 || px > sb_x + sb_w + 4.0 || py < track_y || py > track_y + track_h {
+ return false;
+ }
+ self.sb_dragging = true;
+ let click_offset = py - thumb_y;
+ if (0.0..=thumb_h).contains(&click_offset) {
+ self.sb_drag_offset = click_offset;
+ } else {
+ self.sb_drag_offset = thumb_h / 2.0;
+ self.sb_drag_to(rect, py);
+ }
+ true
+ }
+
+ fn sb_drag_to(&mut self, rect: Rect, py: f32) -> bool {
+ let Some((_, track_y, _, track_h, _, thumb_h)) = self.scrollbar_geom(rect) else {
+ return false;
+ };
+ let target = py - self.sb_drag_offset;
+ let ratio = if track_h - thumb_h > 0.0 { ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0) } else { 0.0 };
+ let old = self.scroll_px;
+ self.set_scroll_px(ratio * self.max_scroll_px());
+ (self.scroll_px - old).abs() > 0.01
+ }
+
+ /// Whether the bar is raised — for the app's press cascade and tests.
+ pub fn scrollbar_raised(&self) -> bool {
+ self.sb_activity.raised()
+ }
+
/// A row's rect in the list, `Some` while any part of it is in view
/// (the paint clips to the list, so a partly scrolled row draws cut).
fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
@@ -288,8 +377,10 @@ impl Dialog {
let bottom = top + ROW_H;
if top < self.scroll_px {
self.set_scroll_px(top);
+ self.sb_activity.bump();
} else if bottom > self.scroll_px + view {
self.set_scroll_px(bottom - view);
+ self.sb_activity.bump();
}
}
@@ -563,12 +654,41 @@ impl Paint for Dialog {
}
}
});
+
+ // The scrollbar's fore copy, over the rows, at the activity's fade:
+ // pills, as `ScrollRegion::push_scrollbar_prims` and cce-mail's body
+ // bar draw them. Driven by the fade rather than the latch so it
+ // draws all the way out. The dialog's plate is the host's, so there
+ // is no under-plate copy to show through while sunk — sunk is
+ // simply not drawn, as cce-mail's body bar over its opaque window.
+ let a = self.sb_activity.fade().clamp(0.0, 1.0);
+ if a > 0.001 {
+ if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) {
+ let dim = |mut c: [f32; 4]| {
+ c[3] *= a;
+ c
+ };
+ let all = (true, true, true, true);
+ ctx.rounded_rect(
+ Rect { x: sb_x, y: track_y, width: sb_w, height: track_h },
+ sb_w.min(track_h) * 0.5,
+ all,
+ dim(cce_ui::color::scrollbar_track_color()),
+ );
+ ctx.rounded_rect(
+ Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
+ sb_w.min(thumb_h) * 0.5,
+ all,
+ dim(cce_ui::color::scrollbar_thumb_color()),
+ );
+ }
+ }
}
}
impl Input for Dialog {
/// Advance the list's glide / coast (see `scroll_px`).
- fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+ fn tick(&mut self, dt: f32, rect: Rect) -> bool {
if !self.shows_list() {
return false;
}
@@ -578,7 +698,14 @@ impl Input for Dialog {
if moved {
self.scroll_px = self.scroll_motion.y.pos();
}
- moved || self.scroll_motion.is_animating()
+ // The bar's raise/sink latch and its fade: frames keep coming while
+ // the hold runs and while the fore copy is still chasing the latch,
+ // so the sink actually renders instead of freezing mid-fade.
+ let visible = self.scrollbar_geom(rect).is_some();
+ let flipped = self.sb_activity.tick(dt, visible, self.sb_dragging);
+ let fade = self.sb_activity.fade();
+ let fading = if self.sb_activity.raised() { fade < 1.0 } else { fade > 0.0 };
+ moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading
}
/// The whole rect, always — this is what makes the dialog modal over what
@@ -599,6 +726,11 @@ impl Input for Dialog {
}
}
if self.shows_list() {
+ // The raised bar is in front of the rows; sunk, it is
+ // not there and the press reaches the row.
+ if self.sb_press(rect, *x, *y) {
+ return true;
+ }
if let Some(i) = self.row_at(rect, *x, *y) {
self.selected = i;
self.activated = self.rows.get(i).map(|r| r.id.clone());
@@ -609,7 +741,19 @@ impl Input for Dialog {
// press cannot reach the pane the dialog is covering.
true
}
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+ if std::mem::take(&mut self.sb_dragging) {
+ // The release starts the hold before the bar sinks.
+ self.sb_activity.bump();
+ return true;
+ }
+ false
+ }
Event::PointerMove { x, y, .. } => {
+ if self.sb_dragging {
+ return self.sb_drag_to(rect, *y);
+ }
+ self.sb_activity.set_hover(self.over_scrollbar(rect, *x, *y));
let row = self.shows_list().then(|| self.row_at(rect, *x, *y)).flatten();
let tab = (self.mode == Mode::Tabbed).then(|| self.tab_at(rect, *x, *y)).flatten();
let changed = row != self.hover_row || tab != self.hover_tab;
@@ -632,6 +776,8 @@ impl Input for Dialog {
cce_ui::widget::Bounds::max(max),
);
self.scroll_px = self.scroll_motion.y.pos();
+ // A scroll raises the bar and starts its hold.
+ self.sb_activity.bump();
moved || self.scroll_motion.is_animating()
}
_ => false,
diff --git a/src/main.rs b/src/main.rs
index 98b0ba0..583d506 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3266,6 +3266,54 @@ mod tests {
}
#[test]
+ /// The list's scrollbar is cce-mail's: sunk until a scroll raises it,
+ /// draggable while raised, sunk again after the hold — and while sunk it
+ /// takes no input, so a press on its lane reaches the row beneath.
+ #[test]
+ fn dialog_scrollbar_raises_on_scroll_drags_and_sinks() {
+ use cce_ui::widget::{ElementState, MouseButton, MouseScrollDelta, Position, ScrollPhase, WidgetHost};
+ use crate::dialog::{Dialog, Row};
+ let mut ctx = cce_ui::context::UiContext::new();
+ let mut d = Dialog::new();
+ d.set_visible(true);
+ WidgetHost::set_rect(&mut d, 0.0, 0.0, 520.0, 420.0);
+ let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 520.0, height: 420.0 };
+ let rows: Vec<Row> = (0..60)
+ .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None })
+ .collect();
+ d.set_rows(rows);
+ d.set_page(10);
+ assert!(d.scrollbar_geom(rect).is_some(), "sixty rows overflow: there is a bar");
+ assert!(!d.scrollbar_raised(), "sunk until something scrolls");
+
+ cce_ui::widget::scroll_motion::set_scroll_phase(ScrollPhase::Finger);
+ d.mouse_wheel_ungated(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), 100.0, 200.0, &mut ctx);
+ WidgetHost::tick(&mut d, 1.0 / 60.0, &mut ctx);
+ assert!(d.scrollbar_raised(), "a scroll raises the bar");
+
+ // Grab the thumb and drag it down: the list follows.
+ let (sb_x, _, sb_w, _, thumb_y, thumb_h) = d.scrollbar_geom(rect).unwrap();
+ let before = d.scroll_px;
+ let gx = sb_x + sb_w * 0.5;
+ let gy = thumb_y + thumb_h * 0.5;
+ assert!(d.mouse_input(MouseButton::Left, ElementState::Pressed, gx, gy, &mut ctx));
+ d.cursor_moved(gx, gy + 80.0, &mut ctx);
+ assert!(d.scroll_px > before + 10.0, "dragging the thumb scrolls: {} -> {}", before, d.scroll_px);
+ d.mouse_input(MouseButton::Left, ElementState::Released, gx, gy + 80.0, &mut ctx);
+ assert!(d.scrollbar_raised(), "the release starts the hold");
+
+ // Quiet for longer than the hold and the fade: the bar sinks, and a
+ // press on its lane is a row press again.
+ for _ in 0..90 {
+ WidgetHost::tick(&mut d, 1.0 / 60.0, &mut ctx);
+ }
+ assert!(!d.scrollbar_raised(), "the bar sinks after the hold");
+ let was = d.scroll_px;
+ d.mouse_input(MouseButton::Left, ElementState::Pressed, gx, gy, &mut ctx);
+ assert!((d.scroll_px - was).abs() < 0.01, "a sunk bar takes no input");
+ assert!(d.take_activated().is_some(), "the press reached the row beneath");
+ }
+
/// The dialog's commands list takes a trackpad (finger-phase pixel
/// delta) as well as a wheel notch (2026-09-20: a finger did nothing).
#[test]