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

commitd48b6223d89bf8cfd6272cf1522292250069cff0
parent711f42d305
authorLucas Galante <[email protected]>
date2026-09-08 14:59
feat(layout): a labeled control's preferred height counts the label its rect eats; Toggle::with_slide

Slider, RangeSlider, Spinbox, Dropdown, TextBox and Slider2D keep the rect they are
assigned and draw their detached label INSIDE it (`inflates_label_rect` false), so a
layout strategy that sized them by `preferred_height` — the bare
`style.control.<name>.height` — left the control the label's leftovers: a 16px slider
became a 2px line under an 18px label strip. `Adapted::preferred_height` now adds the
label strip for those widgets, so a child assigned its preferred height ends up with
its full intrinsic content height under either label convention (the inflating kind
grows past the rect on set_rect instead, and is unchanged). Float3 drops the strip
from its own intrinsic size accordingly. Test: preferred_height_of_a_labeled_control_
leaves_the_content_height_intact.

A multiline TextBox declares no intrinsic height — the host sizes it — instead of the
single-row height that made strategies squash it to one line.

Toggle gains `with_slide(bool)`, the per-widget override of the config-wide slide
style, the way Slider's `with_band` overrides `style.control.slider.style`; the
gallery uses it to show both looks side by side.

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

 src/widget/display/float3.rs |  4 +++-
 src/widget/input/checkbox.rs | 25 +++++++++++++++++++++----
 src/widget/input/text_box.rs |  5 +++++
 src/widget/model.rs          | 42 +++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 70 insertions(+), 6 deletions(-)

diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 5d3e351..6380636 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -194,8 +194,10 @@ impl Layout for Float3 {
         4.0
     }
 
+    /// 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(self.label.is_some())))
+        Some(Size::new(0.0, Float3::preferred_height(false)))
     }
 
     fn rect_assigned(&mut self, rect: Rect) {
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index e97038d..5f986b9 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -206,6 +206,9 @@ pub struct Toggle {
     /// (`set_toggled`, `set_value_string`) snap it, so only user interaction
     /// animates.
     slide_t: f32,
+    /// Per-widget slide-style override; `None` follows the DE config
+    /// (`style.control.toggle.style`).
+    slide_override: Option<bool>,
 }
 
 impl Toggle {
@@ -219,9 +222,17 @@ impl Toggle {
             justify: Justification::Center,
             raised: crate::layout::control_relief(),
             slide_t: 0.0,
+            slide_override: None,
         })
     }
 
+    /// The slide style (config `style.control.toggle.style = "slide"`, or
+    /// `with_slide` per widget): a half-width button gliding between the
+    /// ends instead of the rocker.
+    pub fn slide(&self) -> bool {
+        self.slide_override.unwrap_or_else(crate::layout::toggle_slide)
+    }
+
     pub fn set_label(&mut self, label: &str) {
         self.label = Some(label.to_string());
     }
@@ -240,7 +251,7 @@ impl Toggle {
     /// right (on) ends by the animated `slide_t`. `None` when the style is
     /// off — legacy-view hosts fall back to `rocker_reliefs`.
     pub fn slide_button(&self, rect: Rect) -> Option<Rect> {
-        if !crate::layout::toggle_slide() {
+        if !self.slide() {
             return None;
         }
         let bw = rect.width * 0.5;
@@ -284,7 +295,7 @@ impl Toggle {
     /// style, so on a flat host these overlays plus [`Toggle::flat_carves`]
     /// are the ENTIRE control; without them the row was a bare label.
     pub fn flat_faces(&self, rect: Rect) -> Vec<(Rect, f32, (bool, bool, bool, bool), [f32; 4])> {
-        if crate::layout::toggle_slide() {
+        if self.slide() {
             return Vec::new();
         }
         let radius = crate::layout::toggle_corner_radius();
@@ -395,6 +406,12 @@ impl Adapted<Toggle> {
         self.raised = raised;
         self
     }
+
+    /// Slide style for this widget regardless of the config (see `Toggle::slide`).
+    pub fn with_slide(mut self, slide: bool) -> Self {
+        self.slide_override = Some(slide);
+        self
+    }
 }
 
 impl Layout for Toggle {
@@ -435,7 +452,7 @@ impl Paint for Toggle {
 
     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
-        let slide = crate::layout::toggle_slide();
+        let slide = self.slide();
 
         // A toggle paints NO fill of its own, in any style: it is worked out
         // of the plate it sits on, so the plate's own material shows through
@@ -532,7 +549,7 @@ impl Input for Toggle {
     /// (~90ms exponential settle). Programmatic syncs snap instead — see
     /// `set_toggled` / `set_value_string` — so only user interaction animates.
     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
-        if !crate::layout::toggle_slide() {
+        if !self.slide() {
             return false;
         }
         let target = if self.toggled { 1.0 } else { 0.0 };
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index c1737a3..891b176 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1299,7 +1299,12 @@ impl Layout for TextBox {
         4.0
     }
 
+    /// One row for a single-line box; a multiline box has no natural height of its own —
+    /// the host sizes it, and a layout strategy leaves its assigned rect alone.
     fn intrinsic_size(&self) -> Option<Size> {
+        if self.multiline {
+            return None;
+        }
         Some(Size::new(0.0, crate::layout::textbox_height()))
     }
 
diff --git a/src/widget/model.rs b/src/widget/model.rs
index bdf7aca..fa23c57 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1168,8 +1168,20 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         }
     }
 
+    /// The height a layout should allot: the widget's intrinsic content height, plus the
+    /// detached-label strip for widgets whose label eats INTO the assigned rect
+    /// ([`Layout::inflates_label_rect`] false — Slider, Spinbox, Dropdown, ...). Inflating
+    /// widgets grow past the assigned rect on `set_rect` instead, so their strip is not
+    /// counted here. Either way, a widget assigned its preferred height ends up with its
+    /// intrinsic content height — which is what makes the two conventions lay out alike.
     fn preferred_height(&self) -> Option<f32> {
-        Layout::intrinsic_size(&self.inner).map(|s| s.height)
+        let size = Layout::intrinsic_size(&self.inner)?;
+        let strip = if Layout::inline_label(&self.inner) || Layout::inflates_label_rect(&self.inner) {
+            0.0
+        } else {
+            self.base.label_offset()
+        };
+        Some(size.height + strip)
     }
 
     /// The `WidgetHost::measure` default, except the width consults the intrinsic size when the
@@ -1678,6 +1690,34 @@ mod tests {
         Rect { x, y, width: w, height: h }
     }
 
+    /// A labeled control assigned its `preferred_height` keeps its full intrinsic content
+    /// height whichever label convention it follows: the inflating kind (ProgressBar) grows
+    /// past the assigned rect, the eating kind (Slider, Spinbox, Dropdown) has the strip
+    /// counted into the preferred height instead — so a strategy sizing children by
+    /// `preferred_height` never squashes a track to the label's leftovers.
+    #[test]
+    fn preferred_height_of_a_labeled_control_leaves_the_content_height_intact() {
+        use crate::widget::{Dropdown, ProgressBar, Slider, Spinbox};
+        let mut slider = Slider::new().with_label("Gain");
+        let mut spinbox = Spinbox::new(1, 0, 9, 1).with_label("Count");
+        let mut dropdown = Dropdown::new(vec!["a".into()], 0).with_label("Pick");
+        let mut bar = ProgressBar::new(0.5).with_label("Load");
+        let strip = slider.base.label_offset();
+        assert!(strip > 0.0, "a detached label has a strip above the content");
+
+        for (name, w, content) in [
+            ("slider", &mut slider as &mut dyn WidgetHost, crate::layout::slider_height()),
+            ("spinbox", &mut spinbox, crate::layout::spinbox_height()),
+            ("dropdown", &mut dropdown, crate::layout::dropdown_height()),
+            ("progress bar", &mut bar, crate::layout::progressbar_height()),
+        ] {
+            let pref = w.preferred_height().expect(name);
+            w.set_rect(0.0, 0.0, 100.0, pref);
+            let painted = w.rect().3 - crate::widget::label_offset(w);
+            assert!((painted - content).abs() < 0.01, "{name}: content {painted} after preferred {pref}, wanted {content}");
+        }
+    }
+
     #[test]
     fn narrow_widget_lays_out_and_paints_through_the_adapter() {
         // A pure narrow-trait widget tree (Col + two Dots), wrapped in `Adapted`, is laid out by