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

commitf8dc2410041cfdeb1cac67d23900ed58b400e888
parent2f70e4e621
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 15:29
fix: sliders hover, and the params pane re-hovers its controls after a scroll

Two gaps in the params pane's hover, which together made its highlight
look random. Sliders (and so every float3 row) had no hover state at all,
while spinboxes, wells, dropdowns, toggles and buttons all lit under the
pointer — and the pane's pointer-move arm never forwarded to its slider
rows in the first place. And a control's hover was recomputed only on a
pointer move, so a wheel scroll that carried rows under a still pointer
left the departed control lit and the arriving one dark.

`Slider` carries `hovered` from the adapter's MouseEnter / MouseLeave and
lifts its band by the dropdown border's step; a float3 forwards moves to
its three rows already, so each hovers on its own. `ParametersBg` folds
its forwarding loops into `hover_controls`, adds the sliders to the
roster, and re-runs it from the pointer's last position
(`rehover_after_scroll`) when the wheel arm moves the scroll target and
when the tick's glide moves the rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 src/widget/container/parameters_bg.rs | 195 ++++++++++++++++++++++++----------
 src/widget/input/slider.rs            |  53 ++++++++-
 2 files changed, 189 insertions(+), 59 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 6d096ab..94d87d4 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -749,6 +749,94 @@ impl ParametersBg {
         ]
     }
 
+    /// Hand a pointer position to every hosted control, so each re-reads
+    /// its own hover (and a ramp row its key drag). Called for every pointer
+    /// move, and again from the last known position (`mouse_pos`) whenever a
+    /// SCROLL moves the rows under a still pointer — the wheel arm and the
+    /// tick's coast — because a control's hover is recomputed only when it
+    /// is told where the pointer is, and until 2026-09-28 nothing told it on
+    /// a scroll: a control that scrolled under the pointer stayed dark and
+    /// one that scrolled away stayed lit until the next motion. Sliders are
+    /// in the roster since the same day, having had no hover before.
+    fn hover_controls(&mut self, px: f32, py: f32, ui: &mut UiContext) -> bool {
+        let mut changed = false;
+        for s_opt in &mut self.sliders {
+            if let Some(s) = s_opt {
+                if s.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for sb_opt in &mut self.spinboxes {
+            if let Some(sb) = sb_opt {
+                if sb.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for f_opt in &mut self.float3s {
+            if let Some(f) = f_opt {
+                if f.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for b_opt in &mut self.buttons {
+            if let Some(b) = b_opt {
+                if b.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for d_opt in &mut self.choices {
+            if let Some(d) = d_opt {
+                if d.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for tb_opt in &mut self.texts {
+            if let Some(tb) = tb_opt {
+                if tb.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for cb_opt in &mut self.toggles {
+            if let Some(cb) = cb_opt {
+                if cb.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        for c_opt in &mut self.colors {
+            if let Some(c) = c_opt {
+                if c.on_cursor_moved(px, py, ui) {
+                    changed = true;
+                }
+            }
+        }
+        // Ramp rows: a move can drag a key — re-serialize the curve
+        // into the row value so hosts polling `node_params` see it.
+        for i in 0..self.ramps.len() {
+            if let Some(rp) = &mut self.ramps[i] {
+                if rp.on_cursor_moved(px, py, ui) {
+                    self.display_params[i].1 = rp.inner().spec_string();
+                    changed = true;
+                }
+            }
+        }
+        changed
+    }
+
+    /// The rows moved under a still pointer: re-hover from where it was.
+    fn rehover_after_scroll(&mut self, ui: &mut UiContext) -> bool {
+        match self.mouse_pos {
+            Some((px, py)) => self.hover_controls(px, py, ui),
+            None => false,
+        }
+    }
+
     fn update_slider_rects(&mut self) {
         // Inline rows hand their control the row less the label column; the
         // row rects themselves (`get_param_rects`) stay the full row, which is
@@ -2118,6 +2206,7 @@ impl Input for ParametersBg {
         if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
             self.scroll_y = self.scroll_motion.y.pos();
             self.update_slider_rects();
+            self.rehover_after_scroll(&mut dummy);
             changed = true;
         }
         if self.scroll_motion.is_animating() {
@@ -2192,64 +2281,8 @@ impl Input for ParametersBg {
                     }
                 }
 
-                for sb_opt in &mut self.spinboxes {
-                    if let Some(sb) = sb_opt {
-                        if sb.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for f_opt in &mut self.float3s {
-                    if let Some(f) = f_opt {
-                        if f.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for b_opt in &mut self.buttons {
-                    if let Some(b) = b_opt {
-                        if b.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for d_opt in &mut self.choices {
-                    if let Some(d) = d_opt {
-                        if d.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for tb_opt in &mut self.texts {
-                    if let Some(tb) = tb_opt {
-                        if tb.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for cb_opt in &mut self.toggles {
-                    if let Some(cb) = cb_opt {
-                        if cb.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                for c_opt in &mut self.colors {
-                    if let Some(c) = c_opt {
-                        if c.on_cursor_moved(px, py, ui) {
-                            changed = true;
-                        }
-                    }
-                }
-                // Ramp rows: a move can drag a key — re-serialize the curve
-                // into the row value so hosts polling `node_params` see it.
-                for i in 0..self.ramps.len() {
-                    if let Some(rp) = &mut self.ramps[i] {
-                        if rp.on_cursor_moved(px, py, ui) {
-                            self.display_params[i].1 = rp.inner().spec_string();
-                            changed = true;
-                        }
-                    }
+                if self.hover_controls(px, py, ui) {
+                    changed = true;
                 }
 
                 changed
@@ -3050,6 +3083,7 @@ impl Input for ParametersBg {
                                 self.update_slider_rects();
                                 self.activity.bump();
                                 self.recompute_scrollbar_raised();
+                                self.rehover_after_scroll(ui);
                             }
                         }
                         // An opaque pane swallows EVERY wheel over it, whether
@@ -3964,6 +3998,51 @@ mod tests {
         assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
     }
 
+    /// A scroll moves the rows under a still pointer, and the hover follows
+    /// the rows: the control that was under the pointer goes dark and the one
+    /// now there lights, without a pointer motion. The pointer's last
+    /// position is what the pane re-hovers from, so the wheel's own position
+    /// (over the label column, where the pane takes it) need not be it.
+    #[test]
+    fn a_scroll_re_hovers_the_control_under_a_still_pointer() {
+        let mut ctx = UiContext::new();
+        let rows: Vec<(String, String, String)> = (0..30)
+            .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
+            .collect();
+        let mut p = ParametersBg::new();
+        ParamController::set_display_params(&mut *p, &rows);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
+        let hovered = |p: &Adapted<ParametersBg>| -> Vec<usize> {
+            p.sliders.iter().enumerate().filter(|(_, s)| s.as_ref().map_or(false, |s| s.inner().hovered())).map(|(i, _)| i).collect()
+        };
+        assert!(hovered(&p).is_empty());
+
+        // Over the first row's control.
+        let r0 = p.get_param_rects()[0];
+        let (px, py) = (250.0, r0.1 + r0.3 * 0.5);
+        p.on_cursor_moved(px, py, &mut ctx);
+        assert_eq!(hovered(&p), vec![0], "the slider under the pointer hovers");
+
+        // A wheel over the label column scrolls the pane: a notch moves the
+        // TARGET and the rows glide there over the following ticks, so the
+        // re-hover that matters is the tick's. The pointer's stored position
+        // has not moved, but the rows under it have.
+        ctx.scroll_gesture_new = true;
+        let before = p.scroll_y;
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, py, &mut ctx);
+        for _ in 0..120 {
+            WidgetHost::tick(&mut p, 1.0 / 60.0, &mut ctx);
+        }
+        assert!(p.scroll_y > before, "the pane scrolled: {} -> {}", before, p.scroll_y);
+        let now = p
+            .get_param_rects()
+            .iter()
+            .position(|r| py >= r.1 && py <= r.1 + r.3)
+            .expect("a row under the pointer after the scroll");
+        assert_ne!(now, 0, "a different row is under the pointer");
+        assert_eq!(hovered(&p), vec![now], "the hover followed the rows, without a motion");
+    }
+
     /// A gesture the pane acquired stays the pane's: rows travelling under
     /// the pointer mid-gesture must not hand the wheel to the slider that
     /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 79a46a6..0f93abe 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -48,6 +48,12 @@ pub struct Slider {
     focused: bool,
     /// Readout / edit-buffer display precision (decimal places).
     decimals: usize,
+    /// The pointer is over the row (`MouseEnter` / `MouseLeave`, synthesized
+    /// by the adapter's hover bookkeeping): the band lifts, the way a well's
+    /// frame or a dropdown's border does. A slider had no hover at all until
+    /// 2026-09-28, so a params pane answered the pointer on every row but
+    /// its sliders.
+    hovered: bool,
 }
 
 impl Slider {
@@ -65,6 +71,7 @@ impl Slider {
             editor_state: TextEditorState::new(String::new()),
             just_changed: false,
             label: None,
+            hovered: false,
             scroll_vel: 0.0,
             last_wheel: None,
             focused: false,
@@ -154,17 +161,26 @@ impl Slider {
     /// The slider: the band spanning the whole track, swelling at the value
     /// (`paint_band_shape`).
     fn paint_band(&self, g: &SliderGeom, ctx: &mut PaintCtx) {
-        // A band has no rim to light: focused, the band itself is the highlight.
+        // A band has no rim to light: focused, the band itself is the
+        // highlight; hovered, it lifts by the dropdown border's step.
         let color = if self.dragging {
             colors::slider_thumb_drag()
         } else if self.focused {
             crate::color::highlight_primary_color()
+        } else if self.hovered {
+            let c = colors::slider_thumb();
+            [(c[0] + 0.15).min(1.0), (c[1] + 0.15).min(1.0), (c[2] + 0.15).min(1.0), c[3]]
         } else {
             colors::slider_thumb()
         };
         paint_band_shape(ctx, g.track_x, g.track_w, g.y + g.h * 0.5, color, &|x| self.band_height_at(g, x));
     }
 
+    /// The pointer is over the row.
+    pub fn hovered(&self) -> bool {
+        self.hovered
+    }
+
     fn scaled_string(&self) -> String {
         format!("{:.*}", self.decimals, self.min + self.value * (self.max - self.min))
     }
@@ -406,6 +422,14 @@ impl Input for Slider {
                 }
                 false
             }
+            Event::MouseEnter => {
+                self.hovered = true;
+                true
+            }
+            Event::MouseLeave => {
+                self.hovered = false;
+                true
+            }
             Event::FocusIn => {
                 self.focused = true;
                 true
@@ -1018,6 +1042,33 @@ fn probe_slider_bridge() {
         assert!(sl.inner().value() < before, "scroll up decreases value");
         assert!(sl.take_change());
     }
+
+    /// A slider hovers like every other control: the adapter's hover
+    /// bookkeeping turns a move over the row into `MouseEnter`, a move away
+    /// into `MouseLeave`, and the band reads the flag. A float3's three rows
+    /// each hover on their own, since the group forwards the move to them.
+    #[test]
+    fn a_slider_hovers_under_the_pointer() {
+        let mut ctx = UiContext::new();
+        let mut sl = Slider::new();
+        let (id, ptr) = (sl.id(), sl.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        WidgetHost::set_rect(&mut sl, 0.0, 0.0, 100.0, 20.0);
+        assert!(!sl.inner().hovered());
+        assert!(sl.on_cursor_moved(50.0, 10.0, &mut ctx), "entering is a change");
+        assert!(sl.inner().hovered());
+        assert!(!sl.on_cursor_moved(60.0, 10.0, &mut ctx), "moving within is not");
+        assert!(sl.on_cursor_moved(500.0, 10.0, &mut ctx), "leaving is");
+        assert!(!sl.inner().hovered());
+
+        let mut f = crate::widget::display::Float3::new();
+        WidgetHost::set_rect(&mut f, 0.0, 0.0, 200.0, crate::widget::display::Float3::preferred_height(false));
+        let rows = f.inner().get_row_rects();
+        let (_, y1, _, h1) = rows[1];
+        assert!(f.on_cursor_moved(100.0, y1 + h1 * 0.5, &mut ctx));
+        let hovered: Vec<bool> = f.inner().sliders().iter().map(|s| s.inner().hovered()).collect();
+        assert_eq!(hovered, vec![false, true, false], "the row under the pointer, and only it");
+    }
 }
 
 #[cfg(test)]