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

commitac3658ce0014689e16bebd6ac69ed00db8daf55f
parent90c03e1a02
authorLucas Galante <[email protected]>
date2026-09-08 17:08
feat(layout)!: strategies place content boxes; the label hangs in the gap; the gap is a control height

The rhythm still read unevenly after the occupied-height rule because a strategy
placed a control's LABEL top at the cursor: an unlabeled Button and a labeled
Slider in one row had tops 20px apart, and the space between two rows was the
gap plus a label strip while the space between two columns was the gap alone.

Now `preferred_height` is the content height again and `layout` places that
content box at the origin it is given, with the detached label hanging in the
strip above it (`WidgetHost::label_strip`, replacing `label_inflation`; either
legacy `set_rect` convention lands the same occupied rect). The strategies
(Vertical, Grid, Adaptive, Columns, Mosaic, Reverse Mosaic, Flex) allot content
heights, leave one row of headroom for the first row's labels (`label_lead`),
and advance by content + gap — so labeled and unlabeled controls line up by
content and the space between controls is the gap, above and beside. To hold a
label strip, `CONTROL_GAP` is one control height (24), which is also what makes
the vertical and horizontal spacing read the same. `render_widget` and the
legacy builders keep their label-top contract by stepping the origin down.

Slider2D carves its label tab through the sliders' shared `carve_labeled_well`
(its own 76-line copy is gone) and is as wide as that tab needs, so the label no
longer spills past a 64px pad; it measures that width for strategies.

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

 src/layout.rs                            | 53 +++++++++--------
 src/widget/container/container_layout.rs | 55 ++++++++++++------
 src/widget/display/progress_bar.rs       |  5 +-
 src/widget/input/slider2d.rs             | 97 +++++++-------------------------
 src/widget/mod.rs                        | 20 +++----
 src/widget/model.rs                      | 69 +++++++++++++----------
 6 files changed, 138 insertions(+), 161 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 2a4dd5e..fd4410e 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -292,10 +292,15 @@ static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
 /// defaults lines up; a per-control key is the deliberate exception.
 pub const DEFAULT_CONTROL_HEIGHT: f32 = 24.0;
 
-/// The one gap between controls: what every layout strategy's `Default` puts
-/// between children and around them, and what the legacy row builders advance
-/// by. Containers may set their own, but one number is the rhythm.
-pub const CONTROL_GAP: f32 = 8.0;
+/// The one gap between controls — one control height — in BOTH axes: what every
+/// layout strategy's `Default` puts between children's content boxes and around
+/// them, and what the legacy row builders advance by. A detached label hangs in
+/// the gap above its control (a strategy places content boxes; see
+/// `WidgetHost::label_strip`), which is why the gap is a control height and not
+/// a few pixels: it holds a label strip (font + margin) with room to spare, so
+/// the space between two controls reads the same whether or not a label sits in
+/// it, and the same as the space beside them.
+pub const CONTROL_GAP: f32 = DEFAULT_CONTROL_HEIGHT;
 
 /// The one inset from a control's edge to its text: the field text of a TextBox,
 /// Dropdown, Spinbox, FontSelector, KeybindRecorder or ColorSelector, a left-
@@ -3689,11 +3694,11 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
     if let Some(w_id) = id {
         ctx.register_widget(w_id, w as *mut T as *mut (dyn WidgetHost + 'static));
     }
-    // `wh` is the legacy content height — a widget whose detached label inflates its
-    // rect grows past it. `layout` speaks occupied heights (label included), so hand
-    // it the box the widget will land in.
-    let occupied = wh + w.label_inflation();
-    w.layout(crate::widget::Point { x, y }, crate::widget::LayoutConstraints::new(ww, ww, occupied, occupied), ctx);
+    // The legacy contract: `y` is the top of the detached label, `wh` the content
+    // height below it. `layout` takes the CONTENT origin (the label hangs above it),
+    // so step down by the strip.
+    let strip = w.label_strip();
+    w.layout(crate::widget::Point { x, y: y + strip }, crate::widget::LayoutConstraints::new(ww, ww, wh, wh), ctx);
 
     // Shape, which on this path nobody else does. A flat host consumes
     // `all_quads`, so `prepare_text` — where a TextBox records the per-glyph x
@@ -4057,12 +4062,10 @@ impl Column {
     }
 
     pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, mut wh: f32, ctx: &mut UiContext) {
-        let top_room = crate::widget::label_offset(w);
         if let Some(pref) = w.preferred_height() {
-            // Preferred heights are occupied (label included); this builder adds
-            // the label room itself below.
-            wh = pref - top_room;
+            wh = pref;
         }
+        let top_room = crate::widget::label_offset(w);
         let total_h = wh + top_room;
         let x = self.ax(x_off);
         let y = self.ay();
@@ -4111,9 +4114,7 @@ impl<'a> Row<'a> {
 
     pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, mut wh: f32, ctx: &mut UiContext) {
         if let Some(pref) = w.preferred_height() {
-            // Preferred heights are occupied (label included); `render_widget`
-            // takes the content height and adds the label room itself.
-            wh = pref - crate::widget::label_offset(w);
+            wh = pref;
         }
         render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
         self.cursor_x += ww + self.spacing;
@@ -4241,13 +4242,11 @@ impl Section {
     }
 
     pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
-        let top_room = crate::widget::label_offset(w);
         if let Some(pref) = w.preferred_height() {
-            // Preferred heights are occupied (label included); this builder adds
-            // the label room itself below.
-            wh = pref - top_room;
+            wh = pref;
         }
         let pad = self.padding();
+        let top_room = crate::widget::label_offset(w);
         let total_h = wh + top_room;
 
         let name = w.type_name();
@@ -4732,15 +4731,20 @@ impl LayoutStrategy for FlexLayout {
     }
 
     fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
-        let mut cur_x = x;
-        let mut cur_y = y;
+        let (cur_x, cur_y) = (x, y);
+        // Content boxes: a detached label hangs above its control in the gap (or
+        // this lead row of headroom), so labeled and unlabeled children line up
+        // by content and the gap between controls is the gap.
+        let lead = crate::widget::container::container_layout::label_lead(children);
+        let (cur_x, cur_y) = (cur_x, cur_y + lead);
         match self.direction {
             FlexDirection::Row => {
+                let mut cur_x = cur_x;
                 for &child_ptr in children {
                     unsafe {
                         let child = &mut *child_ptr;
                         let child_w = child.rect().2;
-                        let child_h = child.preferred_height().unwrap_or(child.rect().3);
+                        let child_h = crate::widget::container::container_layout::content_height(child);
                         let use_h = if child_h > 0.0 { child_h } else { h };
                         child.layout(
                             crate::widget::Point { x: cur_x, y: cur_y },
@@ -4753,10 +4757,11 @@ impl LayoutStrategy for FlexLayout {
                 (cur_x - x).max(0.0)
             }
             FlexDirection::Column => {
+                let mut cur_y = cur_y;
                 for &child_ptr in children {
                     unsafe {
                         let child = &mut *child_ptr;
-                        let child_h = child.preferred_height().unwrap_or(child.rect().3);
+                        let child_h = crate::widget::container::container_layout::content_height(child);
                         let use_h = if child_h > 0.0 { child_h } else { 44.0 };
                         child.layout(
                             crate::widget::Point { x, y: cur_y },
diff --git a/src/widget/container/container_layout.rs b/src/widget/container/container_layout.rs
index 4348228..b2e1b12 100644
--- a/src/widget/container/container_layout.rs
+++ b/src/widget/container/container_layout.rs
@@ -110,6 +110,19 @@ impl ContainerLayout for ManualLayout {
 }
 
 
+/// A child's content height for a strategy to allot: its preferred (intrinsic)
+/// height, else its landed rect less the detached-label strip.
+pub fn content_height(child: &dyn WidgetHost) -> f32 {
+    child.preferred_height().unwrap_or(child.rect().3 - child.label_strip())
+}
+
+/// The headroom a strategy leaves above its first row so the first row's detached
+/// labels have somewhere to hang: the tallest label strip among the children (every
+/// later row's labels hang in the gap). Zero when nothing is labeled.
+pub fn label_lead(children: &[*mut (dyn WidgetHost + 'static)]) -> f32 {
+    children.iter().map(|&c| unsafe { (*c).label_strip() }).fold(0.0, f32::max)
+}
+
 #[derive(Debug, Clone, Copy)]
 pub struct VerticalLayout {
     pub padding_x: f32,
@@ -151,12 +164,15 @@ impl crate::layout::LayoutStrategy for VerticalLayout {
     fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
         let left_x = x + self.padding_x;
         let available_w = (w - 2.0 * self.padding_x).max(1.0);
-        let mut current_y = y + self.padding_y;
+        // Content boxes: a child's detached label hangs above its content in the
+        // gap before it (the first row's in this lead), so mixed children line up
+        // by content and the space between controls is the gap.
+        let mut current_y = y + self.padding_y + label_lead(children);
 
         for &child_ptr in children {
             unsafe {
                 let child = &mut *child_ptr;
-                let ch = child.preferred_height().unwrap_or(child.rect().3);
+                let ch = content_height(child);
                 let use_h = if ch > 0.0 { ch } else { 44.0 };
                 child.layout(
                     Point { x: left_x, y: current_y },
@@ -170,7 +186,7 @@ impl crate::layout::LayoutStrategy for VerticalLayout {
     }
 
     fn measure(&self, constraints: LayoutConstraints, children: &[*mut (dyn WidgetHost + 'static)], ctx: &UiContext) -> Size {
-        let mut total_h = self.padding_y * 2.0;
+        let mut total_h = self.padding_y * 2.0 + label_lead(children);
         let mut max_w = 0.0f32;
         let spacing = self.spacing;
 
@@ -256,12 +272,14 @@ impl crate::layout::LayoutStrategy for GridLayout {
         let available_w = (w - 2.0 * self.padding_x - total_gap).max(1.0);
         let col_w = available_w / cols as f32;
         
-        let mut col_heights = vec![y + self.padding_y; cols];
+        // Content boxes (see VerticalLayout): labels hang in the gaps, the first
+        // row's in the lead.
+        let mut col_heights = vec![y + self.padding_y + label_lead(children); cols];
 
         for &child_ptr in children {
             unsafe {
                 let child = &mut *child_ptr;
-                let ch = child.preferred_height().unwrap_or(child.rect().3);
+                let ch = content_height(child);
                 let use_h = if ch > 0.0 { ch } else { 44.0 };
                 
                 let mut min_col = 0;
@@ -290,7 +308,7 @@ impl crate::layout::LayoutStrategy for GridLayout {
 
     fn measure(&self, constraints: LayoutConstraints, children: &[*mut (dyn WidgetHost + 'static)], ctx: &UiContext) -> Size {
         let cols = self.columns.max(1);
-        let mut col_heights = vec![self.padding_y; cols];
+        let mut col_heights = vec![self.padding_y + label_lead(children); cols];
         let total_gap = self.gap * (cols - 1) as f32;
         let available_w = (constraints.max_width - 2.0 * self.padding_x - total_gap).max(1.0);
         let col_w = available_w / cols as f32;
@@ -442,8 +460,10 @@ impl crate::layout::LayoutStrategy for ColumnsLayout {
         let total_padding = self.padding_x * 2.0;
         let available_w = (w - total_padding - total_spacing).max(1.0);
         let col_w = available_w / count as f32;
-        let use_h = (h - 2.0 * self.padding_y).max(1.0);
-        let start_y = y + self.padding_y;
+        // Content boxes: the columns' labels hang in the lead above them.
+        let lead = label_lead(children);
+        let use_h = (h - 2.0 * self.padding_y - lead).max(1.0);
+        let start_y = y + self.padding_y + lead;
 
         let mut current_x = x + self.padding_x;
         for &child_ptr in children {
@@ -474,7 +494,7 @@ impl crate::layout::LayoutStrategy for ColumnsLayout {
         }
         Size {
             width: constraints.max_width,
-            height: (max_h + 2.0 * self.padding_y).clamp(constraints.min_height, constraints.max_height),
+            height: (max_h + 2.0 * self.padding_y + label_lead(children)).clamp(constraints.min_height, constraints.max_height),
         }
     }
 
@@ -585,14 +605,16 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
         let total_padding_x = self.padding_x * 2.0;
         let available_w = (w - total_padding_x).max(1.0);
 
-        let mut packer = Packer::new(x + self.padding_x, y + self.padding_y, available_w, self.gap);
+        // Content boxes (see VerticalLayout): labels hang in the gaps, the first
+        // row's in the lead.
+        let mut packer = Packer::new(x + self.padding_x, y + self.padding_y + label_lead(children), available_w, self.gap);
 
         for &child_ptr in children {
             unsafe {
                 let child = &mut *child_ptr;
                 let child_rect = child.rect();
                 let child_w = child_rect.2;
-                let child_h = child.preferred_height().unwrap_or(child_rect.3);
+                let child_h = content_height(child);
                 let use_h = if child_h > 0.0 { child_h } else { 44.0 };
                 
                 let (px, py) = packer.pack(child_w, use_h);
@@ -615,7 +637,7 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
         let total_padding_x = self.padding_x * 2.0;
         let available_w = (constraints.max_width - total_padding_x).max(1.0);
 
-        let mut packer = Packer::new(self.padding_x, self.padding_y, available_w, self.gap);
+        let mut packer = Packer::new(self.padding_x, self.padding_y + label_lead(children), available_w, self.gap);
 
         for &child_ptr in children {
             unsafe {
@@ -671,7 +693,8 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
         let total_padding_x = self.padding_x * 2.0;
         let available_w = (w - total_padding_x).max(1.0);
 
-        let mut packer = Packer::new(self.padding_x, self.padding_y, available_w, self.gap);
+        let lead = label_lead(children);
+        let mut packer = Packer::new(self.padding_x, self.padding_y + lead, available_w, self.gap);
         let mut temp_positions = Vec::with_capacity(count);
 
         for &child_ptr in children {
@@ -679,7 +702,7 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
                 let child = &mut *child_ptr;
                 let child_rect = child.rect();
                 let child_w = child_rect.2;
-                let child_h = child.preferred_height().unwrap_or(child_rect.3);
+                let child_h = content_height(child);
                 let use_h = if child_h > 0.0 { child_h } else { 44.0 };
                 
                 let (px, py) = packer.pack(child_w, use_h);
@@ -703,7 +726,7 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
         let src_h = (y_max - y_min).max(1.0);
 
         let dst_w = available_w;
-        let dst_h = (h - 2.0 * self.padding_y).max(1.0);
+        let dst_h = (h - 2.0 * self.padding_y - lead).max(1.0);
 
         let scale_x = dst_w / src_w;
         let scale_y = dst_h / src_h;
@@ -714,7 +737,7 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
                 let (px, py, pw, ph) = temp_positions[i];
 
                 let new_x = x + self.padding_x + (px - x_min) * scale_x;
-                let new_y = y + self.padding_y + (py - y_min) * scale_y;
+                let new_y = y + self.padding_y + lead + (py - y_min) * scale_y;
                 let new_w = pw * scale_x;
                 let new_h = ph * scale_y;
 
diff --git a/src/widget/display/progress_bar.rs b/src/widget/display/progress_bar.rs
index 487303c..487bb7b 100644
--- a/src/widget/display/progress_bar.rs
+++ b/src/widget/display/progress_bar.rs
@@ -157,8 +157,9 @@ mod tests {
         assert_eq!(quads[0].1, 10.0 + offset, "track is painted below the label region");
         assert_eq!(quads[0].3, 8.0, "track keeps the assigned height");
 
-        // preferred_height is the occupied height: the intrinsic size plus the label strip.
-        assert_eq!(WidgetHost::preferred_height(&bar), Some(crate::layout::progressbar_height() + offset));
+        // preferred_height is the content height; the label strip is `label_strip`.
+        assert_eq!(WidgetHost::preferred_height(&bar), Some(crate::layout::progressbar_height()));
+        assert_eq!(WidgetHost::label_strip(&bar), offset);
         // Runtime type-name matching still sees "ProgressBar", not Adapted<..>.
         assert_eq!(WidgetHost::type_name(&bar), "ProgressBar");
     }
diff --git a/src/widget/input/slider2d.rs b/src/widget/input/slider2d.rs
index 562d0bb..0fdba74 100644
--- a/src/widget/input/slider2d.rs
+++ b/src/widget/input/slider2d.rs
@@ -92,8 +92,16 @@ impl Layout for Slider2D {
     }
 
 
+    /// A 64px pad, or wide enough for its label's carve-out tab (the tab is clipped
+    /// to the pad and the label spilled past a 64px one).
     fn intrinsic_size(&self) -> Option<Size> {
-        Some(Size::new(64.0, 64.0))
+        let label_w = crate::widget::input::slider::detached_label_width(&self.label);
+        let tab_w = if label_w > 0.0 { label_w + 2.0 * crate::layout::DETACHED_LABEL_INSET + 16.0 } else { 0.0 };
+        Some(Size::new(64.0_f32.max(tab_w), 64.0))
+    }
+
+    fn intrinsic_measure_width(&self) -> bool {
+        true
     }
 }
 
@@ -115,7 +123,6 @@ impl Paint for Slider2D {
         // (the ramp graph's look in miniature), recess rim drawn last so its
         // shading falls over the content at the edges.
         let radius = crate::layout::slider_corner_radius().max(4.0);
-        let radii = (radius, radius, radius, radius);
         ctx.rounded_rect(rect, radius, (true, true, true, true), [0.08, 0.08, 0.10, 1.0]);
 
         // Crosshair through the thumb — the pad's read of both axis values.
@@ -138,82 +145,16 @@ impl Paint for Slider2D {
         );
 
         let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-        let strip = self.label_top();
-        if strip > 0.0 {
-            // Labeled: the label sits in a CARVE-OUT tab (the Dropdown's
-            // labeled-relief idiom) — a recessed well hugging the label run,
-            // its bottom open into the pad's recess ring below. Right of the
-            // tab, a concave fillet rounds the throat and the ring's normal
-            // top wall resumes.
-            let (fam, fsize) = crate::layout::control_label_font_detached_parsed();
-            let text_w = self
-                .label
-                .as_deref()
-                .map(|l| crate::widget::display::measure_text_width(l, &fam, fsize))
-                .unwrap_or(0.0);
-            let inset = crate::layout::DETACHED_LABEL_INSET; // the label's x offset
-            let tab_top = rect.y - strip;
-            let ring_top = rect.y;
-            let tab_w = (text_w + 2.0 * inset + 4.0)
-                .max(2.0 * radius + 8.0)
-                .min(rect.width);
-            let tab_r = rect.x + tab_w;
-            let fr = 6.0_f32.min(strip * 0.5);
-            let filleted = rect.x + rect.width - tab_r > fr + 4.0;
-            let tab_bottom = if filleted { ring_top - fr } else { ring_top };
-            ctx.recess_edges(
-                Rect { x: rect.x, y: tab_top, width: tab_w, height: tab_bottom - tab_top + depth },
-                (radius, radius.min(strip * 0.5), 0.0, 0.0),
-                depth,
-                (true, true, false, true),
-            );
-            if filleted {
-                ctx.recess_edges(
-                    Rect { x: rect.x, y: ring_top - fr, width: tab_w, height: fr + depth },
-                    (0.0, 0.0, 0.0, 0.0),
-                    depth,
-                    (false, false, false, true),
-                );
-            }
-            // The well's ring minus its top wall, which resumes right of the
-            // tab's throat.
-            ctx.recess_edges(rect, (0.0, 0.0, radius, radius), depth, (false, true, true, true));
-            if filleted {
-                ctx.concave_fillet(
-                    tab_r + fr,
-                    ring_top - fr,
-                    fr,
-                    depth,
-                    std::f32::consts::FRAC_PI_2,
-                    false,
-                );
-                ctx.recess_edges(
-                    Rect {
-                        x: tab_r + fr - depth,
-                        y: ring_top,
-                        width: rect.x + rect.width - tab_r - fr + depth,
-                        height: rect.height,
-                    },
-                    (0.0, radius, 0.0, 0.0),
-                    depth,
-                    (true, false, false, false),
-                );
-            } else if rect.x + rect.width - tab_r > 0.5 {
-                ctx.recess_edges(
-                    Rect {
-                        x: tab_r - depth,
-                        y: ring_top,
-                        width: rect.x + rect.width - tab_r + depth,
-                        height: rect.height,
-                    },
-                    (0.0, radius, 0.0, 0.0),
-                    depth,
-                    (true, false, false, false),
-                );
-            }
-        } else {
-            ctx.recess(rect, radii, depth);
-        }
+        // The well's ring, with a labeled pad's label in a carve-out tab — the one
+        // labeled-well composition the sliders share (`carve_labeled_well`).
+        crate::widget::input::slider::carve_labeled_well(
+            ctx,
+            rect,
+            self.label_top(),
+            crate::widget::input::slider::detached_label_width(&self.label),
+            radius,
+            depth,
+        );
     }
 }
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index b86ea6d..0b93659 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -200,18 +200,18 @@ pub trait WidgetHost {
     /// implementor) always owns a base; test shims carry one via `impl_widget_base!`.
     fn base(&self) -> &Widget;
     fn base_mut(&mut self) -> &mut Widget;
-    /// The height a layout should allot this widget: its content height plus the
-    /// detached-label strip above it, whichever label convention the widget follows
-    /// (see [`WidgetHost::label_inflation`]). Assigning it through [`WidgetHost::layout`]
-    /// lands a rect exactly this tall. `None` when the widget has no natural height.
+    /// The widget's natural CONTENT height — the control below its detached label, if
+    /// any. What a layout strategy allots; [`WidgetHost::layout`] places that content
+    /// box at the origin it is given and hangs the label ([`WidgetHost::label_strip`])
+    /// above it. `None` when the widget has no natural height.
     fn preferred_height(&self) -> Option<f32> { None }
 
-    /// How far past an assigned rect this widget grows on `set_rect` to make room for
-    /// its detached label — the legacy inflating convention (ProgressBar, ButtonStrip,
-    /// ...). Zero for widgets that keep the assigned rect and draw the label inside it
-    /// (Slider, Dropdown, ...), and for inline-label widgets. `layout` subtracts it, so
-    /// both conventions land the occupied height they were allotted.
-    fn label_inflation(&self) -> f32 { 0.0 }
+    /// The height of the detached-label strip above this widget's content: zero for
+    /// unlabeled and inline-label widgets. A widget's occupied rect is its content plus
+    /// this strip, whichever legacy convention its `set_rect` follows; `layout` lands
+    /// the content at the origin and the strip above it, in the gap a strategy leaves
+    /// between rows (`layout::CONTROL_GAP` holds one).
+    fn label_strip(&self) -> f32 { 0.0 }
 
     fn mark_dirty(&mut self, ctx: &mut UiContext) {
         let b = self.base_mut();
diff --git a/src/widget/model.rs b/src/widget/model.rs
index feca440..c3fb7d2 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -712,6 +712,17 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
     /// The rect the wrapped widget paints into: the widget's rect minus the detached-label
     /// region at the top (zero inset when there is no label, or when the widget draws its label
     /// inline — `Widget::label_offset` / [`Layout::inline_label`]).
+    /// How far past an assigned rect `set_rect` grows this widget for its detached
+    /// label — the legacy inflating convention; zero for the eating convention and
+    /// for inline labels.
+    fn label_inflation(&self) -> f32 {
+        if Layout::inline_label(&self.inner) || !Layout::inflates_label_rect(&self.inner) {
+            0.0
+        } else {
+            self.base.label_offset()
+        }
+    }
+
     fn content_rect(&self) -> Rect {
         let top = if Layout::inline_label(&self.inner) { 0.0 } else { self.base.label_offset() };
         Rect {
@@ -1104,11 +1115,13 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         // The WidgetHost default (measure + set_rect), plus recursive child layout for visible
         // containers — the ctx-carrying half of the arrangement the model can't do in
         // `arrange_children`.
-        // `measure` speaks occupied heights; `set_rect` re-adds the inflating
-        // convention's label strip, so hand it the content height and land exactly
-        // the measured box.
+        // `origin` is the CONTENT box's top-left and `measure` its height; the detached
+        // label hangs in the strip above, so the occupied rect starts `strip` higher.
+        // `set_rect` speaks the widget's legacy convention: an inflating widget grows
+        // by the strip itself, one whose label eats into its rect needs it included.
         let size = self.measure(constraints, ctx);
-        self.set_rect(origin.x, origin.y, size.width, size.height - self.label_inflation());
+        let strip = self.label_strip();
+        self.set_rect(origin.x, origin.y - strip, size.width, size.height + strip - self.label_inflation());
         let host_id = self.base.id();
         Layout::register_embedded_children(&mut self.inner, host_id, ctx);
     }
@@ -1167,24 +1180,15 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         }
     }
 
-    /// The height a layout should allot: the widget's intrinsic content height plus its
-    /// detached-label strip — the OCCUPIED height, the same number under either label
-    /// convention. `layout` lands exactly this: it hands `set_rect` the content height
-    /// for an inflating widget (which then grows by the strip) and the whole height for
-    /// one whose label eats into its rect. One rule, so a column of mixed controls keeps
-    /// one rhythm — before this, an inflating widget's label sat in the gap after it.
+    /// The intrinsic content height — the control below the label. Which legacy
+    /// `set_rect` convention the widget follows (rect inflated by the label, or the
+    /// label eating into it) is `layout`'s business, not the caller's.
     fn preferred_height(&self) -> Option<f32> {
-        let size = Layout::intrinsic_size(&self.inner)?;
-        let strip = if Layout::inline_label(&self.inner) { 0.0 } else { self.base.label_offset() };
-        Some(size.height + strip)
+        Layout::intrinsic_size(&self.inner).map(|s| s.height)
     }
 
-    fn label_inflation(&self) -> f32 {
-        if Layout::inline_label(&self.inner) || !Layout::inflates_label_rect(&self.inner) {
-            0.0
-        } else {
-            self.base.label_offset()
-        }
+    fn label_strip(&self) -> f32 {
+        if Layout::inline_label(&self.inner) { 0.0 } else { self.base.label_offset() }
     }
 
     /// The `WidgetHost::measure` default, except the width consults the intrinsic size when the
@@ -1196,7 +1200,8 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         } else {
             w
         };
-        let pref_h = self.preferred_height().unwrap_or(h);
+        // Content height: the intrinsic one, else the landed rect less its label strip.
+        let pref_h = self.preferred_height().unwrap_or(h - self.label_strip());
         crate::widget::Size {
             width: pref_w.clamp(constraints.min_width, constraints.max_width),
             height: pref_h.clamp(constraints.min_height, constraints.max_height),
@@ -1693,14 +1698,14 @@ mod tests {
         Rect { x, y, width: w, height: h }
     }
 
-    /// One rhythm for labeled controls whichever label convention they follow: the
-    /// preferred height is the OCCUPIED height (content + label strip) for the inflating
-    /// kind (ProgressBar) and the eating kind (Slider, Spinbox, Dropdown) alike, and
-    /// `layout` lands a rect exactly that tall with the full content height inside it —
-    /// so a strategy allotting preferred heights neither squashes a track to the label's
-    /// leftovers nor lets a label spill into the gap below it.
+    /// One rhythm for labeled controls whichever legacy convention they follow: the
+    /// preferred height is the CONTENT height for the inflating kind (ProgressBar) and
+    /// the eating kind (Slider, Spinbox, Dropdown) alike, and `layout` places that
+    /// content at the origin with the label strip hanging above it — so a strategy
+    /// placing content boxes lines mixed controls up by content, neither squashes a
+    /// track to the label's leftovers nor lets a label spill into the gap below.
     #[test]
-    fn labeled_controls_occupy_exactly_their_preferred_height() {
+    fn labeled_controls_land_their_content_at_the_origin_with_the_label_above() {
         use crate::widget::{Dropdown, LayoutConstraints, Point, ProgressBar, Slider, Spinbox};
         let mut ctx = UiContext::new();
         let mut slider = Slider::new().with_label("Gain");
@@ -1717,10 +1722,12 @@ mod tests {
             ("progress bar", &mut bar, crate::layout::progressbar_height()),
         ] {
             let pref = w.preferred_height().expect(name);
-            assert!((pref - (content + strip)).abs() < 0.01, "{name}: preferred {pref} is content {content} + strip {strip}");
-            w.layout(Point { x: 0.0, y: 0.0 }, LayoutConstraints::new(100.0, 100.0, pref, pref), &mut ctx);
-            let landed = w.rect().3;
-            assert!((landed - pref).abs() < 0.01, "{name}: landed {landed} for preferred {pref}");
+            assert!((pref - content).abs() < 0.01, "{name}: preferred {pref} is the content height {content}");
+            assert!((w.label_strip() - strip).abs() < 0.01, "{name}: one label strip");
+            w.layout(Point { x: 0.0, y: 100.0 }, LayoutConstraints::new(100.0, 100.0, pref, pref), &mut ctx);
+            let (_, top, _, landed) = w.rect();
+            assert!((top - (100.0 - strip)).abs() < 0.01, "{name}: the label hangs above the origin (top {top})");
+            assert!((landed - (content + strip)).abs() < 0.01, "{name}: occupied {landed} = content + strip");
             let painted = landed - crate::widget::label_offset(w);
             assert!((painted - content).abs() < 0.01, "{name}: content {painted}, wanted {content}");
         }