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

commitb6d4cb5c45ad31059ca89e3e4088e48f4ad8a193
parent90aea0d035
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 14:44
feat: float2 and float4 parameter rows are the float3 group with two or four sliders

Float3 holds four sliders and lays out, draws, hits and writes the first n
(set_components / with_components, 1..=4, three by default). ParametersBg
treats float2/float3/float4 rows alike and sizes them by their count; only a
three-wide group has a trackball.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             |  13 +++++
 src/widget/container/parameters_bg.rs | 104 ++++++++++++++++++++++++++--------
 src/widget/display/float3.rs          |  77 ++++++++++++++++++++-----
 3 files changed, 158 insertions(+), 36 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 173439d..a879720 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -240,6 +240,19 @@ Layer surfaces keep the in-window menu, placed by `context_menu::constrain_to` w
 same flip / slide / shorten rules inside the window; so does any app run with
 `CCE_UI_MENU_POPUP=0`.
 
+### A float row can be two, three or four wide (since 2026-10-01)
+
+`float2:lo:hi` and `float4:lo:hi` parameter rows are the `float3` row's group with two
+or four sliders (X Y, X Y Z W): `Float3::set_components(n)` / `with_components(n)`,
+four sliders stored with the first `n` laid out, drawn, hit and written
+(`value_string` joins `n` components, `scaled_values` / `set_values_n` read and write
+them). `ParametersBg` treats every `floatN` row alike (`is_vec_row`, `vec_row_n`) and
+sizes it with `Float3::preferred_height_for(labeled, n)`; a row whose width changes on
+a re-read takes the new width in place. Only a three-wide group has a trackball — a
+direction is three numbers — so `float4:…:trackball` has none. The designer's
+Attribute node presents its Value through these.
+`float2_and_float4_rows_are_the_group_with_two_or_four_sliders` is the test.
+
 ### The keyboard can walk a menu (since 2026-10-01)
 
 `context_menu::set_hovered_item(Some(idx))` highlights a row as the pointer would, and
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 3dcb8f0..95aeef2 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -332,7 +332,7 @@ impl ParametersBg {
     /// Whether a float3 row's type asks for the trackball: a fourth segment,
     /// `float3:lo:hi:trackball`.
     fn has_trackball(t: &str) -> bool {
-        t.starts_with("float3") && t.split(':').nth(3) == Some("trackball")
+        t.starts_with("float3:") && t.split(':').nth(3) == Some("trackball")
     }
 
     /// What a row of type `t` spends of its control rect on everything but
@@ -341,7 +341,7 @@ impl ParametersBg {
     fn control_chrome(t: &str) -> f32 {
         if t.starts_with("slider") {
             crate::widget::input::slider::Slider::readout_chrome()
-        } else if t.starts_with("float3") {
+        } else if is_vec_row(t) {
             let ball = if Self::has_trackball(t) { Float3::trackball_chrome() } else { 0.0 };
             ball + crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
         } else {
@@ -355,7 +355,7 @@ impl ParametersBg {
     /// code rows have layouts of their own.
     fn inline_kind(t: &str) -> bool {
         t.starts_with("slider")
-            || t.starts_with("float3")
+            || is_vec_row(t)
             || t.starts_with("spinbox")
             || t.starts_with("choice")
             || is_text_row(t)
@@ -511,8 +511,8 @@ impl ParametersBg {
             // strip and label band are fixed, so this whole increase grows the
             // curve plot (Ramp::graph_h = height − strip).
             260.0
-        } else if p.2.starts_with("float3") {
-            Float3::preferred_height(!self.inline_row(i))
+        } else if is_vec_row(&p.2) {
+            Float3::preferred_height_for(!self.inline_row(i), vec_row_n(&p.2))
         } else if p.2.starts_with("slider") {
             (38.0 - self.inline_strip(i)).max(22.0)
         } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
@@ -1171,7 +1171,7 @@ impl ParametersBg {
                 if let Some(s) = &self.sliders[i] {
                     labels.extend(s.own_text_labels());
                 }
-            } else if ptype.starts_with("float3") {
+            } else if is_vec_row(ptype) {
                 if let Some(f) = &self.float3s[i] {
                     labels.extend(f.own_text_labels());
                 }
@@ -1344,7 +1344,7 @@ impl ParametersBg {
                         let new_val = min + s.value * (max - min);
                         p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
                     }
-                } else if p.2.starts_with("float3") {
+                } else if is_vec_row(&p.2) {
                     if let Some(f) = &mut self.float3s[idx] {
                         f.unfocus();
                         p.1 = f.value_string();
@@ -1435,7 +1435,7 @@ impl ParametersBg {
                 }
             } else if p.2 == "section" {
                 // Section header line is handled by the border box top border now
-            } else if p.2.starts_with("float3") {
+            } else if is_vec_row(&p.2) {
                 if let Some(f) = &self.float3s[i] {
                     param_quads.extend(f.extra_quads());
                 }
@@ -1577,7 +1577,7 @@ impl ParametersBg {
                 if let Some(s) = &self.sliders[i] {
                     out.extend(s.all_rounded_quads(ctx));
                 }
-            } else if p.2.starts_with("float3") {
+            } else if is_vec_row(&p.2) {
                 // 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] {
@@ -2233,7 +2233,7 @@ impl Input for ParametersBg {
     fn draggable(&self, _rect: Rect) -> bool {
         self.scrollbar_dragging
             || self.dragging_param.is_some()
-            || self.display_params.iter().any(|p| p.2.starts_with("slider") || p.2.starts_with("float3"))
+            || self.display_params.iter().any(|p| p.2.starts_with("slider") || is_vec_row(&p.2))
     }
 
     fn is_dragging(&self) -> bool {
@@ -2256,7 +2256,7 @@ impl Input for ParametersBg {
                         break;
                     }
                 }
-            } else if p.2.starts_with("float3") {
+            } else if is_vec_row(&p.2) {
                 let r = rects[i];
                 if py >= r.1 && py <= r.1 + r.3 {
                     if let Some(f) = &mut self.float3s[i] {
@@ -2844,7 +2844,7 @@ impl Input for ParametersBg {
                                     }
                                 }
                             }
-                        } else if p.2.starts_with("float3") {
+                        } else if is_vec_row(&p.2) {
                             let r = rects[i];
                             if py >= r.1 && py <= r.1 + r.3 {
                                 if let Some(f) = &mut self.float3s[i] {
@@ -3130,7 +3130,7 @@ impl Input for ParametersBg {
                                     return true;
                                 }
                             }
-                        } else if p.2.starts_with("float3") {
+                        } else if is_vec_row(&p.2) {
                             if let Some(f) = &mut self.float3s[idx] {
                                 if f.keyboard_input(event, ui) {
                                     p.1 = f.value_string();
@@ -3295,7 +3295,7 @@ impl Input for ParametersBg {
                                 s.set_scroll(was_scroll);
                             }
                         }
-                    } else if p.2.starts_with("float3") {
+                    } else if is_vec_row(&p.2) {
                         if owner == Some(i) {
                             // The group's rows apply the slider-row contract
                             // themselves (band halo / gesture latch, else the
@@ -3391,8 +3391,20 @@ fn slider_decimals(ptype: &str) -> usize {
     ptype.split(':').nth(3).and_then(|s| s.parse().ok()).unwrap_or(2)
 }
 
+/// Whether a row of type `t` is a vector of sliders: `float2`, `float3` or
+/// `float4`, each `floatN:lo:hi` — the one [`Float3`] group with that many
+/// rows ([`Float3::set_components`]).
+fn is_vec_row(t: &str) -> bool {
+    t.starts_with("float2") || t.starts_with("float3") || t.starts_with("float4")
+}
+
+/// How many rows a vector row has: the digit after `float`.
+fn vec_row_n(t: &str) -> usize {
+    t.get(5..6).and_then(|d| d.parse().ok()).unwrap_or(3)
+}
+
 fn parse_slider_range(ptype: &str) -> (f32, f32) {
-    if ptype.starts_with("slider:") || ptype.starts_with("float3:") {
+    if ptype.starts_with("slider:") || (is_vec_row(ptype) && ptype.contains(':')) {
         let parts: Vec<&str> = ptype.split(':').collect();
         if parts.len() >= 3 {
             if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
@@ -3427,13 +3439,15 @@ fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
     (0, 10000, 1)
 }
 
-fn parse_float3_value(val_str: &str, min: f32, max: f32) -> [f32; 3] {
-    let mut out = [0.5, 0.5, 0.5];
+/// A vector row's text as `n` normalized values: each component over the
+/// row's range, mid-range where the text has none.
+fn parse_vec_value(val_str: &str, min: f32, max: f32, n: usize) -> Vec<f32> {
+    let mut out = vec![0.5; n];
     let parts: Vec<&str> = val_str
         .split(|c| c == ':' || c == ',' || c == ' ')
         .filter(|s| !s.is_empty())
         .collect();
-    for i in 0..3 {
+    for i in 0..n {
         if i < parts.len() {
             if let Ok(v) = parts[i].parse::<f32>() {
                 let range = max - min;
@@ -3491,10 +3505,11 @@ impl ParamController for ParametersBg {
                 }
             }).collect();
             self.float3s = self.display_params.iter().map(|p| {
-                if p.2.starts_with("float3") {
+                if is_vec_row(&p.2) {
                     let (min, max) = parse_slider_range(&p.2);
-                    let vals = parse_float3_value(&p.1, min, max);
-                    let mut f = Float3::new().with_values(vals).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
+                    let n = vec_row_n(&p.2);
+                    let mut f = Float3::new().with_components(n).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
+                    f.set_values_n(&parse_vec_value(&p.1, min, max, n));
                     f.set_view(self.trackball_view);
                     Some(if inline { f } else { f.with_label(&p.0) })
                 } else {
@@ -3619,8 +3634,11 @@ impl ParamController for ParametersBg {
                         // Same round-trip guard as the slider row.
                         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);
+                            let n = vec_row_n(&p_new.2);
+                            if f.components() != n {
+                                f.set_components(n);
+                            }
+                            f.set_values_n(&parse_vec_value(&p_new.1, min, max, n));
                         }
                     } else if let Some(ref mut sb) = self.spinboxes[i] {
                         if !sb.editing {
@@ -3948,6 +3966,46 @@ mod tests {
         assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
     }
 
+    /// `float2` and `float4` rows are the float3 group with two or four
+    /// rows (X Y, X Y Z W): that many sliders over the row's range, laid out
+    /// and sized for that many, read from and written back as that many
+    /// components — and no trackball, which a direction of three numbers
+    /// is the only thing to have.
+    #[test]
+    fn float2_and_float4_rows_are_the_group_with_two_or_four_sliders() {
+        let mut p = panel_with(&[
+            ("Two", "1.00:-2.00", "float2:-10:10"),
+            ("Four", "1:2:3:4", "float4:-10:10:trackball"),
+            ("Three", "0:0:0", "float3:-10:10"),
+        ]);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 600.0);
+        let two = p.float3s[0].as_ref().expect("a float2 row is a group");
+        let four = p.float3s[1].as_ref().expect("a float4 row is a group");
+        assert_eq!((two.components(), four.components()), (2, 4));
+        assert_eq!(two.get_row_rects().len(), 2);
+        assert_eq!(four.get_row_rects().len(), 4);
+        assert!(!four.has_trackball(), "four numbers have no ball");
+        assert_eq!(two.value_string(), "1.00:-2.00");
+        assert_eq!(four.value_string(), "1.00:2.00:3.00:4.00");
+        assert!(
+            Float3::preferred_height_for(false, 4) > Float3::preferred_height_for(false, 3)
+                && Float3::preferred_height_for(false, 3) > Float3::preferred_height_for(false, 2)
+        );
+        assert_eq!(Float3::preferred_height_for(false, 3), Float3::preferred_height(false));
+
+        // A new value for the row reaches the group, and a group whose
+        // row changed width takes the new width.
+        let rows: Vec<(String, String, String)> = vec![
+            ("Two".into(), "3:4".into(), "float2:-10:10".into()),
+            ("Four".into(), "5:6:7".into(), "float3:-10:10".into()),
+            ("Three".into(), "0:0:0".into(), "float3:-10:10".into()),
+        ];
+        ParamController::set_display_params(&mut *p, &rows);
+        assert_eq!(p.float3s[0].as_ref().unwrap().value_string(), "3.00:4.00");
+        let f = p.float3s[1].as_ref().unwrap();
+        assert_eq!((f.components(), f.value_string()), (3, "5.00:6.00:7.00".to_string()));
+    }
+
     /// A `float3:lo:hi:trackball` row builds its group with the ball, counts
     /// the ball as chrome when the label layout is decided, paints it
     /// through the scene path, and a press-and-drag on it through the
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 15ab326..059a299 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -61,8 +61,13 @@ struct BallDrag {
 pub struct Float3 {
     /// The assigned (label-inclusive) rect.
     rect: Rect,
-    sliders: [Adapted<Slider>; 3],
-    axes: [&'static str; 3],
+    /// Four rows, of which the first [`Float3::components`] are the group's
+    /// (three unless a host asks for two or four — `float2` / `float4`
+    /// parameter rows). The rest are never laid out, drawn or hit.
+    sliders: [Adapted<Slider>; 4],
+    axes: [&'static str; 4],
+    /// How many rows the group has, 1..=4; three by default.
+    n: usize,
     label: Option<String>,
     dragging_idx: Option<usize>,
     /// Whether the group carries a TRACKBALL left of its rows: a ball the
@@ -95,8 +100,9 @@ impl 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 },
-            sliders: [row(), row(), row()],
-            axes: ["X", "Y", "Z"],
+            sliders: [row(), row(), row(), row()],
+            axes: ["X", "Y", "Z", "W"],
+            n: 3,
             label: None,
             dragging_idx: None,
             ball: false,
@@ -169,7 +175,8 @@ impl Float3 {
     /// ([`Float3::set_view`]). A vector of no length has no direction to
     /// turn, so the first drag gives it a length of one, toward the viewer.
     pub fn set_trackball(&mut self, on: bool) {
-        self.ball = on;
+        // A direction is three numbers: two or four have no ball.
+        self.ball = on && self.n == 3;
         self.ball_drag = None;
         let decimals = self.decimals();
         for s in self.sliders.iter_mut() {
@@ -182,6 +189,23 @@ impl Float3 {
         self.ball
     }
 
+    /// Give the group `n` rows (1..=4: X, Y, Z, W) — a `float2` or
+    /// `float4` value is the same control with fewer or more of them. A
+    /// group of other than three has no trackball.
+    pub fn set_components(&mut self, n: usize) {
+        self.n = n.clamp(1, 4);
+        if self.n != 3 {
+            self.ball = false;
+            self.ball_drag = None;
+        }
+        self.layout_rows();
+    }
+
+    /// How many rows the group has.
+    pub fn components(&self) -> usize {
+        self.n
+    }
+
     fn decimals(&self) -> usize {
         if self.ball { BALL_DECIMALS } else { DECIMALS }
     }
@@ -424,8 +448,14 @@ impl Float3 {
 
     /// The height a labeled (`labeled`) group lays out to: the detached label band plus three slider rows and their gaps — the row-height table entry.
     pub fn preferred_height(labeled: bool) -> f32 {
+        Self::preferred_height_for(labeled, 3)
+    }
+
+    /// [`Self::preferred_height`] for a group of `n` rows.
+    pub fn preferred_height_for(labeled: bool, n: usize) -> f32 {
+        let n = n.clamp(1, 4) as f32;
         let top = if labeled { crate::layout::control_label_strip() } else { 0.0 };
-        top + 3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
+        top + n * crate::layout::slider_height() + (n - 1.0) * ROW_GAP
     }
 
     /// Normalized (0..1) values, X/Y/Z.
@@ -433,6 +463,19 @@ impl Float3 {
         [self.sliders[0].value, self.sliders[1].value, self.sliders[2].value]
     }
 
+    /// The scaled values of the group's rows, as many as it has.
+    pub fn scaled_values(&self) -> Vec<f32> {
+        self.sliders[..self.n].iter().map(|s| s.get_scaled_value()).collect()
+    }
+
+    /// Normalized values in for the group's rows, as many as it has; each
+    /// row clamps to 0..1, and a row with no value given keeps its own.
+    pub fn set_values_n(&mut self, values: &[f32]) {
+        for (s, v) in self.sliders[..self.n].iter_mut().zip(values) {
+            s.set_value(*v);
+        }
+    }
+
     /// Normalized values in; each row clamps to 0..1.
     pub fn set_values(&mut self, values: [f32; 3]) {
         for (s, v) in self.sliders.iter_mut().zip(values) {
@@ -448,16 +491,15 @@ impl Float3 {
     /// 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();
         let d = self.decimals();
-        format!("{:.*}:{:.*}:{:.*}", d, v(0), d, v(1), d, v(2))
+        self.sliders[..self.n].iter().map(|s| format!("{:.*}", d, s.get_scaled_value())).collect::<Vec<_>>().join(":")
     }
 
     /// 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
+    pub fn sliders(&self) -> &[Adapted<Slider>] {
+        &self.sliders[..self.n]
     }
 
     /// Detached-label band above the rows (zero unlabeled) — the adapter's
@@ -474,12 +516,15 @@ impl Float3 {
         let x = self.rect.x + ball + AXIS_W;
         let w = (self.rect.width - ball - 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()
+        (0..self.n).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) {
+        for (i, s) in self.sliders.iter_mut().enumerate() {
+            // A row past the group's count is laid out nowhere, so it is
+            // hit by nothing.
+            let r = rows.get(i).copied().unwrap_or((0.0, 0.0, 0.0, 0.0));
             s.set_rect(r.0, r.1, r.2, r.3);
         }
     }
@@ -546,6 +591,12 @@ impl Adapted<Float3> {
         self
     }
 
+    /// See [`Float3::set_components`].
+    pub fn with_components(mut self, n: usize) -> Self {
+        self.set_components(n);
+        self
+    }
+
     pub fn with_values(mut self, values: [f32; 3]) -> Self {
         self.set_values(values);
         self
@@ -566,7 +617,7 @@ impl Layout for Float3 {
     /// The three rows alone: the adapter adds the detached-label strip itself
     /// (`Adapted::preferred_height`), as it does for every non-inflating widget.
     fn intrinsic_size(&self) -> Option<Size> {
-        Some(Size::new(0.0, Float3::preferred_height(false)))
+        Some(Size::new(0.0, Float3::preferred_height_for(false, self.n)))
     }
 
     fn rect_assigned(&mut self, rect: Rect) {