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