GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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).