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

commit30a5d213d4846d70c4190b32a6cdb7f7a850099a
parent4d6eb5c02b
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 23:13
fix: the params pane stacks its labels by the slider's track, not the control's rect

The threshold measured the control rect a label column would leave, which
for a slider still holds the 60 px readout and its gap, and for a float3
the axis column too: a slider's track shrank to 52 px, and a float3's to
36, before the labels moved above the controls. It measures the TRACK now
(MIN_INLINE_TRACK_W, still 120 px; control_chrome is what a row's rect
spends on anything else), and a control with no track is measured whole.
One decision for the pane, by its shortest visible track — and re-taken
on every metrics refresh, so a section collapsing or opening re-decides
as a resize does.

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

 CLAUDE.md                             | 21 +++++----
 src/widget/container/parameters_bg.rs | 88 +++++++++++++++++++++++++++++------
 src/widget/display/float3.rs          |  2 +-
 src/widget/input/slider.rs            | 17 ++++++-
 4 files changed, 102 insertions(+), 26 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e8c0767..10d9818 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -900,14 +900,19 @@ All opt-in, all read once, all quiet when unset — set one and run any client.
 `ParametersBg` has two label layouts: **inline**, the pane's own column of
 labels beside unlabelled controls, and **stacked**, each control carrying
 its label above itself. `param_label_layout` in the style config is the
-PREFERENCE (`stacked`, or inline by default). Since 2026-09-28 the width
-decides on top of it: an inline pane whose label column would leave the
-controls narrower than `MIN_INLINE_CONTROL_W` (120 px — a slider band with
-no room for its thumb and readout) stacks, and takes the column back when
-it widens. The rows' widgets are built with or without their label and the
-two layouts have different row heights, so the flip is a relabel and a
-re-layout on every rect assignment (`apply_label_layout` /
-`relabel_rows`), not a flag; `Adapted::clear_label` is the way a label
+PREFERENCE (`stacked`, or inline by default). Since 2026-09-28 the rows
+decide on top of it: an inline pane whose label column would leave any
+visible row's TRACK shorter than `MIN_INLINE_TRACK_W` (120 px) stacks, and
+takes the column back when there is room. The track is what is measured —
+the control's rect less its chrome (`control_chrome`: a slider's 60 px
+readout and its gap, plus a float3's axis column) — because the first cut
+measured the rect and let a slider's track shrink to 52 px before the
+labels moved; a control with no track is measured whole. One decision for
+the pane, taken by its shortest track. The rows' widgets are built with or
+without their label and the two layouts have different row heights, so the
+flip is a relabel and a re-layout on every metrics refresh — a rect
+assignment, a rebuild, a section collapsing (`apply_label_layout` /
+`relabel_rows`) — not a flag; `Adapted::clear_label` is the way a label
 comes OFF a widget, and `slider::detached_strip` reads an empty label as
 none for the widgets that store whatever they are handed.
 
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 139e115..2897682 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -294,11 +294,28 @@ impl ParametersBg {
     }
 
     /// Row `i`'s laid-out height, ignoring collapse (the row-type table).
-    /// The narrowest control an inline row may be left with. Below it the
-    /// pane stacks every label above its control instead: a slider whose
-    /// band is shorter than this has no room for its thumb and readout,
-    /// and the column was already truncating the label to make it fit.
-    pub const MIN_INLINE_CONTROL_W: f32 = 120.0;
+    /// The shortest TRACK an inline row may leave its slider. Below it the
+    /// pane stacks every label above its control instead. Measured on the
+    /// track — what the slider is, to the eye and the hand — not on the
+    /// control's rect, which also holds the readout and its gap (and a
+    /// float3's axis column): until 2026-09-28 the rect was what was
+    /// measured, so a slider's track shrank to 52 px, and a float3's to
+    /// 36, before the labels moved. A control with no track (spinbox, text
+    /// box, dropdown, colour) is measured whole.
+    pub const MIN_INLINE_TRACK_W: f32 = 120.0;
+
+    /// What a row of type `t` spends of its control rect on everything but
+    /// its track: a slider's readout and gap, plus a float3's axis column.
+    /// Zero for the controls that have no track.
+    fn control_chrome(t: &str) -> f32 {
+        if t.starts_with("slider") {
+            crate::widget::input::slider::Slider::readout_chrome()
+        } else if t.starts_with("float3") {
+            crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
+        } else {
+            0.0
+        }
+    }
 
     /// Whether a row of type `t` takes the inline layout at all. Only the
     /// rows whose control would otherwise carry a label strip: toggles and
@@ -320,9 +337,12 @@ impl ParametersBg {
         self.inline_labels && Self::inline_kind(&self.display_params[i].2)
     }
 
-    /// The layout the pane's width allows: the preference, unless a label
-    /// column would leave the controls narrower than
-    /// [`Self::MIN_INLINE_CONTROL_W`], in which case the labels stack. An
+    /// The layout the rows allow: the preference, unless a label column
+    /// would leave any visible row's track shorter than
+    /// [`Self::MIN_INLINE_TRACK_W`], in which case the labels stack. One
+    /// decision for the pane — a mix of inline and stacked rows reads as
+    /// ragged — taken by its SHORTEST track, so a pane of spinboxes keeps
+    /// its column at a width where a pane with a float3 has let it go. An
     /// unlaid pane (no rect yet) follows the preference; the first rect
     /// assignment decides.
     fn decide_inline(&self) -> bool {
@@ -333,7 +353,13 @@ impl ParametersBg {
             return true;
         }
         let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
-        row_w - self.label_col_w_if(true) >= Self::MIN_INLINE_CONTROL_W
+        let control_w = row_w - self.label_col_w_if(true);
+        let hidden = self.hidden_rows();
+        self.display_params
+            .iter()
+            .enumerate()
+            .filter(|(i, p)| !hidden[*i] && Self::inline_kind(&p.2))
+            .all(|(_, p)| control_w - Self::control_chrome(&p.2) >= Self::MIN_INLINE_TRACK_W)
     }
 
     /// Re-decide the layout for the current rect and, when it flips, move
@@ -1072,6 +1098,12 @@ impl ParametersBg {
     /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
     /// scroll into it, re-lay the rows.
     fn refresh_scroll_metrics(&mut self) {
+        // The label layout (inline column or stacked) is decided first, by
+        // the tracks the rows would get: every geometry below depends on
+        // it. Here rather than on rect assignment alone, because a section
+        // collapsing or opening changes which rows are visible, and so
+        // which is the shortest track and how wide the column is.
+        self.apply_label_layout();
         self.content_h = self.get_total_content_height();
         let max_scroll = (self.content_h - self.rect.height).max(0.0);
         self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
@@ -1899,9 +1931,6 @@ impl Layout for ParametersBg {
     /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
     fn rect_assigned(&mut self, rect: Rect) {
         self.rect = rect;
-        // The width decides the label layout (inline column or stacked),
-        // before the row geometry that depends on it is derived.
-        self.apply_label_layout();
         self.refresh_scroll_metrics();
     }
 
@@ -3785,10 +3814,13 @@ mod tests {
         assert!(p.sliders[0].as_ref().unwrap().base().label.is_none());
         let (_, _, sw_inline, _) = p.sliders[0].as_ref().unwrap().rect();
         let h_inline = p.get_param_rects()[0].3;
-        assert!(sw_inline >= ParametersBg::MIN_INLINE_CONTROL_W, "the fixture starts with room: {sw_inline}");
+        // The threshold is the slider's TRACK: its rect less the readout.
+        let chrome = crate::widget::input::slider::Slider::readout_chrome();
+        assert!(sw_inline - chrome >= ParametersBg::MIN_INLINE_TRACK_W, "the fixture starts with room: {sw_inline}");
 
-        // Narrow it until the control would fall under the minimum: stacked.
-        let narrow = 2.0 * ROW_X_INSET + col + ParametersBg::MIN_INLINE_CONTROL_W - 1.0;
+        // Narrow it until the TRACK would fall under the minimum: stacked,
+        // though the control's rect is still well over it.
+        let narrow = 2.0 * ROW_X_INSET + col + chrome + ParametersBg::MIN_INLINE_TRACK_W - 1.0;
         WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow, 400.0);
         assert!(!p.inner().inline_labels, "under the minimum the labels stack");
         assert_eq!(p.inner().label_col_w(), 0.0, "no column");
@@ -3820,6 +3852,32 @@ mod tests {
         ParamController::set_display_params(&mut *p, &rows);
         assert!(!p.inner().inline_labels);
         assert_eq!(p.sliders[1].as_ref().unwrap().base().label.as_deref(), Some("Width"));
+
+        // The shortest track decides. At one width: a pane of controls with
+        // no track keeps its column, a slider's readout costs it the
+        // column, and a float3's axis letters cost it sooner still.
+        let at = |rows: &[(&str, &str, &str)], width: f32| {
+            let mut p = panel_with(rows);
+            WidgetHost::set_rect(&mut p, 0.0, 0.0, width, 400.0);
+            p.inner().inline_labels
+        };
+        let spin = [("Size", "3", "spinbox:0:10")];
+        let slider = [("Size", "1.00", "slider:0:2")];
+        let float3 = [("Size", "0:0:0", "float3:-1:1")];
+        let base = 2.0 * ROW_X_INSET + col + ParametersBg::MIN_INLINE_TRACK_W;
+        assert!(at(&spin, base), "a spinbox is measured whole");
+        assert!(!at(&slider, base), "a slider at that width has a track {chrome} short");
+        assert!(at(&slider, base + chrome));
+        assert!(!at(&float3, base + chrome), "a float3 spends an axis column too");
+        assert!(at(&float3, base + chrome + crate::widget::display::float3::AXIS_W));
+
+        // Collapsing the section that holds the only slider re-decides: the
+        // rows left visible have no track, and the column comes back.
+        let mut p = panel_with(&[("Count", "3", "spinbox:0:10"), ("Shape", "", "section"), ("Size", "1.00", "slider:0:2")]);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, base, 400.0);
+        assert!(!p.inner().inline_labels, "the slider's track is the shortest");
+        p.inner_mut().set_section_collapsed("Shape", true);
+        assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
     }
 
     #[test]
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index b8bbf23..4424bbf 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -26,7 +26,7 @@ use crate::widget::{
 /// 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;
+pub(crate) const AXIS_W: f32 = 16.0;
 /// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
 const DECIMALS: usize = 2;
 
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 4e1b6f8..4e8b51f 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -124,12 +124,25 @@ impl Slider {
     }
 
 
+    /// Width of the value readout at the slider's right end.
+    pub const READOUT_W: f32 = 60.0;
+    /// Gap between the track and the readout.
+    pub const READOUT_GAP: f32 = 8.0;
+
+    /// What a slider's rect spends on everything but its TRACK: the readout
+    /// and its gap when it shows one. `rect width - chrome` is the track a
+    /// host gets for a rect — what `ParametersBg` measures to decide whether
+    /// a row's label can sit beside the control.
+    pub const fn readout_chrome() -> f32 {
+        Self::READOUT_W + Self::READOUT_GAP
+    }
+
     fn geom(&self, rect: Rect) -> SliderGeom {
         let x = rect.x;
         let w = rect.width;
         let (track_x, track_w) = if self.show_readout {
-            let readout_w = 60.0;
-            let gap = 8.0;
+            let readout_w = Self::READOUT_W;
+            let gap = Self::READOUT_GAP;
             ((x), (w - readout_w - gap).max(10.0))
         } else {
             (x, w)