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