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