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 }