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

commite203d6012456c54b71078db3c2c7e2e6d7929720
parent8d253c8b0a
authorLucas Galante <[email protected]>
date2026-09-05 20:22
feat(float3): three standard Slider rows instead of the bespoke flat track

Float3 (the designer's vector3 parameter rows) drew its own flat track and
square thumb, so under the DE's band style the X/Y/Z rows looked nothing like
the slider rows beside them. It is now a labeled group of three Adapted<Slider>
children — readouts on, no per-row label, an axis letter in front of each —
laid out below the ordinary detached control label (Slider's own convention:
the label eats into the assigned rect, the host sizes the row through
Float3::preferred_height). Paint, presses, drags, readout edits, keys, wheel
(band halo + gesture latch, ungated forward) and the wheel glide all delegate
to the rows, so they look and feel exactly like a single slider row.

ParametersBg reads the group through the new accessors (values, editing_idx,
value_string, sliders + get_row_rects) and gains float3 branches in the
rounded-quad, relief and sphere views and the glide tick, so every slider
style — band, recessed well, square — reaches the pane for these rows too.

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

 src/widget/container/parameters_bg.rs | 120 ++++----
 src/widget/display/float3.rs          | 521 +++++++++++++++++++---------------
 2 files changed, 355 insertions(+), 286 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 93ff438..e4067b9 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -206,7 +206,7 @@ impl ParametersBg {
             // curve plot (Ramp::graph_h = height − strip).
             260.0
         } else if p.2.starts_with("float3") {
-            108.0
+            Float3::preferred_height(true)
         } else if p.2.starts_with("slider") {
             38.0
         } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
@@ -841,11 +841,7 @@ impl ParametersBg {
                 } else if p.2.starts_with("float3") {
                     if let Some(f) = &mut self.float3s[idx] {
                         f.unfocus();
-                        let (min, max) = parse_slider_range(&p.2);
-                        let val0 = min + f.values[0] * (max - min);
-                        let val1 = min + f.values[1] * (max - min);
-                        let val2 = min + f.values[2] * (max - min);
-                        p.1 = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
+                        p.1 = f.value_string();
                     }
                 } else if is_text_row(&p.2) {
                     if let Some(tb) = &mut self.texts[idx] {
@@ -1036,6 +1032,12 @@ impl ParametersBg {
                 if let Some(s) = &self.sliders[i] {
                     out.extend(s.all_rounded_quads(ctx));
                 }
+            } else if p.2.starts_with("float3") {
+                // Three slider rows: the same set per row (readout boxes,
+                // square-style tracks and fills), through the group's own paint.
+                if let Some(f) = &self.float3s[i] {
+                    out.extend(f.all_rounded_quads(ctx));
+                }
             } else if p.2.starts_with("choice") {
                 if let Some(d) = &self.choices[i] {
                     out.extend(d.all_rounded_quads(ctx));
@@ -1209,6 +1211,20 @@ impl ParametersBg {
                 self.spinboxes[i]
                     .as_ref()
                     .map(|w| (w as &dyn WidgetHost, crate::layout::spinbox_corner_radius(), false))
+            } else if p.2.starts_with("float3") {
+                // Three standard slider rows: each row's track carve over its
+                // own row rect (an unlabeled slider's content rect is its
+                // whole rect), the slider-row entry below three times.
+                if let Some(f) = &self.float3s[i] {
+                    for (s, r) in f.sliders().iter().zip(f.get_row_rects()) {
+                        if let Some((rx, ry, rw, rh, rr, rd)) =
+                            s.inner().track_relief(Rect { x: r.0, y: r.1, width: r.2, height: r.3 })
+                        {
+                            out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
+                        }
+                    }
+                }
+                None
             } else {
                 if let Some(s) = &self.sliders[i] {
                     let (x, y, w, h) = s.rect();
@@ -1336,7 +1352,23 @@ impl ParametersBg {
         let mut out = Vec::new();
         let hidden = self.hidden_rows();
         for (i, p) in self.display_params.iter().enumerate() {
-            if hidden[i] || !p.2.starts_with("slider") {
+            if hidden[i] {
+                continue;
+            }
+            if p.2.starts_with("float3") {
+                // The group's three slider rows, each knob over its row rect.
+                if let Some(f) = &self.float3s[i] {
+                    for (s, r) in f.sliders().iter().zip(f.get_row_rects()) {
+                        if let Some(sphere) =
+                            s.inner().thumb_sphere(Rect { x: r.0, y: r.1, width: r.2, height: r.3 })
+                        {
+                            out.push(sphere);
+                        }
+                    }
+                }
+                continue;
+            }
+            if !p.2.starts_with("slider") {
                 continue;
             }
             if let Some(s) = &self.sliders[i] {
@@ -1714,13 +1746,8 @@ impl Input for ParametersBg {
                 }
             } else if let Some(f) = &mut self.float3s[i] {
                 if f.drag_update(px, py) {
-                    let (min, max) = parse_slider_range(&self.display_params[i].2);
-                    let val0 = min + f.values[0] * (max - min);
-                    let val1 = min + f.values[1] * (max - min);
-                    let val2 = min + f.values[2] * (max - min);
-                    let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                    let old_val = &self.display_params[i].1;
-                    if *old_val != new_val_str {
+                    let new_val_str = f.value_string();
+                    if self.display_params[i].1 != new_val_str {
                         self.display_params[i].1 = new_val_str;
                         return true;
                     }
@@ -1791,6 +1818,18 @@ impl Input for ParametersBg {
                 }
             }
         }
+        // Float3 rows are three slider rows: same glide, same fold-back.
+        for i in 0..self.float3s.len() {
+            if let Some(f) = &mut self.float3s[i] {
+                if f.tick(dt, &mut dummy) {
+                    let new_val_str = f.value_string();
+                    if self.display_params[i].1 != new_val_str {
+                        self.display_params[i].1 = new_val_str;
+                    }
+                    changed = true;
+                }
+            }
+        }
         // Ramp rows tick their field widgets (preset application, slider→key
         // sync) and drain their change flag — fold the curve back into the row
         // value when it moved.
@@ -2277,7 +2316,7 @@ impl Input for ParametersBg {
                             if py >= r.1 && py <= r.1 + r.3 {
                                 if let Some(f) = &mut self.float3s[i] {
                                     if f.mouse_input(button, state, px, py, ui) {
-                                        if f.editing_idx.is_some() {
+                                        if f.editing_idx().is_some() {
                                             self.focused_param = Some(i);
                                             clicked_any_focusable = true;
                                         }
@@ -2509,12 +2548,8 @@ impl Input for ParametersBg {
                         } else if p.2.starts_with("float3") {
                             if let Some(f) = &mut self.float3s[idx] {
                                 if f.keyboard_input(event, ui) {
-                                    let (min, max) = parse_slider_range(&p.2);
-                                    let val0 = min + f.values[0] * (max - min);
-                                    let val1 = min + f.values[1] * (max - min);
-                                    let val2 = min + f.values[2] * (max - min);
-                                    p.1 = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                                    if f.editing_idx.is_none() {
+                                    p.1 = f.value_string();
+                                    if f.editing_idx().is_none() {
                                         self.focused_param = None;
                                     }
                                     return true;
@@ -2597,33 +2632,17 @@ impl Input for ParametersBg {
                         }
                     } else if p.2.starts_with("float3") {
                         let r = rects[i];
-                        let row_y = r.1;
-                        if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                        if py >= r.1 - 2.0 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                            // The group's rows apply the slider-row contract
+                            // themselves (band halo / gesture latch, else the
+                            // row strip; ungated forward).
                             if let Some(f) = &mut self.float3s[i] {
-                                let rects_inner = f.get_row_rects();
-                                for j in 0..3 {
-                                    let r_inner = rects_inner[j];
-                                    if py >= r_inner.1 && py <= r_inner.1 + r_inner.3 {
-                                        wheel_taken = true;
-                                        let scroll_amount = delta.notches_y();
-                                        let step = 0.02;
-                                        let new_val = (f.values[j] - scroll_amount * step).clamp(0.0, 1.0);
-                                        if (new_val - f.values[j]).abs() > 0.0001 {
-                                            f.values[j] = new_val;
-                                            if f.editing_idx == Some(j) {
-                                                let scaled_val = f.mins[j] + f.values[j] * (f.maxs[j] - f.mins[j]);
-                                                f.edit_buffer = format!("{:.2}", scaled_val);
-                                            }
-                                            let (min, max) = parse_slider_range(&p.2);
-                                            let val0 = min + f.values[0] * (max - min);
-                                            let val1 = min + f.values[1] * (max - min);
-                                            let val2 = min + f.values[2] * (max - min);
-                                            let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                                            if p.1 != new_val_str {
-                                                p.1 = new_val_str;
-                                                changed = true;
-                                            }
-                                        }
+                                if f.wheel(delta, px, py, ui) {
+                                    wheel_taken = true;
+                                    let new_val_str = f.value_string();
+                                    if p.1 != new_val_str {
+                                        p.1 = new_val_str;
+                                        changed = true;
                                     }
                                 }
                             }
@@ -2919,12 +2938,7 @@ impl ParamController for ParametersBg {
                     } else if let Some(ref mut f) = self.float3s[i] {
                         let (min, max) = parse_slider_range(&p_new.2);
                         // Same round-trip guard as the slider row.
-                        let cur_str = format!(
-                            "{:.2}:{:.2}:{:.2}",
-                            min + f.values[0] * (max - min),
-                            min + f.values[1] * (max - min),
-                            min + f.values[2] * (max - min)
-                        );
+                        let cur_str = f.value_string();
                         if cur_str != p_new.1 {
                             let vals = parse_float3_value(&p_new.1, min, max);
                             f.set_values(vals);
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 0f7daa8..5d3e351 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -1,97 +1,206 @@
-//! Narrow-trait `Float3` (Phase 5t) — three labeled slider rows (X/Y/Z) with click-to-edit
-//! numeric readouts, embedded by value inside `ParametersBg` (its only consumer), which drives
-//! it through direct `WidgetHost` calls and reads the pub value/edit fields through `Deref`. The
-//! model caches its laid-out rect ([`Layout::rect_assigned`] — `get_row_rects` is pub API with
-//! no rect parameter), draws everything in [`Paint::paint`], and keeps the legacy drag surface
-//! on the `Input` drag hooks. The readout click's legacy `focus::set_focused(self)` rides
-//! `EventCtx::request_focus`.
-
-use crate::colors;
-use crate::scene::layout::Rect;
+//! Narrow-trait `Float3` — a labeled group of three STANDARD [`Slider`]s (X/Y/Z), each with
+//! the toolkit's readout, embedded by value inside `ParametersBg` (its only consumer), which
+//! drives it through direct `WidgetHost` calls. The group label is the ordinary detached
+//! control label (the adapter's, exactly like a slider row's — `inflates_label_rect = false`,
+//! the label eats into the assigned rect); below it sit three `Adapted<Slider>` children in
+//! whatever style the DE config gives every other slider (the band that swallowed the rodent,
+//! the recessed well, the square track), each fronted by its axis letter. The model caches its
+//! laid-out rect ([`Layout::rect_assigned`]) and lays the children out from it; paint and input
+//! delegate to them, so the rows look and feel like a plain slider row rather than the bespoke
+//! flat track + square thumb this widget used to draw.
+//!
+//! Hosts reading this panel through the legacy flat views get the children's quads, rounded
+//! rects and text through the adapter's prim bridges (the sub-sliders paint into this widget's
+//! own [`Paint::paint`]); their relief prims (recessed-track carve, thumb sphere) cannot ride
+//! those views and travel through [`Float3::sliders`] + [`Float3::get_row_rects`] instead, the
+//! way `ParametersBg::reliefs` / `spheres` read a slider row.
+
+use crate::context::UiContext;
+use crate::scene::layout::{Rect, Size};
 use crate::scene::paint::PaintCtx;
+use crate::widget::input::Slider;
 use crate::widget::{
-    Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, NamedKey, Paint,
-    TextEditorState,
+    Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, MouseScrollDelta,
+    Paint, WidgetHost,
 };
 
+/// Vertical gap between the three slider rows.
+const ROW_GAP: f32 = 4.0;
+/// The axis-letter column left of each slider.
+const AXIS_W: f32 = 16.0;
+/// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
+const DECIMALS: usize = 2;
+
 pub struct Float3 {
+    /// The assigned (label-inclusive) rect.
     rect: Rect,
-    pub values: [f32; 3],
-    pub(crate) mins: [f32; 3],
-    pub(crate) maxs: [f32; 3],
-    labels: [String; 3],
+    sliders: [Adapted<Slider>; 3],
+    axes: [&'static str; 3],
     label: Option<String>,
     dragging_idx: Option<usize>,
-    drag_offset: f32,
-    pub editing_idx: Option<usize>,
-    pub edit_buffer: String,
 }
 
 impl Float3 {
     pub fn new() -> Adapted<Float3> {
+        let row = || Slider::new().with_readout(true).with_decimals(DECIMALS);
         Adapted::new(Float3 {
             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
-            values: [0.5, 0.5, 0.5],
-            mins: [0.0, 0.0, 0.0],
-            maxs: [1.0, 1.0, 1.0],
-            labels: ["X".to_string(), "Y".to_string(), "Z".to_string()],
+            sliders: [row(), row(), row()],
+            axes: ["X", "Y", "Z"],
             label: None,
             dragging_idx: None,
-            drag_offset: 0.0,
-            editing_idx: None,
-            edit_buffer: String::new(),
         })
     }
 
+    /// The height a labeled (`labeled`) group lays out to: the detached label band (none in
+    /// the side layout) plus three slider rows and their gaps — the row-height table entry.
+    pub fn preferred_height(labeled: bool) -> f32 {
+        let top = if labeled && crate::layout::control_label_layout() != "side" {
+            let (_, font_size) = crate::layout::control_label_font_detached_parsed();
+            font_size + crate::layout::control_label_margin()
+        } else {
+            0.0
+        };
+        top + 3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
+    }
+
+    /// Normalized (0..1) values, X/Y/Z.
+    pub fn values(&self) -> [f32; 3] {
+        [self.sliders[0].value, self.sliders[1].value, self.sliders[2].value]
+    }
+
+    /// Normalized values in; each row clamps to 0..1.
     pub fn set_values(&mut self, values: [f32; 3]) {
-        self.values = values;
+        for (s, v) in self.sliders.iter_mut().zip(values) {
+            s.set_value(v);
+        }
     }
 
+    /// The row whose readout is open for typing, if any.
+    pub fn editing_idx(&self) -> Option<usize> {
+        self.sliders.iter().position(|s| s.editing)
+    }
+
+    /// The scaled values as the `x:y:z` row string (`DECIMALS` places) the parameter pane
+    /// stores — the one formatter for every host sync.
+    pub fn value_string(&self) -> String {
+        let v = |i: usize| self.sliders[i].get_scaled_value();
+        format!("{:.*}:{:.*}:{:.*}", DECIMALS, v(0), DECIMALS, v(1), DECIMALS, v(2))
+    }
+
+    /// The three rows, X/Y/Z — for hosts that draw this group through the legacy flat views
+    /// and need each row's relief prims (`track_relief`, `thumb_sphere`) over its
+    /// [`Self::get_row_rects`] rect.
+    pub fn sliders(&self) -> &[Adapted<Slider>; 3] {
+        &self.sliders
+    }
+
+    /// Detached-label band above the rows — a replica of `Widget::label_offset` over the
+    /// synced label (zero in the side layout or unlabeled), the Slider's own formula.
+    fn label_top(&self) -> f32 {
+        if crate::layout::control_label_layout() == "side" {
+            return 0.0;
+        }
+        if self.label.is_some() {
+            let (_, font_size) = crate::layout::control_label_font_detached_parsed();
+            font_size + crate::layout::control_label_margin()
+        } else {
+            0.0
+        }
+    }
+
+    /// The side-layout label inset (`WidgetHost::label_x_offset` — this type is not exempt).
+    fn side_offset(&self) -> f32 {
+        if crate::layout::control_label_layout() == "side" && self.label.is_some() {
+            90.0
+        } else {
+            0.0
+        }
+    }
+
+    /// The three slider rows' rects (`(x, y, w, h)`, X/Y/Z), laid out below the label band and
+    /// right of the axis-letter column. Each is exactly the rect its sub-slider is assigned.
     pub fn get_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
-        let mut rects = Vec::new();
-        let by = self.rect.y + 20.0;
-        for i in 0..3 {
-            rects.push((self.rect.x + 8.0, by + 6.0 + i as f32 * 26.0, self.rect.width - 16.0, 20.0));
+        let top = self.rect.y + self.label_top();
+        let side = self.side_offset();
+        let x = self.rect.x + side + AXIS_W;
+        let w = (self.rect.width - side - AXIS_W).max(10.0);
+        let h = crate::layout::slider_height();
+        (0..3).map(|i| (x, top + i as f32 * (h + ROW_GAP), w, h)).collect()
+    }
+
+    fn layout_rows(&mut self) {
+        let rows = self.get_row_rects();
+        for (s, r) in self.sliders.iter_mut().zip(rows) {
+            s.set_rect(r.0, r.1, r.2, r.3);
         }
-        rects
     }
 
-    /// Commit the in-flight readout edit back into the value — the legacy `unfocus` body.
-    fn commit_edit(&mut self) {
-        if let Some(i) = self.editing_idx.take() {
-            if let Ok(new_val) = self.edit_buffer.parse::<f32>() {
-                let range = self.maxs[i] - self.mins[i];
-                if range != 0.0 {
-                    self.values[i] = ((new_val - self.mins[i]) / range).clamp(0.0, 1.0);
-                } else {
-                    self.values[i] = 0.0;
-                }
+    /// Wheel over the rows, the parameter pane's slider-row contract: under the band style the
+    /// capture zone is each row's shape halo ([`Slider::scroll_hit`]) or its gesture latch,
+    /// otherwise the row's rect; a row in zone takes the wheel ungated (the halo already gated
+    /// spatially, and the adapter's rect gate would clip its fringe). Returns whether a row took
+    /// it, whether or not the value string ticked over.
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ui: &mut UiContext) -> bool {
+        let rows = self.get_row_rects();
+        let band = crate::layout::slider_band();
+        for (s, r) in self.sliders.iter_mut().zip(rows) {
+            let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
+            let in_zone = if band {
+                let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(s.base().id());
+                latched || s.inner().scroll_hit(rect, px, py)
+            } else {
+                py >= rect.y && py <= rect.y + rect.height
+            };
+            if !in_zone {
+                continue;
+            }
+            let was_scroll = s.scroll_enabled;
+            s.set_scroll(true);
+            let taken = s.mouse_wheel_ungated(delta, px, py, ui);
+            s.set_scroll(was_scroll);
+            if taken {
+                return true;
             }
         }
+        false
     }
 }
 
 impl Adapted<Float3> {
     pub fn with_values(mut self, values: [f32; 3]) -> Self {
-        self.values = values;
+        self.set_values(values);
         self
     }
 
     pub fn with_range(mut self, min: f32, max: f32) -> Self {
-        self.mins = [min, min, min];
-        self.maxs = [max, max, max];
+        for s in self.sliders.iter_mut() {
+            s.set_range(min, max);
+        }
         self
     }
 }
 
 impl Layout for Float3 {
-    /// The control label draws inside the rect (row one's header), like legacy.
-    fn inline_label(&self) -> bool {
-        true
+    /// The Slider convention: the label eats into the assigned rect, the host sizes the row
+    /// for it ([`Float3::preferred_height`]).
+    fn inflates_label_rect(&self) -> bool {
+        false
+    }
+
+    /// Detached label x inset — the Slider/Dropdown value, so the group label lines up with a
+    /// slider row's.
+    fn detached_label_inset(&self) -> f32 {
+        4.0
+    }
+
+    fn intrinsic_size(&self) -> Option<Size> {
+        Some(Size::new(0.0, Float3::preferred_height(self.label.is_some())))
     }
 
     fn rect_assigned(&mut self, rect: Rect) {
         self.rect = rect;
+        self.layout_rows();
     }
 }
 
@@ -100,77 +209,30 @@ impl Paint for Float3 {
         [0.0, 0.0, 0.0, 0.0]
     }
 
+    fn widget_font(&self) -> Option<String> {
+        Some(crate::layout::control_label_font_detached())
+    }
+
     fn sync_label(&mut self, label: &str) {
         self.label = Some(label.to_string());
+        self.layout_rows();
     }
 
+    /// The axis letters and the three rows, each painted by its own slider over its row rect
+    /// (an unlabeled slider's content rect is its whole rect). The group label is the adapter's
+    /// detached label, like a slider row's.
     fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
-        // Outline border box
-        let bx = self.rect.x + 4.0;
-        let bw = self.rect.width - 8.0;
-        let by = self.rect.y + 20.0;
-        let bh = 84.0;
-        let border_color = [0.18, 0.18, 0.27, 1.0];
-        let border_t = 1.0;
-
-        ctx.quad(Rect { x: bx, y: by, width: bw, height: border_t }, border_color);
-        ctx.quad(Rect { x: bx, y: by + bh - border_t, width: bw, height: border_t }, border_color);
-        ctx.quad(Rect { x: bx, y: by, width: border_t, height: bh }, border_color);
-        ctx.quad(Rect { x: bx + bw - border_t, y: by, width: border_t, height: bh }, border_color);
-
-        if let Some(ref l) = self.label {
-            ctx.text(l.clone(), self.rect.x + 8.0, self.rect.y + 2.0, 13.0, [0xee, 0xee, 0xf0]);
-        }
-
-        let rects = self.get_row_rects();
-        for i in 0..3 {
-            let r = rects[i];
-            let track_x = self.rect.x + 100.0;
-            let track_w = self.rect.width - 188.0;
-            let track_y = r.1 + 4.0;
-            let track_h = 12.0;
-
-            ctx.quad(Rect { x: track_x, y: track_y, width: track_w, height: track_h }, colors::slider_track());
-
-            let thumb_size = track_h * 0.9;
-            let range = track_w - thumb_size;
-            let thumb_x = track_x + self.values[i] * range;
-            let thumb_color = if self.dragging_idx == Some(i) {
-                colors::SLIDER_THUMB_DRAG
-            } else {
-                colors::SLIDER_THUMB
-            };
-            ctx.quad(
-                Rect { x: thumb_x, y: track_y + (track_h - thumb_size) / 2.0, width: thumb_size, height: thumb_size },
-                thumb_color,
+        let rows = self.get_row_rects();
+        for (i, r) in rows.into_iter().enumerate() {
+            let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
+            ctx.text(
+                self.axes[i].to_string(),
+                rect.x - AXIS_W + 2.0,
+                crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
+                12.0,
+                [0xaa, 0xaa, 0xbb],
             );
-
-            let rx = self.rect.x + self.rect.width - 68.0;
-            let bg_color = if self.editing_idx == Some(i) {
-                [0.06, 0.10, 0.18, 1.0]
-            } else {
-                [0.10, 0.10, 0.13, 1.0]
-            };
-            ctx.quad(Rect { x: rx, y: track_y, width: 60.0, height: track_h }, bg_color);
-
-            if self.editing_idx == Some(i) {
-                let border_color = [0.20, 0.50, 0.85, 1.0];
-                ctx.quad(Rect { x: rx, y: track_y, width: 60.0, height: border_t }, border_color);
-                ctx.quad(Rect { x: rx, y: track_y + track_h - border_t, width: 60.0, height: border_t }, border_color);
-                ctx.quad(Rect { x: rx, y: track_y, width: border_t, height: track_h }, border_color);
-                ctx.quad(Rect { x: rx + 60.0 - border_t, y: track_y, width: border_t, height: track_h }, border_color);
-            }
-
-            // Row label + readout text (the legacy `text_labels` body).
-            let ry = track_y;
-            ctx.text(self.labels[i].clone(), self.rect.x + 16.0, ry - 2.0, 12.0, [0xaa, 0xaa, 0xbb]);
-            let text = if self.editing_idx == Some(i) {
-                self.edit_buffer.clone()
-            } else {
-                let scaled_val = self.mins[i] + self.values[i] * (self.maxs[i] - self.mins[i]);
-                format!("{:.2}", scaled_val)
-            };
-            ctx.text(text, rx + 8.0, ry - 2.0, 12.0, [0xee, 0xee, 0xf0]);
+            Paint::paint(&*self.sliders[i], rect, ctx);
         }
     }
 }
@@ -184,32 +246,51 @@ impl Input for Float3 {
         self.dragging_idx.is_some()
     }
 
-    fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {}
-
-    fn drag_update(&mut self, px: f32, _py: f32, _rect: Rect) -> bool {
-        if let Some(i) = self.dragging_idx {
-            let track_x = self.rect.x + 100.0;
-            let track_w = self.rect.width - 188.0;
-            let thumb_size = 12.0 * 0.9;
-            let range = track_w - thumb_size;
-            if range > 0.0 {
-                let raw = (px - self.drag_offset - track_x) / range;
-                let new_val = raw.clamp(0.0, 1.0);
-                if (new_val - self.values[i]).abs() > 0.001 {
-                    self.values[i] = new_val;
-                    if self.editing_idx == Some(i) {
-                        let scaled_val = self.mins[i] + self.values[i] * (self.maxs[i] - self.mins[i]);
-                        self.edit_buffer = format!("{:.2}", scaled_val);
-                    }
-                    return true;
-                }
+    /// A host-driven drag begins on the row under the pointer — unless a press already
+    /// started one (the pane's press path), in which case that row keeps it.
+    fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
+        if self.dragging_idx.is_some() {
+            return;
+        }
+        let rows = self.get_row_rects();
+        for (i, r) in rows.into_iter().enumerate() {
+            if py >= r.1 && py <= r.1 + r.3 {
+                self.sliders[i].drag_begin(px, py);
+                self.dragging_idx = Some(i);
+                return;
             }
         }
-        false
+    }
+
+    fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
+        match self.dragging_idx {
+            Some(i) => self.sliders[i].drag_update(px, py),
+            None => false,
+        }
     }
 
     fn drag_end(&mut self) {
-        self.dragging_idx = None;
+        if let Some(i) = self.dragging_idx.take() {
+            self.sliders[i].drag_end();
+        }
+    }
+
+    /// The rows' wheel-glide inertia.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        let mut dummy = UiContext::new();
+        let mut changed = false;
+        for s in self.sliders.iter_mut() {
+            changed |= WidgetHost::tick(s, dt, &mut dummy);
+        }
+        changed
+    }
+
+    fn take_change(&mut self) -> bool {
+        let mut any = false;
+        for s in self.sliders.iter_mut() {
+            any |= s.take_change();
+        }
+        any
     }
 
     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
@@ -218,102 +299,77 @@ impl Input for Float3 {
                 if *button != MouseButton::Left {
                     return false;
                 }
-                let (state, px, py) = (*state, *px, *py);
-                let rects = self.get_row_rects();
-
-                for i in 0..3 {
-                    let r = rects[i];
-                    let rx = self.rect.x + self.rect.width - 68.0;
-                    let ry = r.1 + 4.0;
-                    let rh = 12.0;
-                    let readout_w = 60.0;
-
-                    if px >= rx && px <= rx + readout_w && py >= ry && py <= ry + rh {
-                        if state == ElementState::Pressed {
-                            if self.editing_idx != Some(i) {
-                                self.commit_edit();
-                                self.editing_idx = Some(i);
-                                let scaled_val = self.mins[i] + self.values[i] * (self.maxs[i] - self.mins[i]);
-                                self.edit_buffer = format!("{:.2}", scaled_val);
+                let mut dummy = UiContext::new();
+                match state {
+                    ElementState::Pressed => {
+                        let rows = self.get_row_rects();
+                        for (i, r) in rows.into_iter().enumerate() {
+                            if *py < r.1 || *py > r.1 + r.3 {
+                                continue;
+                            }
+                            // The child's own readout click claims focus through the
+                            // dummy ctx (a no-op beyond the thread-local slot); the
+                            // GROUP is the host's focus target, as before.
+                            if !self.sliders[i].mouse_input(*button, *state, *px, *py, &mut dummy) {
+                                continue;
+                            }
+                            if self.sliders[i].is_dragging() {
+                                self.dragging_idx = Some(i);
+                            }
+                            if self.sliders[i].editing {
+                                for (j, s) in self.sliders.iter_mut().enumerate() {
+                                    if j != i && s.editing {
+                                        s.unfocus();
+                                    }
+                                }
                                 ectx.request_focus();
                             }
-                        }
-                        return true;
-                    }
-                }
-
-                if state == ElementState::Pressed {
-                    for i in 0..3 {
-                        let r = rects[i];
-                        let track_x = self.rect.x + 100.0;
-                        let track_w = self.rect.width - 188.0;
-                        let track_y = r.1 + 4.0;
-                        let track_h = 12.0;
-                        let thumb_size = track_h * 0.9;
-                        let range = track_w - thumb_size;
-                        let thumb_x = track_x + self.values[i] * range;
-
-                        if px >= track_x && px <= track_x + track_w && py >= track_y && py <= track_y + track_h {
-                            self.dragging_idx = Some(i);
-                            self.drag_offset = px - thumb_x;
                             return true;
                         }
+                        false
                     }
-                } else if state == ElementState::Released {
-                    if self.dragging_idx.is_some() {
-                        self.dragging_idx = None;
-                        return true;
+                    ElementState::Released => {
+                        let mut any = false;
+                        for s in self.sliders.iter_mut() {
+                            any |= s.mouse_input(*button, *state, *px, *py, &mut dummy);
+                        }
+                        if self.dragging_idx.take().is_some() {
+                            any = true;
+                        }
+                        any
                     }
                 }
-                false
             }
-            Event::KeyInput(event) => {
-                if self.editing_idx.is_none() {
-                    return false;
+            Event::MouseWheel { delta, x: px, y: py, .. } => {
+                let mut dummy = UiContext::new();
+                let ui = ectx.ui.as_deref_mut();
+                match ui {
+                    Some(ui) => self.wheel(delta, *px, *py, ui),
+                    None => self.wheel(delta, *px, *py, &mut dummy),
                 }
-                if event.state != ElementState::Pressed {
-                    return false;
+            }
+            Event::PointerMove { x: px, y: py, .. } => {
+                let mut dummy = UiContext::new();
+                let mut changed = false;
+                for s in self.sliders.iter_mut() {
+                    changed |= s.on_cursor_moved(*px, *py, &mut dummy);
                 }
-
-                let mut state = TextEditorState {
-                    buffer: self.edit_buffer.clone(),
-                    cursor_idx: self.edit_buffer.chars().count(),
-                    select_anchor: None,
-                    all_selected: false,
-                };
-
-                let mut handled = false;
-                match &event.logical_key {
-                    Key::Named(NamedKey::Backspace) => {
-                        state.delete_backwards();
-                        handled = true;
-                    }
-                    Key::Named(NamedKey::Enter) => {
-                        self.commit_edit();
-                        handled = true;
-                    }
-                    Key::Named(NamedKey::Escape) => {
-                        self.editing_idx = None;
-                        handled = true;
-                    }
-                    Key::Character(s) => {
-                        for ch in s.chars() {
-                            if ch.is_ascii_digit() || ch == '.' || (ch == '-' && state.buffer.is_empty()) {
-                                state.insert_text(&ch.to_string());
-                            }
-                        }
-                        handled = true;
+                changed
+            }
+            Event::KeyInput(key_event) => {
+                let mut dummy = UiContext::new();
+                for s in self.sliders.iter_mut() {
+                    if s.editing {
+                        return s.keyboard_input(key_event, &mut dummy);
                     }
-                    _ => {}
-                }
-
-                if self.editing_idx.is_some() {
-                    self.edit_buffer = state.buffer;
                 }
-                handled
+                false
             }
+            // Focus loss commits every open readout edit (each row's own FocusOut).
             Event::FocusOut => {
-                self.commit_edit();
+                for s in self.sliders.iter_mut() {
+                    s.unfocus();
+                }
                 true
             }
             _ => false,
@@ -324,37 +380,36 @@ impl Input for Float3 {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::context::UiContext;
-    use crate::widget::WidgetHost;
 
-    /// The ParametersBg drive pattern: readout click opens the edit, Enter/unfocus commits
-    /// back into the normalized value, track press starts a drag.
+    /// The ParametersBg drive pattern: readout click opens the row's edit, Enter/unfocus
+    /// commits back into the normalized value, a track press starts a drag.
     #[test]
     fn readout_edit_commits_on_unfocus() {
         let mut ctx = UiContext::new();
         let mut f = Float3::new().with_values([0.5, 0.5, 0.5]).with_range(0.0, 10.0);
-        WidgetHost::set_rect(&mut f, 0.0, 0.0, 300.0, 108.0);
+        WidgetHost::set_rect(&mut f, 0.0, 0.0, 300.0, Float3::preferred_height(false));
 
         let rows = f.get_row_rects();
         assert_eq!(rows.len(), 3);
-        // Click row 1's readout (x within [w-68, w-8], y within row top+4..+16).
-        let rx = 300.0 - 68.0 + 5.0;
-        let ry = rows[1].1 + 8.0;
+        assert_eq!(f.value_string(), "5.00:5.00:5.00");
+        // Click row 1's readout (the 60px box at the row's right end).
+        let rx = rows[1].0 + rows[1].2 - 30.0;
+        let ry = rows[1].1 + rows[1].3 * 0.5;
         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, rx, ry, &mut ctx));
-        assert_eq!(f.editing_idx, Some(1));
-        assert_eq!(f.edit_buffer, "5.00");
+        assert_eq!(f.editing_idx(), Some(1));
 
-        f.edit_buffer = "7.5".to_string();
+        f.sliders[1].set_value_string("7.5");
         WidgetHost::unfocus(&mut f);
-        assert_eq!(f.editing_idx, None);
-        assert!((f.values[1] - 0.75).abs() < 1e-4, "7.5 of 0..10 normalizes to 0.75");
+        assert_eq!(f.editing_idx(), None);
+        assert!((f.values()[1] - 0.75).abs() < 1e-4, "7.5 of 0..10 normalizes to 0.75");
 
         // Track press starts a drag; drag_update moves the value; release ends it.
-        let track_y = rows[0].1 + 8.0;
-        assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, 150.0, track_y, &mut ctx));
+        let track_x = rows[0].0 + 20.0;
+        let track_y = rows[0].1 + rows[0].3 * 0.5;
+        assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, track_x, track_y, &mut ctx));
         assert!(f.is_dragging());
-        f.drag_update(260.0, track_y);
-        assert!(f.values[0] > 0.5, "drag right raises the value");
+        f.drag_update(track_x + 100.0, track_y);
+        assert!(f.values()[0] > 0.5, "drag right raises the value");
         f.drag_end();
         assert!(!f.is_dragging());
     }