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

commit90c03e1a0252e6201907c073a5d1f4868db9f9ad
parent969f87348e
authorLucas Galante <[email protected]>
date2026-09-08 15:36
feat(layout)!: one spacing rule — occupied heights, one gap, one label inset, one text inset

Spacing between controls varied because the toolkit had several rules where it
needed one:

- Two label conventions sized differently. A widget whose detached label eats
  into its rect (Slider, Dropdown, Spinbox, TextBox, ...) reported content + strip
  as its preferred height; one whose label inflates its rect (ProgressBar,
  ButtonStrip, ...) reported the bare content and then grew past the box a
  strategy allotted — its label sat in the gap below it. Now `preferred_height`
  is the OCCUPIED height for both (content + label strip), `WidgetHost::
  label_inflation` says how far a widget grows on set_rect, and `Adapted::layout`
  subtracts it, so a widget lands exactly the box it was measured for. The
  strategies (container_layout, FlexLayout — which now lands through `layout`
  instead of set_rect) get one rhythm for free; `render_widget` and the legacy
  Column/Row/Section builders, which speak content heights, convert at their
  boundary and behave as before.
- The detached label's x inset was 4px on some controls (an override on six
  widgets) and 0 on the rest, so labels in a column zigzagged and RangeSlider's
  carve-out tab sat beside its label. `DETACHED_LABEL_INSET` (4) is the default
  for every control; the overrides and the hardcoded `4.0`s in the tab carves go.
- Field text sat 8px in on most controls and 4px on Spinbox and ColorSelector.
  `CONTROL_TEXT_INSET` (8) for both.
- The strategies' default gaps were 8, 12 and 8 with paddings of 8 and 10.
  `CONTROL_GAP` (8) is every strategy's default gap and padding, and the legacy
  Row builder's spacing.

Breaking for hosts that call `preferred_height` on an inflating labeled widget
and expected the content height: it now includes the strip (subtract
`label_inflation()` for the old number). Tests: the adapter test now drives
`layout` and asserts occupied == preferred and content intact for both kinds.

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

 src/layout.rs                            | 55 +++++++++++++++++++++-----
 src/widget/container/container_layout.rs | 20 +++++-----
 src/widget/display/float3.rs             |  5 ---
 src/widget/display/progress_bar.rs       |  4 +-
 src/widget/input/color_selector.rs       |  6 +--
 src/widget/input/dropdown.rs             |  5 +--
 src/widget/input/slider.rs               |  7 +---
 src/widget/input/slider2d.rs             |  7 +---
 src/widget/input/spinbox.rs              | 17 ++++----
 src/widget/input/text_box.rs             |  3 --
 src/widget/mod.rs                        | 11 ++++++
 src/widget/model.rs                      | 66 ++++++++++++++++++--------------
 12 files changed, 118 insertions(+), 88 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index c9414ec..2a4dd5e 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -291,6 +291,23 @@ static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
 /// colour selector, button strip, breadcrumb. One number, so a form built from
 /// 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 inset from a control's edge to its text: the field text of a TextBox,
+/// Dropdown, Spinbox, FontSelector, KeybindRecorder or ColorSelector, a left-
+/// justified Button or Toggle label, a Slider's readout. Fields in a column line
+/// their text up because they all use this.
+pub const CONTROL_TEXT_INSET: f32 = 8.0;
+
+/// The one inset from a control's left edge to its detached label above it — the
+/// x offset the adapter draws the label at, and the tab hugging that label in the
+/// carve-out compositions (Slider, Dropdown, RangeSlider). Every labeled control
+/// uses it, so a column of labels is one line.
+pub const DETACHED_LABEL_INSET: f32 = 4.0;
 /// The same for the track-shaped controls: slider, range slider, progress bar,
 /// usage bar.
 pub const DEFAULT_TRACK_HEIGHT: f32 = 16.0;
@@ -3672,7 +3689,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));
     }
-    w.layout(crate::widget::Point { x, y }, crate::widget::LayoutConstraints::new(ww, ww, wh, wh), ctx);
+    // `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);
 
     // 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
@@ -4036,10 +4057,12 @@ 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() {
-            wh = pref;
+            // Preferred heights are occupied (label included); this builder adds
+            // the label room itself below.
+            wh = pref - top_room;
         }
-        let top_room = crate::widget::label_offset(w);
         let total_h = wh + top_room;
         let x = self.ax(x_off);
         let y = self.ay();
@@ -4056,7 +4079,7 @@ impl Column {
             base_x: self.ox + self.cx,
             y: row_y,
             cursor_x: 0.0,
-            spacing: 8.0,
+            spacing: CONTROL_GAP,
         };
         f(&mut row);
         self.y = self.y + h;
@@ -4088,7 +4111,9 @@ 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() {
-            wh = pref;
+            // 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);
         }
         render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
         self.cursor_x += ww + self.spacing;
@@ -4216,11 +4241,13 @@ 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() {
-            wh = pref;
+            // Preferred heights are occupied (label included); this builder adds
+            // the label room itself below.
+            wh = pref - top_room;
         }
         let pad = self.padding();
-        let top_room = crate::widget::label_offset(w);
         let total_h = wh + top_room;
 
         let name = w.type_name();
@@ -4704,7 +4731,7 @@ impl LayoutStrategy for FlexLayout {
         self.spacing
     }
 
-    fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
+    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;
         match self.direction {
@@ -4715,7 +4742,11 @@ impl LayoutStrategy for FlexLayout {
                         let child_w = child.rect().2;
                         let child_h = child.preferred_height().unwrap_or(child.rect().3);
                         let use_h = if child_h > 0.0 { child_h } else { h };
-                        child.set_rect(cur_x, cur_y, child_w, use_h);
+                        child.layout(
+                            crate::widget::Point { x: cur_x, y: cur_y },
+                            crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
+                            ctx,
+                        );
                         cur_x += child_w + self.spacing;
                     }
                 }
@@ -4727,7 +4758,11 @@ impl LayoutStrategy for FlexLayout {
                         let child = &mut *child_ptr;
                         let child_h = child.preferred_height().unwrap_or(child.rect().3);
                         let use_h = if child_h > 0.0 { child_h } else { 44.0 };
-                        child.set_rect(x, cur_y, w, use_h);
+                        child.layout(
+                            crate::widget::Point { x, y: cur_y },
+                            crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
+                            ctx,
+                        );
                         cur_y += use_h + self.spacing;
                     }
                 }
diff --git a/src/widget/container/container_layout.rs b/src/widget/container/container_layout.rs
index aec6729..4348228 100644
--- a/src/widget/container/container_layout.rs
+++ b/src/widget/container/container_layout.rs
@@ -124,7 +124,7 @@ impl Default for VerticalLayout {
         Self {
             padding_x: 0.0,
             padding_y: 0.0,
-            spacing: 8.0,
+            spacing: crate::layout::CONTROL_GAP,
             left: 0.0,
             current_y: 0.0,
         }
@@ -421,9 +421,9 @@ pub struct ColumnsLayout {
 impl Default for ColumnsLayout {
     fn default() -> Self {
         Self {
-            padding_x: 8.0,
-            padding_y: 10.0,
-            spacing: 12.0,
+            padding_x: crate::layout::CONTROL_GAP,
+            padding_y: crate::layout::CONTROL_GAP,
+            spacing: crate::layout::CONTROL_GAP,
         }
     }
 }
@@ -499,9 +499,9 @@ pub struct MosaicLayout {
 impl Default for MosaicLayout {
     fn default() -> Self {
         Self {
-            gap: 8.0,
-            padding_x: 8.0,
-            padding_y: 10.0,
+            gap: crate::layout::CONTROL_GAP,
+            padding_x: crate::layout::CONTROL_GAP,
+            padding_y: crate::layout::CONTROL_GAP,
         }
     }
 }
@@ -651,9 +651,9 @@ pub struct ReverseMosaicLayout {
 impl Default for ReverseMosaicLayout {
     fn default() -> Self {
         Self {
-            gap: 8.0,
-            padding_x: 8.0,
-            padding_y: 10.0,
+            gap: crate::layout::CONTROL_GAP,
+            padding_x: crate::layout::CONTROL_GAP,
+            padding_y: crate::layout::CONTROL_GAP,
         }
     }
 }
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 6380636..48e857b 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -188,11 +188,6 @@ impl Layout for Float3 {
         false
     }
 
-    /// Detached label x inset — the Slider/Dropdown value, so the group label lines up with a
-    /// slider row's.
-    fn detached_label_inset(&self) -> f32 {
-        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.
diff --git a/src/widget/display/progress_bar.rs b/src/widget/display/progress_bar.rs
index b479974..487303c 100644
--- a/src/widget/display/progress_bar.rs
+++ b/src/widget/display/progress_bar.rs
@@ -157,8 +157,8 @@ 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 forwards from the narrow intrinsic size.
-        assert_eq!(WidgetHost::preferred_height(&bar), Some(crate::layout::progressbar_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));
         // Runtime type-name matching still sees "ProgressBar", not Adapted<..>.
         assert_eq!(WidgetHost::type_name(&bar), "ProgressBar");
     }
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index c8937a9..26b7936 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -264,7 +264,7 @@ impl Paint for ColorSelector {
                 let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
                 crate::widget::display::measure_text(&cursor_text, font_size)
             });
-            let caret_x = rect.x + 4.0 + text_w;
+            let caret_x = rect.x + crate::layout::CONTROL_TEXT_INSET + text_w;
             let caret_h = font_size * 1.15;
             let caret_y = rect.y + (visual_h - caret_h) / 2.0;
             quads.push((caret_x, caret_y, 1.5, caret_h, [0.80, 0.80, 0.85, 1.0]));
@@ -367,7 +367,7 @@ impl Paint for ColorSelector {
             let depth = crate::layout::bevel_width().min(ph * 0.2);
             ctx.bevel(swatch, (preview_radius, preview_radius, preview_radius, preview_radius), linear_c, depth);
             let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
-            ctx.text(hex, rect.x + 4.0, crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0), 12.0, [0xcc, 0xcc, 0xd4]);
+            ctx.text(hex, rect.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0), 12.0, [0xcc, 0xcc, 0xd4]);
             return;
         }
 
@@ -437,7 +437,7 @@ impl Paint for ColorSelector {
         let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
         ctx.text(
             hex,
-            rect.x + 4.0,
+            rect.x + crate::layout::CONTROL_TEXT_INSET,
             crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
             12.0,
             [0xcc, 0xcc, 0xd4],
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index f03884b..59857e6 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -495,7 +495,7 @@ impl Dropdown {
                     .as_deref()
                     .map(|l| crate::widget::display::measure_text_width(l, &fam, fsize))
                     .unwrap_or(0.0);
-                let inset = 4.0; // Layout::detached_label_inset — the label's x offset
+                let inset = crate::layout::DETACHED_LABEL_INSET; // the label's x offset
                 let tab_w = (text_w + 2.0 * inset + 2.0 * g)
                     .max(2.0 * orad.0 + 8.0)
                     .min(outer_r - outer_x);
@@ -904,9 +904,6 @@ impl Layout for Dropdown {
         false
     }
 
-    fn detached_label_inset(&self) -> f32 {
-        4.0
-    }
 
     /// Content size for the scene layout engine (Phase 2b). A normal dropdown is wide enough for
     /// the widest option (via `content_width`, which already includes the arrow/padding inset), so
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index c944f78..ed065c5 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -433,11 +433,6 @@ impl Layout for Slider {
         false // legacy Slider::set_rect stored the assigned rect verbatim
     }
 
-    /// Detached label x inset — keeps the label clear of its carve-out tab's
-    /// left wall (the Dropdown value).
-    fn detached_label_inset(&self) -> f32 {
-        4.0
-    }
 
 
     fn intrinsic_size(&self) -> Option<Size> {
@@ -881,7 +876,7 @@ pub(crate) fn carve_labeled_well(ctx: &mut PaintCtx, track: Rect, strip: f32, la
         // recessed well hugging the label run, bottom open into the
         // track's well; the well's top wall picks up right of the
         // tab's throat.
-        let inset = 4.0; // Layout::detached_label_inset — the label's x offset
+        let inset = crate::layout::DETACHED_LABEL_INSET; // the label's x offset
         let tab_w = (label_w + 2.0 * inset).max(2.0 * radius + 8.0).min(track.width);
         let tab_r = track.x + tab_w;
         // The labeled-Dropdown composition: pieces extend `depth`
diff --git a/src/widget/input/slider2d.rs b/src/widget/input/slider2d.rs
index fd51914..562d0bb 100644
--- a/src/widget/input/slider2d.rs
+++ b/src/widget/input/slider2d.rs
@@ -91,11 +91,6 @@ impl Layout for Slider2D {
         false // the Slider rule: the detached label eats into the assigned rect
     }
 
-    /// Detached label x inset — keeps the label clear of its carve-out tab's
-    /// left wall (the Dropdown/Slider value).
-    fn detached_label_inset(&self) -> f32 {
-        4.0
-    }
 
     fn intrinsic_size(&self) -> Option<Size> {
         Some(Size::new(64.0, 64.0))
@@ -156,7 +151,7 @@ impl Paint for Slider2D {
                 .as_deref()
                 .map(|l| crate::widget::display::measure_text_width(l, &fam, fsize))
                 .unwrap_or(0.0);
-            let inset = 4.0; // Layout::detached_label_inset — the label's x offset
+            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)
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 3480401..8a7522a 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -275,9 +275,6 @@ impl Layout for Spinbox {
     }
 
 
-    fn detached_label_inset(&self) -> f32 {
-        4.0 // legacy Control::control_label x offset
-    }
 
     fn intrinsic_size(&self) -> Option<Size> {
         Some(Size::new(0.0, crate::layout::spinbox_height()))
@@ -371,10 +368,10 @@ impl Paint for Spinbox {
                     // cannot carry.
                     let accent = colors::highlight_primary_color();
                     ctx.quad(
-                        Rect { x: g.x + 4.0, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
+                        Rect { x: g.x + crate::layout::CONTROL_TEXT_INSET, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
                         accent,
                     );
-                    let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
+                    let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                     let cursor_y = g.y + (g.h - 14.0) / 2.0;
                     ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
                 }
@@ -416,7 +413,7 @@ impl Paint for Spinbox {
                 );
             }
             if self.editing {
-                let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
                 ctx.rounded_rect(
                     Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
@@ -438,7 +435,7 @@ impl Paint for Spinbox {
                 ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
                 ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
 
-                let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
                 ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
             }
@@ -447,9 +444,9 @@ impl Paint for Spinbox {
         // Value, unit, and -/+ glyphs.
         let tc = colors::spinbox_text_color();
         let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
-        ctx.text(self.value_text(), g.x + 4.0, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color);
+        ctx.text(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color);
         if let Some(ref unit) = self.unit {
-            ctx.text(unit.clone(), g.x + 4.0 + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a]);
+            ctx.text(unit.clone(), g.x + crate::layout::CONTROL_TEXT_INSET + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a]);
         }
         if g.btn_w > 0.0 {
             let dec_center_x = g.split_dec + g.pad + g.btn_w * 0.5;
@@ -495,7 +492,7 @@ impl Input for Spinbox {
                 } else if *px < g.split_dec {
                     self.begin_edit(false);
                     self.cursor_idx = self
-                        .x_to_idx(px - (g.x + 4.0))
+                        .x_to_idx(px - (g.x + crate::layout::CONTROL_TEXT_INSET))
                         .min(self.edit_buffer.chars().count());
                     ectx.request_focus();
                     true
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 891b176..d22bdae 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1295,9 +1295,6 @@ impl Layout for TextBox {
         false
     }
 
-    fn detached_label_inset(&self) -> f32 {
-        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.
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index c36d06c..b86ea6d 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -200,8 +200,19 @@ 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.
     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 }
+
     fn mark_dirty(&mut self, ctx: &mut UiContext) {
         let b = self.base_mut();
         if b.dirty {
diff --git a/src/widget/model.rs b/src/widget/model.rs
index fa23c57..feca440 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -56,11 +56,11 @@ pub trait Layout {
         true
     }
 
-    /// Horizontal inset of the detached base label. Legacy split: `Control::control_label`
-    /// added +4px (Spinbox et al., explicitly zeroed for Slider/RangeSlider); the default
-    /// `text_labels` used none (ProgressBar). Default: none.
+    /// Horizontal inset of the detached base label: [`crate::layout::DETACHED_LABEL_INSET`]
+    /// for every control, so a column of labels is one line and the carve-out tabs
+    /// (which hug the label at this inset) sit under their labels.
     fn detached_label_inset(&self) -> f32 {
-        0.0
+        crate::layout::DETACHED_LABEL_INSET
     }
 
     /// Whether `WidgetHost::measure` should prefer [`intrinsic_size`](Layout::intrinsic_size)'s
@@ -1104,8 +1104,11 @@ 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.
         let size = self.measure(constraints, ctx);
-        self.set_rect(origin.x, origin.y, size.width, size.height);
+        self.set_rect(origin.x, origin.y, size.width, size.height - self.label_inflation());
         let host_id = self.base.id();
         Layout::register_embedded_children(&mut self.inner, host_id, ctx);
     }
@@ -1146,11 +1149,7 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
     /// inside their rect and get no inflation. Zero-cost when no label is set.
     fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
         let r = Layout::adjust_rect(&self.inner, Rect { x, y, width: w, height: h });
-        let inflation = if Layout::inline_label(&self.inner) || !Layout::inflates_label_rect(&self.inner) {
-            0.0
-        } else {
-            self.base.label_offset()
-        };
+        let inflation = self.label_inflation();
         self.base.x = r.x;
         self.base.y = r.y;
         self.base.w = r.width;
@@ -1168,20 +1167,24 @@ 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.
+    /// 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.
     fn preferred_height(&self) -> Option<f32> {
         let size = Layout::intrinsic_size(&self.inner)?;
-        let strip = if Layout::inline_label(&self.inner) || Layout::inflates_label_rect(&self.inner) {
+        let strip = if Layout::inline_label(&self.inner) { 0.0 } else { self.base.label_offset() };
+        Some(size.height + strip)
+    }
+
+    fn label_inflation(&self) -> f32 {
+        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
@@ -1690,14 +1693,16 @@ 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.
+    /// 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.
     #[test]
-    fn preferred_height_of_a_labeled_control_leaves_the_content_height_intact() {
-        use crate::widget::{Dropdown, ProgressBar, Slider, Spinbox};
+    fn labeled_controls_occupy_exactly_their_preferred_height() {
+        use crate::widget::{Dropdown, LayoutConstraints, Point, ProgressBar, Slider, Spinbox};
+        let mut ctx = UiContext::new();
         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");
@@ -1712,9 +1717,12 @@ mod tests {
             ("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}");
+            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}");
+            let painted = landed - crate::widget::label_offset(w);
+            assert!((painted - content).abs() < 0.01, "{name}: content {painted}, wanted {content}");
         }
     }