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

src/widget/container/group.rs (20.9K)

  1 //! `Group` — a lasso around registered widgets. See "Plates, wells and seams"
  2 //! in `CLAUDE.md`: a group is a segment of the plate it sits on, parted from
  3 //! the rest by a section carve rather than a seam.
  4 //!
  5 //! Unlike a container it OWNS nothing and lays nothing out: it is defined by
  6 //! membership alone (widget ids), and its frame is derived every paint from
  7 //! where the host's own layout put those members — the padded hull of their
  8 //! rects, with a title tab flush on the top edge when it has a label. So a
  9 //! host groups controls without restructuring its tree or its layout code.
 10 //!
 11 //! **Fit to plate.** Given the plate it sits on ([`Adapted<Group>::with_plate`]
 12 //! and [`Adapted<Group>::with_fit`]), any side of the frame within `snap` of
 13 //! that plate's edge (or past it) extends to the edge, one padding in, and a
 14 //! corner whose two sides both snapped takes the plate's corner concentrically
 15 //! — the Dropdown's corner-frame rule. A group on a narrow pane becomes that
 16 //! pane's inset lining; on a wide one it stays a lasso around its members.
 17 //!
 18 //! **The frame is the section's frame.** Under `control_relief` it is the
 19 //! settings app's union well ([`PaintCtx::section_well`]): the body carved as
 20 //! a recess, the title tab carved with it as one shape, the throat between
 21 //! them filleted. Without relief it is the section outline: a hairline ring
 22 //! with a gap for the title. The section style keys
 23 //! (`style.container.section.{depth,font,padding}`) apply.
 24 //!
 25 //! The group is never hittable and draws only through `paint_ui` (it needs
 26 //! the context to find its members), so hosts on the legacy quad bridges see
 27 //! it only through the prim replay — which carries every prim it emits
 28 //! (recesses, fillets, vectors, text). `CCE_GROUP_DEBUG=1` prints each
 29 //! paint's hull, plate and frame.
 30 
 31 use crate::scene::layout::Rect;
 32 use crate::scene::paint::{Cap, PaintCtx};
 33 use crate::widget::{Adapted, Input, Layout, Paint, UiContext, WidgetId};
 34 
 35 /// A group's frame for one paint: the body box, the title tab flush on its top
 36 /// edge (when labelled), and the body's corner radii (TL, TR, BR, BL — a
 37 /// fitted corner is the plate's, concentric).
 38 #[derive(Debug, Clone, Copy, PartialEq)]
 39 pub struct GroupFrame {
 40     pub body: Rect,
 41     pub tab: Option<Rect>,
 42     pub radii: (f32, f32, f32, f32),
 43 }
 44 
 45 #[derive(Debug, Clone)]
 46 pub struct Group {
 47     members: Vec<WidgetId>,
 48     label: Option<String>,
 49     /// Frame inset around the members' hull.
 50     padding: f32,
 51     /// The plate the group sits on, and its corner radius — the fit target.
 52     plate: Option<(Rect, f32)>,
 53     fit: bool,
 54     /// How close (px) a side must be to the plate's edge to snap to it.
 55     snap: f32,
 56 }
 57 
 58 impl Group {
 59     /// A group of `members` (registered widget ids); no plate, no fit, the
 60     /// section padding plus the relief width around the hull.
 61     pub fn new(members: Vec<WidgetId>) -> Adapted<Group> {
 62         Adapted::new(Group {
 63             members,
 64             label: None,
 65             // The section padding plus the wall's outer half: the carve straddles
 66             // the frame line, so this keeps its outer slope clear of the members.
 67             padding: crate::layout::section_padding() + crate::layout::bevel_width() * 0.5,
 68             plate: None,
 69             fit: false,
 70             snap: 12.0,
 71         })
 72     }
 73 
 74     pub fn members(&self) -> &[WidgetId] {
 75         &self.members
 76     }
 77 
 78     pub fn set_members(&mut self, members: Vec<WidgetId>) {
 79         self.members = members;
 80     }
 81 
 82     /// The plate this group sits on (rect, corner radius) — what `fit` snaps to.
 83     pub fn set_plate(&mut self, plate: Rect, corner_radius: f32) {
 84         self.plate = Some((plate, corner_radius));
 85     }
 86 
 87     pub fn set_fit(&mut self, fit: bool) {
 88         self.fit = fit;
 89     }
 90 
 91     /// The section carve's wall width: the DE relief scaled by the section depth
 92     /// multiplier, capped against the frame height (the ParametersBg rule).
 93     fn depth(&self, h: f32) -> f32 {
 94         (crate::layout::bevel_width() * crate::layout::section_depth()).min(h * 0.2).max(0.5)
 95     }
 96 
 97     fn title_font(&self) -> (String, f32) {
 98         let (fam, size) = crate::layout::parse_font_string(&crate::layout::section_label_font());
 99         (fam, size.unwrap_or(14.0))
100     }
101 
102     /// The title tab's height — zero without a label.
103     pub fn tab_height(&self) -> f32 {
104         match self.label.as_deref().filter(|l| !l.is_empty()) {
105             Some(_) => self.title_font().1 + 8.0,
106             None => 0.0,
107         }
108     }
109 
110     /// The vertical room this lasso takes ABOVE its members' hull: the padding
111     /// and the title tab. A lasso is laid out by its members, so a host that
112     /// places them in rows leaves this much between the row before and the
113     /// first member — the row a strategy would reserve for the title were the
114     /// group its child — or the tab lands on that row.
115     pub fn headroom(&self) -> f32 {
116         self.padding + self.tab_height()
117     }
118 
119     /// The padding between the members' hull and the frame.
120     pub fn padding(&self) -> f32 {
121         self.padding
122     }
123 
124     /// The members' hull: the union of the registered, visible, on-screen members' rects.
125     fn hull(&self, ui: &UiContext) -> Option<Rect> {
126         let mut hull: Option<(f32, f32, f32, f32)> = None;
127         for id in &self.members {
128             let Some(ptr) = ui.tree.get_ptr(*id) else { continue };
129             if ptr.is_null() {
130                 continue;
131             }
132             let w = unsafe { &*ptr };
133             if !w.visible() {
134                 continue;
135             }
136             let (x, y, ww, hh) = w.rect();
137             if ww <= 0.0 || hh <= 0.0 || x + ww <= 0.0 || y + hh <= 0.0 {
138                 continue;
139             }
140             // The rect IS the block — a labelled control's detached label strip
141             // is already in it (`WidgetHost::label_strip`: "a widget's rect is
142             // always its content plus this strip"), so the lasso wraps the label
143             // by taking the rect as is. Subtracting the strip here again pushed
144             // every labelled member's hull one strip too high, and the tab with it.
145             // Widthwise the rect is the content's: a label wider than its control
146             // (a StatusDot's) runs past it, so the label's own box joins the hull
147             // or the wall cuts through the text.
148             let mut grow = |x: f32, y: f32, w: f32, h: f32| {
149                 hull = Some(match hull {
150                     None => (x, y, x + w, y + h),
151                     Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h)),
152                 });
153             };
154             grow(x, y, ww, hh);
155             if let Some(l) = w.detached_label_rect() {
156                 grow(l.x, l.y, l.width, l.height);
157             }
158         }
159         hull.map(|(x0, y0, x1, y1)| Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
160     }
161 
162     /// This paint's frame — `None` when no member is on screen.
163     pub fn frame(&self, ui: &UiContext) -> Option<GroupFrame> {
164         let hull = self.hull(ui)?;
165         let p = self.padding;
166         let mut body = Rect { x: hull.x - p, y: hull.y - p, width: hull.width + 2.0 * p, height: hull.height + 2.0 * p };
167         let r = crate::layout::plate_corner_radius();
168         let mut radii = (r, r, r, r);
169         let title = self.label.as_deref().filter(|l| !l.is_empty());
170         let (fam, size) = self.title_font();
171         let tab_h = self.tab_height();
172         if let (true, Some((plate, pr))) = (self.fit, self.plate) {
173             // The frame's natural seat is one padding inside the plate's edge. A
174             // side within `snap` of that seat — or past it — takes it, moving
175             // OUTWARD only: a member that already sits in the padding zone keeps
176             // the frame outside itself, up to the plate's own edge. A snapped
177             // top seats one tab lower, so the title stays inside the plate — but
178             // never lower than the plate's edge: the frame is clamped to the
179             // plate on every side, and a member flush with the plate's top keeps
180             // the wall on that edge with the tab rising above it. The tab never
181             // covers a member; a host that wants it inside the plate lays the
182             // members out one `headroom` down (the fit's seat).
183             let (l, t) = (plate.x, plate.y);
184             let (rr, b) = (plate.x + plate.width, plate.y + plate.height);
185             let top_room = if title.is_some() { tab_h } else { 0.0 };
186             let (x0, y0, x1, y1) = (body.x, body.y, body.x + body.width, body.y + body.height);
187             let snap_l = x0 - (l + p) <= self.snap;
188             let snap_t = y0 - (t + p + top_room) <= self.snap;
189             let snap_r = (rr - p) - x1 <= self.snap;
190             let snap_b = (b - p) - y1 <= self.snap;
191             let nx0 = if snap_l { x0.min(l + p).max(l) } else { x0 };
192             let ny0 = if snap_t { y0.min(t + p + top_room).max(t) } else { y0 };
193             let nx1 = if snap_r { x1.max(rr - p).min(rr) } else { x1 };
194             let ny1 = if snap_b { y1.max(b - p).min(b) } else { y1 };
195             body = Rect { x: nx0, y: ny0, width: nx1 - nx0, height: ny1 - ny0 };
196             // A corner on the plate's corner follows its curve, concentrically —
197             // at the inset the two sides actually landed at (a tab above the
198             // plate counts as no inset).
199             let cr = |a: f32, bb: f32| (pr - a.max(bb).max(0.0)).max(0.0);
200             radii = (
201                 if snap_l && snap_t { cr(nx0 - l, ny0 - top_room - t) } else { r },
202                 if snap_r && snap_t { cr(rr - nx1, ny0 - top_room - t) } else { r },
203                 if snap_r && snap_b { cr(rr - nx1, b - ny1) } else { r },
204                 if snap_l && snap_b { cr(nx0 - l, b - ny1) } else { r },
205             );
206         }
207         let tab = title.map(|l| {
208             let tw = (crate::widget::display::measure_text_width(l, &fam, size) + 16.0).clamp(8.0, body.width.max(8.0));
209             // Flush on the body's top edge, at its left (the settings' tab).
210             Rect { x: body.x, y: body.y - tab_h, width: tw, height: tab_h }
211         });
212         if std::env::var_os("CCE_GROUP_DEBUG").is_some() {
213             eprintln!("[group] {:?} hull={:?} plate={:?} fit={} body={:?} tab={:?}", self.label, hull, self.plate, self.fit, body, tab);
214         }
215         Some(GroupFrame { body, tab, radii })
216     }
217 }
218 
219 impl Adapted<Group> {
220     pub fn with_padding(mut self, padding: f32) -> Self {
221         self.padding = padding;
222         self
223     }
224 
225     /// The plate this group sits on (rect, corner radius) — see [`Group::set_plate`].
226     pub fn with_plate(mut self, plate: Rect, corner_radius: f32) -> Self {
227         self.plate = Some((plate, corner_radius));
228         self
229     }
230 
231     /// Fit to the plate: sides near its edges snap to them (see the module doc).
232     pub fn with_fit(mut self, fit: bool) -> Self {
233         self.fit = fit;
234         self
235     }
236 
237     pub fn with_snap(mut self, snap: f32) -> Self {
238         self.snap = snap;
239         self
240     }
241 }
242 
243 impl Layout for Group {
244     /// The title is part of the frame (the tab), not a detached control label.
245     fn inline_label(&self) -> bool {
246         true
247     }
248 }
249 
250 impl Paint for Group {
251     fn color(&self) -> [f32; 4] {
252         [0.0; 4]
253     }
254 
255     fn sync_label(&mut self, label: &str) {
256         self.label = Some(label.to_string());
257     }
258 
259     /// The title is authored here, with its font, in `paint_ui` — it must pass
260     /// through the paint walk verbatim rather than be re-derived from `paint`
261     /// (which, without the context, has nothing to say).
262     fn paints_own_subtree(&self) -> bool {
263         true
264     }
265 
266     /// Nothing without the context: the frame is where the members are.
267     fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {}
268 
269     fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
270         let Some(f) = self.frame(ui) else { return };
271         let (fam, size) = self.title_font();
272         if crate::layout::control_relief() {
273             ctx.section_well(f.body, f.tab, f.radii, self.depth(f.body.height));
274         } else {
275             // The section outline: a hairline ring, the top edge parted for the title.
276             let c = [0.25, 0.25, 0.35, 1.0];
277             let (x0, y0) = (f.body.x, f.body.y);
278             let (x1, y1) = (f.body.x + f.body.width, f.body.y + f.body.height);
279             match f.tab {
280                 Some(t) => {
281                     let (gap0, gap1) = (t.x + 2.0, t.x + t.width - 2.0);
282                     if gap0 > x0 {
283                         ctx.vector(x0, y0, gap0, y0, 1.0, c, Cap::Flat);
284                     }
285                     if x1 > gap1 {
286                         ctx.vector(gap1, y0, x1, y0, 1.0, c, Cap::Flat);
287                     }
288                 }
289                 None => ctx.vector(x0, y0, x1, y0, 1.0, c, Cap::Flat),
290             }
291             ctx.vector(x0, y1, x1, y1, 1.0, c, Cap::Flat);
292             ctx.vector(x0, y0, x0, y1, 1.0, c, Cap::Flat);
293             ctx.vector(x1, y0, x1, y1, 1.0, c, Cap::Flat);
294         }
295         if let (Some(label), Some(t)) = (self.label.as_deref(), f.tab) {
296             let color = crate::color::control_label_color_for_state(false, false);
297             let ty = if crate::layout::control_relief() { t.y + 4.0 } else { t.y + t.height - size - 2.0 };
298             ctx.text_with(label.to_string(), t.x + 8.0, ty, size, color, Some(format!("{fam} {size}")), None);
299         }
300     }
301 }
302 
303 impl Input for Group {
304     /// Never hittable: the members underneath take the pointer.
305     fn hit(&self, _rect: Rect, _x: f32, _y: f32) -> bool {
306         false
307     }
308 
309     fn blocks_root_plate_drag(&self) -> bool {
310         false
311     }
312 }
313 
314 #[cfg(test)]
315 mod tests {
316     use super::*;
317     use crate::widget::{Button, DotStatus, Slider, StatusDot, WidgetHost};
318 
319     fn register(ctx: &mut UiContext, w: &mut dyn WidgetHost) -> WidgetId {
320         let id = w.base().id();
321         let ptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(w as *mut dyn WidgetHost) };
322         ctx.register_widget(id, ptr);
323         id
324     }
325 
326     /// The frame is the members' hull plus the padding; a parked member is not in it.
327     #[test]
328     fn frame_is_the_padded_hull_of_the_members() {
329         let mut ctx = UiContext::new();
330         let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
331         let mut b = Button::new(0.0, 0.0, 80.0, 24.0);
332         let mut parked = Button::new(0.0, 0.0, 1.0, 1.0);
333         WidgetHost::set_rect(&mut a, 100.0, 50.0, 80.0, 24.0);
334         WidgetHost::set_rect(&mut b, 200.0, 90.0, 60.0, 30.0);
335         WidgetHost::set_rect(&mut parked, -1000.0, -1000.0, 1.0, 1.0);
336         let ids = vec![register(&mut ctx, &mut a), register(&mut ctx, &mut b), register(&mut ctx, &mut parked)];
337         let g = Group::new(ids).with_padding(10.0);
338         let f = g.inner().frame(&ctx).expect("members on screen");
339         assert_eq!((f.body.x, f.body.y, f.body.width, f.body.height), (90.0, 40.0, 180.0, 90.0));
340         assert!(f.tab.is_none(), "no label, no tab");
341     }
342 
343     /// A labelled member's rect already holds its label strip; the hull takes the
344     /// rect as it is, not the rect less another strip.
345     #[test]
346     fn a_labelled_members_strip_is_counted_once() {
347         let mut ctx = UiContext::new();
348         let mut s = Slider::new().with_label("Amount");
349         let strip = s.label_strip();
350         assert!(strip > 0.0, "a detached label has a strip");
351         // The block: strip + control, as `layout` lands it.
352         WidgetHost::set_rect(&mut s, 100.0, 50.0, 160.0, 16.0 + strip);
353         let ids = vec![register(&mut ctx, &mut s)];
354         let g = Group::new(ids).with_padding(10.0);
355         let f = g.inner().frame(&ctx).unwrap();
356         assert_eq!(f.body.y, 40.0, "one padding above the block, not a strip more");
357         assert_eq!(f.body.height, 16.0 + strip + 20.0);
358     }
359 
360     /// A label wider than its control's rect is in the hull: the frame closes past
361     /// the text, not through it.
362     #[test]
363     fn a_members_wide_label_is_in_the_hull() {
364         let mut ctx = UiContext::new();
365         let mut dot = StatusDot::new(DotStatus::Inactive).with_label("StatusDot (inactive)");
366         let strip = dot.label_strip();
367         let size = StatusDot::SIZE;
368         WidgetHost::set_rect(&mut dot, 100.0, 50.0, size, size + strip);
369         let label = dot.detached_label_rect().expect("a detached label");
370         assert!(label.width > size, "the label text is wider than the dot");
371         let ids = vec![register(&mut ctx, &mut dot)];
372         let g = Group::new(ids).with_padding(10.0);
373         let f = g.inner().frame(&ctx).unwrap();
374         assert_eq!(f.body.x, 90.0, "the left is the rect's");
375         assert_eq!(f.body.x + f.body.width, label.x + label.width + 10.0, "the right is the label's");
376         assert_eq!(f.body.height, size + strip + 20.0, "the label strip adds no height: it is in the rect");
377     }
378 
379     /// Fit to plate: a side near the plate's edge takes it (one padding in), a
380     /// far side keeps the hull, and a corner on the plate's corner is concentric.
381     #[test]
382     fn fit_snaps_near_sides_to_the_plate() {
383         let mut ctx = UiContext::new();
384         let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
385         WidgetHost::set_rect(&mut a, 20.0, 20.0, 80.0, 24.0);
386         let ids = vec![register(&mut ctx, &mut a)];
387         let plate = Rect { x: 0.0, y: 0.0, width: 400.0, height: 300.0 };
388         let g = Group::new(ids).with_padding(10.0).with_plate(plate, 16.0).with_fit(true).with_snap(24.0);
389         let f = g.inner().frame(&ctx).unwrap();
390         // Left/top hull edges (10, 10) sit on the plate's inset seat (10, 10): snapped.
391         assert_eq!((f.body.x, f.body.y), (10.0, 10.0));
392         // A member inside the padding zone keeps the frame outside itself, up to the edge.
393         let mut edge = Button::new(0.0, 0.0, 80.0, 24.0);
394         WidgetHost::set_rect(&mut edge, 4.0, 60.0, 80.0, 24.0);
395         let eid = register(&mut ctx, &mut edge);
396         let ge = Group::new(vec![eid]).with_padding(10.0).with_plate(plate, 16.0).with_fit(true).with_snap(24.0);
397         let fe = ge.inner().frame(&ctx).unwrap();
398         assert_eq!(fe.body.x, 0.0, "clamped to the plate's own edge, never inside the member");
399         // Right/bottom are far from the plate: the hull's own.
400         assert_eq!((f.body.x + f.body.width, f.body.y + f.body.height), (110.0, 54.0));
401         assert_eq!(f.radii.0, 6.0, "the top-left corner is the plate's, concentric (16 - 10)");
402         assert_eq!(f.radii.2, crate::layout::plate_corner_radius(), "the far corner keeps the section radius");
403         // Unfitted, the same group hugs its member.
404         let loose = Group::new(vec![a.base().id()]).with_padding(10.0).with_plate(plate, 16.0);
405         assert_eq!(loose.inner().frame(&ctx).unwrap().body.x, 10.0);
406         assert_eq!(loose.inner().frame(&ctx).unwrap().radii.0, crate::layout::plate_corner_radius());
407     }
408 
409     /// A member flush with the plate's top: the wall stops on the plate's edge and
410     /// the tab rises above the plate — it never lands on the member.
411     #[test]
412     fn a_fitted_tab_never_covers_a_member() {
413         let mut ctx = UiContext::new();
414         let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
415         WidgetHost::set_rect(&mut a, 0.0, 0.0, 80.0, 24.0);
416         let ids = vec![register(&mut ctx, &mut a)];
417         let plate = Rect { x: 0.0, y: 0.0, width: 400.0, height: 300.0 };
418         let g = Group::new(ids).with_label("Tab").with_padding(10.0).with_plate(plate, 16.0).with_fit(true);
419         let f = g.inner().frame(&ctx).unwrap();
420         let tab = f.tab.expect("labelled");
421         assert_eq!((f.body.x, f.body.y), (0.0, 0.0), "the frame stops on the plate's edges");
422         assert_eq!(tab.y + tab.height, f.body.y, "the tab sits on the body's top edge");
423         assert!(tab.y < 0.0, "so it rises above the plate rather than onto the member");
424         assert_eq!(f.radii.0, 16.0, "no inset: the corner is the plate's own");
425         // Laid out at the fit's seat — one headroom down, one padding in — the same
426         // group keeps the tab inside the plate with an even padding around the member.
427         let (head, pad) = (g.inner().headroom(), g.inner().padding());
428         let mut seated = Button::new(0.0, 0.0, 80.0, 24.0);
429         WidgetHost::set_rect(&mut seated, 2.0 * pad, head + pad, 80.0, 24.0);
430         let sid = register(&mut ctx, &mut seated);
431         let gs = Group::new(vec![sid]).with_label("Tab").with_padding(10.0).with_plate(plate, 16.0).with_fit(true);
432         let fs = gs.inner().frame(&ctx).unwrap();
433         assert_eq!((fs.body.x, fs.body.y), (pad, head), "on the seat: one padding in, the tab's room above");
434         assert_eq!(fs.tab.unwrap().y, pad, "the tab is inside the plate");
435     }
436 
437     /// A labelled group carries a title tab flush on the body's top edge.
438     #[test]
439     fn a_label_adds_a_tab_on_the_top_edge() {
440         let mut ctx = UiContext::new();
441         let mut a = Button::new(0.0, 0.0, 80.0, 24.0);
442         WidgetHost::set_rect(&mut a, 100.0, 100.0, 80.0, 24.0);
443         let ids = vec![register(&mut ctx, &mut a)];
444         let g = Group::new(ids).with_label("Group");
445         let f = g.inner().frame(&ctx).unwrap();
446         let t = f.tab.expect("a tab");
447         assert_eq!(t.x, f.body.x);
448         assert_eq!(t.y + t.height, f.body.y, "flush on the top edge");
449     }
450 }