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

commit9dd78dd31de75fd548eca578ffd519343fb0a72b
parent37c856b5f0
authorLucas Galante <[email protected]>
date2026-07-30 13:17
fix: band slider scroll latch + wider halo + ungated pane forwarding

Adjusting slides the bulge — and its capture halo — away from the pointer,
so a scroll moved the value a little and stalled mid-gesture. Three pieces:

- Gesture LATCH: once a gesture engages the slider (acquired via the shape
  halo) it keeps it until the gesture ends, in both the slider's own wheel
  test and ParametersBg's forwarding gate; a new gesture re-acquires by
  position.
- The halo inset widens 8 → 14px: the thin band's ±9px capture strip was a
  needle to hit (measured misses by <1px in testing).
- Adapted::mouse_wheel_ungated: the adapter's wheel rect-gate clipped
  exactly the halo fringe outside the row rect, silently dropping events
  the pane's spatial gate had already accepted. Hosts that zone-gate
  themselves forward wheels ungated; the widget's own on_event still
  applies its fine-grained test.

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

 src/widget/container/parameters_bg.rs | 22 ++++++++++++++------
 src/widget/input/slider.rs            | 11 ++++++++--
 src/widget/model.rs                   | 38 +++++++++++++++++++++++++++++++++++
 3 files changed, 63 insertions(+), 8 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 44bce82..8783c31 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2191,13 +2191,20 @@ impl Input for ParametersBg {
                         // The default style keeps the whole-row strip.
                         let in_zone = if crate::layout::slider_band() {
                             self.sliders[i].as_ref().map_or(false, |s| {
+                                // The same gesture latch the slider's own wheel
+                                // test applies: mid-gesture the slider that
+                                // acquired the scroll keeps it (its halo travels
+                                // away from the pointer as the value moves).
+                                let latched = !ui.scroll_gesture_new
+                                    && ui.scroll_initiate_widget_id == Some(s.base().id());
                                 let (sx, sy, sw, sh) = s.rect();
                                 let ty = crate::widget::label_offset(s);
-                                s.inner().scroll_hit(
-                                    Rect { x: sx, y: sy + ty, width: sw, height: sh - ty },
-                                    px,
-                                    py,
-                                )
+                                latched
+                                    || s.inner().scroll_hit(
+                                        Rect { x: sx, y: sy + ty, width: sw, height: sh - ty },
+                                        px,
+                                        py,
+                                    )
                             })
                         } else {
                             py >= row_y - 2.0
@@ -2209,7 +2216,10 @@ impl Input for ParametersBg {
                             if let Some(s) = &mut self.sliders[i] {
                                 let was_scroll = s.scroll_enabled;
                                 s.set_scroll(true);
-                                if s.mouse_wheel(delta, px, py, ui) {
+                                // Ungated: the in_zone halo above already gated
+                                // spatially, and the adapter's rect gate would
+                                // clip the halo's fringe outside the row rect.
+                                if s.mouse_wheel_ungated(delta, px, py, ui) {
                                     let (min, max) = parse_slider_range(&p.2);
                                     let new_val = min + s.value * (max - min);
                                     let old_val = &p.1;
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 1c4f6b9..a1c8bfb 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -211,7 +211,7 @@ impl Slider {
                 && py >= rect.y
                 && py <= rect.y + rect.height;
         }
-        const SCROLL_INSET: f32 = 8.0;
+        const SCROLL_INSET: f32 = 14.0;
         let g = self.geom(rect);
         if px < g.track_x - SCROLL_INSET || px > g.track_x + g.track_w + SCROLL_INSET {
             return false;
@@ -642,7 +642,14 @@ impl Input for Slider {
                         return false;
                     }
                     let r = ectx.rect;
-                    if self.scroll_hit(r, *px, *py) {
+                    // Band: spatial acquisition + gesture LATCH. The halo travels
+                    // with the bulge, so adjusting slides it away from the pointer
+                    // — without the latch the value moves a little and stalls
+                    // mid-scroll. Once a gesture engages this slider it keeps it
+                    // until the gesture ends; a new gesture re-acquires by halo.
+                    let latched =
+                        band && !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(ectx.id);
+                    if latched || self.scroll_hit(r, *px, *py) {
                         if band || ui.scroll_gesture_new {
                             ui.scroll_initiate_widget_id = Some(ectx.id);
                         }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 1afb9ff..1e80914 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -771,6 +771,31 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
             ctx,
         )
     }
+
+    /// [`Self::mouse_wheel`] WITHOUT the adapter's rect hit-gate: straight to the
+    /// widget's `Input::on_event`. For hosts that already zone-gated the wheel
+    /// themselves against a capture region LARGER than the widget rect — the
+    /// band slider's shape-conforming halo extends past the row rect, and the
+    /// rect gate would clip exactly the fringe the halo exists to catch
+    /// (`ParametersBg`'s slider forwarding). The widget's own on_event still
+    /// applies its fine-grained zone test.
+    pub fn mouse_wheel_ungated(
+        &mut self,
+        delta: &crate::widget::MouseScrollDelta,
+        px: f32,
+        py: f32,
+        ctx: &mut UiContext,
+    ) -> bool {
+        let rect = self.content_rect();
+        let id = self.base.id();
+        let self_ptr = self.as_ptr_mut();
+        let mut ectx = EventCtx { rect, id, ui: Some(ctx), self_ptr: Some(self_ptr) };
+        Input::on_event(
+            &mut self.inner,
+            &Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py },
+            &mut ectx,
+        )
+    }
     pub fn keyboard_input(&mut self, event: &crate::widget::KeyEvent, ctx: &mut UiContext) -> bool {
         self.handle_event(&Event::KeyInput(event.clone()), ctx)
     }
@@ -1226,6 +1251,13 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         // (re-deriving would flatten a composite's mixed child fonts to widget_font).
         let subtree = Paint::paints_own_subtree(&self.inner);
         for item in tmp.finish().items {
+            // Re-emitting through ctx re-records clip state, so restore the
+            // circular clip the widget authored the prim under (Ramp's
+            // foam-cell fills) — it would otherwise be dropped here.
+            let clip_circle = item.clip_circle;
+            if let Some(c) = clip_circle {
+                ctx.push_clip_circle(c);
+            }
             match item.prim {
                 Prim::Text { text, x, y, font_size, color, font, bounds, .. } if subtree => {
                     ctx.text_with(text, x, y, font_size, color, font, bounds)
@@ -1246,6 +1278,9 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 Prim::Ridge { rect, radii, depth, edges } => ctx.ridge_edges(rect, radii, depth, edges),
                 Prim::Plate { rect, radii, color, depth } => ctx.plate(rect, radii, color, depth),
                 Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
+                Prim::ArcShaded { cx, cy, radius, thickness, start, end, inner, crest, outer } => {
+                    ctx.arc_shaded(cx, cy, radius, thickness, start, end, inner, crest, outer)
+                }
                 Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
                 Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
                 Prim::Sphere { cx, cy, radius, color } => ctx.sphere(cx, cy, radius, color),
@@ -1254,6 +1289,9 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 }
                 Prim::Image { image, rect, alpha } => ctx.image(image, rect, alpha),
             }
+            if clip_circle.is_some() {
+                ctx.pop_clip_circle();
+            }
         }
         // The legacy default `paint_self` drained `all_quads`, which carries the focus
         // highlight — replicate for opt-in widgets, over the background (same draw order).