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

commit06830ff25ae4748194b82dbade3e6e5e2c5549a0
parentd4f65bfcff
authorLucas Galante <[email protected]>
date2026-09-09 12:43
feat(group): a member's detached label is in the hull

WidgetHost::detached_label_rect: where a detached label is drawn — the strip
above the content, as wide as the text, measured in the detached-label font.
A rect is the content's width, so a label wider than its control (a
StatusDot's) ran past the rect and a Group's wall cut through the text;
the hull now unions the label's box with the rect. No height change: the
strip was already in the rect. Test with a labelled StatusDot.

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

 src/widget/container/group.rs | 38 +++++++++++++++++++++++++++++++++-----
 src/widget/mod.rs             |  7 +++++++
 src/widget/model.rs           | 12 ++++++++++++
 3 files changed, 52 insertions(+), 5 deletions(-)

diff --git a/src/widget/container/group.rs b/src/widget/container/group.rs
index 3e07449..42c997a 100644
--- a/src/widget/container/group.rs
+++ b/src/widget/container/group.rs
@@ -142,10 +142,19 @@ impl Group {
             // always its content plus this strip"), so the lasso wraps the label
             // by taking the rect as is. Subtracting the strip here again pushed
             // every labelled member's hull one strip too high, and the tab with it.
-            hull = Some(match hull {
-                None => (x, y, x + ww, y + hh),
-                Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + ww), y1.max(y + hh)),
-            });
+            // Widthwise the rect is the content's: a label wider than its control
+            // (a StatusDot's) runs past it, so the label's own box joins the hull
+            // or the wall cuts through the text.
+            let mut grow = |x: f32, y: f32, w: f32, h: f32| {
+                hull = Some(match hull {
+                    None => (x, y, x + w, y + h),
+                    Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h)),
+                });
+            };
+            grow(x, y, ww, hh);
+            if let Some(l) = w.detached_label_rect() {
+                grow(l.x, l.y, l.width, l.height);
+            }
         }
         hull.map(|(x0, y0, x1, y1)| Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
     }
@@ -305,7 +314,7 @@ impl Input for Group {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::widget::{Button, Slider, WidgetHost};
+    use crate::widget::{Button, DotStatus, Slider, StatusDot, WidgetHost};
 
     fn register(ctx: &mut UiContext, w: &mut dyn WidgetHost) -> WidgetId {
         let id = w.base().id();
@@ -348,6 +357,25 @@ mod tests {
         assert_eq!(f.body.height, 16.0 + strip + 20.0);
     }
 
+    /// A label wider than its control's rect is in the hull: the frame closes past
+    /// the text, not through it.
+    #[test]
+    fn a_members_wide_label_is_in_the_hull() {
+        let mut ctx = UiContext::new();
+        let mut dot = StatusDot::new(DotStatus::Inactive).with_label("StatusDot (inactive)");
+        let strip = dot.label_strip();
+        let size = StatusDot::SIZE;
+        WidgetHost::set_rect(&mut dot, 100.0, 50.0, size, size + strip);
+        let label = dot.detached_label_rect().expect("a detached label");
+        assert!(label.width > size, "the label text is wider than the dot");
+        let ids = vec![register(&mut ctx, &mut dot)];
+        let g = Group::new(ids).with_padding(10.0);
+        let f = g.inner().frame(&ctx).unwrap();
+        assert_eq!(f.body.x, 90.0, "the left is the rect's");
+        assert_eq!(f.body.x + f.body.width, label.x + label.width + 10.0, "the right is the label's");
+        assert_eq!(f.body.height, size + strip + 20.0, "the label strip adds no height: it is in the rect");
+    }
+
     /// Fit to plate: a side near the plate's edge takes it (one padding in), a
     /// far side keeps the hull, and a corner on the plate's corner is concentric.
     #[test]
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index fa939dc..03b9166 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -217,6 +217,13 @@ pub trait WidgetHost {
     /// `layout::CONTROL_GAP` between the blocks.
     fn label_strip(&self) -> f32 { self.base().label_offset() }
 
+    /// Where the detached label is drawn: the strip above the content, as wide as the
+    /// label's text. `None` for an unlabeled widget and for an inline label. The label
+    /// may be wider than the widget's rect (a StatusDot's, a Checkbox's) — the rect is
+    /// the content's width, and the text runs past it — so anything wrapping a widget
+    /// as a block (a `Group`'s hull) unions this with the rect.
+    fn detached_label_rect(&self) -> Option<crate::scene::layout::Rect> { None }
+
     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 c1a1771..fb8fd1f 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1191,6 +1191,18 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         if Layout::inline_label(&self.inner) { 0.0 } else { self.base.label_offset() }
     }
 
+    /// The detached label's box, as `base_label_fallback` places the text: at the
+    /// label inset on the strip above the content, measured in the detached-label font.
+    fn detached_label_rect(&self) -> Option<Rect> {
+        if Layout::inline_label(&self.inner) {
+            return None;
+        }
+        let label = self.base.label.as_deref()?;
+        let (fam, size) = crate::layout::control_label_font_detached_parsed();
+        let width = crate::widget::display::measure_text_width(label, &fam, size);
+        Some(Rect { x: self.base.x + Layout::detached_label_inset(&self.inner), y: self.base.y, width, height: self.label_strip() })
+    }
+
     /// The `WidgetHost::measure` default, except the width consults the intrinsic size when the
     /// widget opts in ([`Layout::intrinsic_measure_width`] — Dropdown's `auto_width`).
     fn measure(&self, constraints: crate::widget::LayoutConstraints, _ctx: &UiContext) -> crate::widget::Size {