GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/layout/section.rs (22.4K)
1 //! A settings page's sections: where each stands on the page ([`PageFlow`]), and what
2 //! goes inside one ([`SectionContext`]).
3 //!
4 //! A page is a masonry of sections — the page split into as many columns as fit
5 //! `grid_min_col_width()`, at most the page's section count, each section dropped into the
6 //! shortest column. A section is drawn ONCE, in place: where it goes depends only on the
7 //! sections before it, never on its own height, so [`PageLayoutBuilder`] asks the flow for
8 //! the slot, lets the section draw its content there, and hands the height it came to back
9 //! to the flow. Until 2026-10-08 every section was drawn twice — once into a throwaway
10 //! target to measure it, then for real — through the legacy `LayoutStrategy`, whose
11 //! `allocate` took the height first; the flow's choices are the same, so the pages are too.
12 //!
13 //! What goes inside a section is a [`Form`](super::Form): a box-model tree the page declares,
14 //! which the section lays out across its content box with `scene::layout` and paints
15 //! ([`SectionContext::form`], [`SectionContext::place`]). Until 2026-10-08 it was a cursor
16 //! down the content box with a one- or two-column grid, and every page added its own insets.
17
18 use super::*;
19 use crate::context::UiContext;
20
21 fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
22 let fam_lower = font_fam.to_lowercase();
23 let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
24 if is_mono {
25 label.chars().count() as f32 * font_size * 0.60
26 } else {
27 let mut width = 0.0;
28 for c in label.chars() {
29 let factor = match c {
30 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
31 'f' | 'j' | 't' => 0.35,
32 'r' | 's' | 'c' | 'z' => 0.50,
33 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
34 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
35 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
36 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
37 '0'..='9' => 0.60,
38 _ => 0.60,
39 };
40 width += factor * font_size;
41 }
42 width
43 }
44 }
45
46 /// A section's column grid, and the flow's: the column lefts, their width, and how far down
47 /// each is filled.
48 #[derive(Debug, Clone)]
49 pub struct Grid {
50 pub left: f32,
51 pub top: f32,
52 pub width: f32,
53 pub col_width: f32,
54 pub gap: f32,
55 pub col_heights: Vec<f32>,
56 pub col_lefts: Vec<f32>,
57 }
58
59 impl Grid {
60 pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
61 let total_gap = gap * (count - 1) as f32;
62 let col_width = if count > 0 {
63 (width - total_gap).max(0.0) / count as f32
64 } else {
65 min_col_width
66 };
67 let left_offset = 0.0;
68
69 let mut col_lefts = Vec::with_capacity(count);
70 let col_heights = vec![top; count];
71 for i in 0..count {
72 col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
73 }
74
75 Self {
76 left,
77 top,
78 width,
79 col_width,
80 gap,
81 col_heights,
82 col_lefts,
83 }
84 }
85
86 pub fn next_column(&self) -> usize {
87 let mut min_idx = 0;
88 let mut min_h = self.col_heights[0];
89 for i in 1..self.col_heights.len() {
90 if self.col_heights[i] < min_h {
91 min_h = self.col_heights[i];
92 min_idx = i;
93 }
94 }
95 min_idx
96 }
97
98 pub fn max_height(&self) -> f32 {
99 let mut max_h = self.col_heights[0];
100 for i in 1..self.col_heights.len() {
101 if self.col_heights[i] > max_h {
102 max_h = self.col_heights[i];
103 }
104 }
105 max_h
106 }
107 }
108
109 /// Where a page's sections stand (see the module docs).
110 #[derive(Debug, Clone, Default)]
111 pub struct PageFlow {
112 grid: Option<Grid>,
113 sections: Option<usize>,
114 }
115
116 /// Where [`PageFlow::place`] put a section, for [`PageFlow::commit`] to fill once its height
117 /// is known.
118 #[derive(Debug, Clone, Copy)]
119 pub struct Slot {
120 pub x: f32,
121 pub y: f32,
122 pub w: f32,
123 span: SlotSpan,
124 }
125
126 #[derive(Debug, Clone, Copy)]
127 enum SlotSpan {
128 /// Every column: they all end below it.
129 All,
130 /// One column.
131 One(usize),
132 /// `n` columns from `first`.
133 Run { first: usize, n: usize },
134 /// The flow was never laid over a page.
135 Nowhere,
136 }
137
138 impl PageFlow {
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 /// Lay the flow over the page's content rect. The column count is what fits
144 /// `grid_min_col_width()` with `grid_gap()` between, capped by the section count.
145 pub fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
146 let min_col_width = grid_min_col_width();
147 let gap = grid_gap();
148 let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
149 let count = if let Some(n) = self.sections {
150 n.min(max_cols).max(1)
151 } else {
152 max_cols
153 };
154 self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
155 }
156
157 /// At most this many columns: a page with fewer sections than fit across does not leave
158 /// columns empty. Takes effect at the next [`init`](Self::init).
159 pub fn set_section_count(&mut self, count: usize) {
160 self.sections = Some(count);
161 }
162
163 /// One column's width, once laid over a page.
164 pub fn column_width(&self) -> Option<f32> {
165 self.grid.as_ref().map(|g| g.col_width)
166 }
167
168 /// The gap between sections, both ways.
169 pub fn gap(&self) -> f32 {
170 grid_gap()
171 }
172
173 /// Where a section `width` wide goes: as many columns as it is wide (rounded), at the
174 /// left of the run whose lowest fill is highest-up, just below that fill. A section as
175 /// wide as the page goes below everything.
176 pub fn place(&self, width: f32) -> Slot {
177 let Some(grid) = self.grid.as_ref() else {
178 return Slot { x: 0.0, y: 0.0, w: 0.0, span: SlotSpan::Nowhere };
179 };
180 let num_cols = grid.col_heights.len();
181 let n = (((width + grid.gap) / (grid.col_width + grid.gap)).round() as usize).clamp(1, num_cols);
182 if n >= num_cols {
183 Slot { x: grid.left, y: grid.max_height(), w: grid.width, span: SlotSpan::All }
184 } else if n == 1 {
185 let col = grid.next_column();
186 Slot { x: grid.col_lefts[col], y: grid.col_heights[col], w: grid.col_width, span: SlotSpan::One(col) }
187 } else {
188 let mut first = 0;
189 let mut lowest = f32::MAX;
190 for c in 0..=(num_cols - n) {
191 let fill = grid.col_heights[c..c + n].iter().fold(0.0f32, |a, &h| a.max(h));
192 if fill < lowest {
193 lowest = fill;
194 first = c;
195 }
196 }
197 let w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
198 Slot { x: grid.col_lefts[first], y: lowest, w, span: SlotSpan::Run { first, n } }
199 }
200 }
201
202 /// The section placed at `slot` came to `height`: its columns now end a gap below it.
203 pub fn commit(&mut self, slot: Slot, height: f32) {
204 let Some(grid) = self.grid.as_mut() else { return };
205 match slot.span {
206 SlotSpan::All => grid.col_heights.fill(slot.y + height + grid.gap),
207 SlotSpan::One(col) => grid.col_heights[col] += height + grid.gap,
208 SlotSpan::Run { first, n } => {
209 for h in &mut grid.col_heights[first..first + n] {
210 *h = slot.y + height + grid.gap;
211 }
212 }
213 SlotSpan::Nowhere => {}
214 }
215 }
216 }
217
218 /// Vertical slack for a section's content clip. The clip exists to stop
219 /// content escaping its section SIDEWAYS, which is the axis a section's width
220 /// actually fixes; a section's height is only known once its content has been
221 /// placed, so bounding that axis too would risk cutting content off rather
222 /// than keeping it in. Deliberately far larger than any section.
223 const SECTION_CLIP_SLACK: f32 = 100_000.0;
224
225 /// A page's sections, placed by a [`PageFlow`] and drawn once each.
226 pub struct PageLayoutBuilder<'a, P> {
227 pub flow: &'a mut PageFlow,
228 pub cx: f32,
229 pub cy: f32,
230 pub cw: f32,
231 pub ch: f32,
232 pub section_width: f32,
233 pub idx: usize,
234 _phantom: std::marker::PhantomData<P>,
235 }
236
237 impl<'a, P: RenderTarget> PageLayoutBuilder<'a, P> {
238 /// Lay `flow` over the page's content rect. `section_width` is a section's width when the
239 /// flow has none to offer (it always does once laid over a page).
240 pub fn new(flow: &'a mut PageFlow, cx: f32, cy: f32, cw: f32, ch: f32, section_width: f32) -> Self {
241 flow.init(cx, cy, cw, ch);
242 Self { flow, cx, cy, cw, ch, section_width, idx: 0, _phantom: std::marker::PhantomData }
243 }
244
245 /// At most `count` columns (see [`PageFlow::set_section_count`]).
246 pub fn with_section_count(self, count: usize) -> Self {
247 self.flow.set_section_count(count);
248 self.flow.init(self.cx, self.cy, self.cw, self.ch);
249 self
250 }
251
252 /// A section one column wide.
253 pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, render_fn: F)
254 where
255 F: FnOnce(&mut SectionContext<'_, P>),
256 {
257 let width = self.flow.column_width().unwrap_or(self.section_width);
258 self.add_section_with_width(final_pc, width, label, focused, render_fn);
259 }
260
261 /// A section `width` wide: as many columns as that rounds to.
262 pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, render_fn: F)
263 where
264 F: FnOnce(&mut SectionContext<'_, P>),
265 {
266 let slot = self.flow.place(width);
267 let mut sec = SectionContext::new(final_pc, slot.x, slot.y, slot.w, label, focused, false);
268 let (clip_x, clip_w) = (sec.content_left(), sec.content_width());
269 sec.pc.push_clip_rect(clip_x, slot.y - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
270 render_fn(&mut sec);
271 sec.pc.pop_clip_rect();
272 let bottom = sec.finish();
273 self.flow.commit(slot, bottom - slot.y);
274 self.idx += 1;
275 }
276
277 /// A section `span` columns wide.
278 pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
279 where
280 F: FnOnce(&mut SectionContext<'_, P>),
281 {
282 let col_width = self.flow.column_width().unwrap_or(self.section_width);
283 let gap = self.flow.gap();
284 let width = span as f32 * col_width + (span - 1) as f32 * gap;
285 self.add_section_with_width(final_pc, width, label, focused, render_fn);
286 }
287 }
288
289 pub struct SectionContext<'a, P> {
290 pub pc: &'a mut P,
291 pub left: f32,
292 pub top: f32,
293 /// How far down the section's content reaches.
294 pub content_y: f32,
295 pub cw: f32,
296 pub label_width: f32,
297 pub label_x: f32,
298 /// Whether the host renders sections as sunken wells (`section_relief_style`).
299 pub relief_style: bool,
300 /// The title tab box (x, y, w, h) when the host's relief styling laid the
301 /// label out left-aligned — `finish` offers it with the section carve.
302 /// None under relief styling means a label-less section: the well carves
303 /// tabless, flush with the allocation top.
304 pub relief_tab: Option<(f32, f32, f32, f32)>,
305 pub focused: bool,
306 pub is_child: bool,
307 /// Where the content box starts.
308 pub content_start_y: f32,
309 }
310
311 impl<'a, P: RenderTarget> SectionContext<'a, P> {
312 pub const DEFAULT_MARGIN_X: f32 = 12.0;
313
314 fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
315 estimate_label_width_helper(label, font_size, font_fam)
316 }
317
318 pub fn padding(&self) -> f32 {
319 section_padding()
320 }
321
322 pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
323 let font_setting = if is_child {
324 nested_section_label_font()
325 } else {
326 section_label_font()
327 };
328 let (font_fam, font_size_opt) = parse_font_string(&font_setting);
329 let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
330 let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
331 let label_width = Self::estimate_label_width(label, font_size, &font_fam);
332 let relief_style = pc.section_relief_style();
333 let label_x = if is_child {
334 let base_x = match nested_section_label_alignment() {
335 0 => left + 12.0,
336 1 => left + (cw - label_width) / 2.0,
337 2 => left + cw - 12.0 - label_width,
338 _ => left + 12.0,
339 };
340 base_x + nested_section_label_offset()
341 } else if relief_style {
342 // Sunken style: the title sits in a tab flush with the well's left
343 // edge (the designer look), not centered on the border.
344 left + 12.0
345 } else {
346 left + (cw - label_width) / 2.0
347 };
348 // Under relief styling the well fills the whole allocated rect (so the
349 // page's gaps and margins are the visual gaps) — the tab tops the
350 // allocation and the label centers inside it.
351 let label_y = if relief_style { top + 4.0 } else { top };
352 pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
353
354 // The tab wraps the label, flush on the well's top-left corner; clamped
355 // so off-default child alignments can't push it outside the well.
356 let relief_tab = if relief_style && label_width > 0.0 {
357 let tab_x = (label_x - 12.0).max(left);
358 Some((tab_x, top, label_width + 24.0, font_size + 10.0))
359 } else {
360 None
361 };
362
363 let pad = section_padding();
364 let margin_x = 2.0 * pad + 12.0;
365 // Under relief styling the content stands off the well's top wall by
366 // the same inset it keeps from the side walls (`margin_x`, which is
367 // also what `finish` leaves below it), so a well reads as one even
368 // frame. The outline style's `pad + 19` predates the title tab: under
369 // relief the tab alone is `font_size + 10` tall, which left the first
370 // line touching the well's top wall at the default padding while the
371 // sides kept 20px and more.
372 let content_start_y = if relief_style {
373 relief_tab.map(|t| t.1 + t.3).unwrap_or(top) + margin_x
374 } else {
375 top + pad + 19.0
376 };
377
378 Self {
379 pc,
380 left,
381 top,
382 content_y: content_start_y,
383 cw,
384 label_width,
385 label_x,
386 relief_style,
387 relief_tab,
388 focused,
389 is_child,
390 content_start_y,
391 }
392 }
393
394 /// The section's content-box top edge: the body well's top (the tab's
395 /// bottom) under relief styling, the outline's border line otherwise.
396 pub fn well_top(&self) -> f32 {
397 if self.relief_style {
398 self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
399 } else {
400 self.top + 7.0
401 }
402 }
403
404 /// Horizontal inset of section CONTENT from the section's left edge. The section's border
405 /// is drawn at `left + padding()` (see `finish`), so content clears the border by
406 /// `padding() + 12`.
407 pub fn content_margin(&self) -> f32 {
408 2.0 * self.padding() + 12.0
409 }
410
411 /// Left edge of the content box.
412 pub fn content_left(&self) -> f32 {
413 self.left + self.content_margin()
414 }
415
416 /// Width of the content box — the section's width less the inset on both sides.
417 pub fn content_width(&self) -> f32 {
418 (self.cw - 2.0 * self.content_margin()).max(0.0)
419 }
420
421 /// A [`Form`](super::Form) across this section's content box, starting below whatever the
422 /// section already holds (the control gap after it). Declare the contents into it, then
423 /// hand it to [`place`](Self::place).
424 pub fn form<'w>(&self) -> super::Form<'w, P>
425 where
426 P: 'w,
427 {
428 let mut y = self.content_y;
429 if y > self.content_start_y {
430 y += crate::layout::control_gap();
431 }
432 let pad = self.padding();
433 super::Form::new(self.content_left(), y, self.content_width(), (self.left + pad, self.cw - 2.0 * pad))
434 }
435
436 /// Lay a [`Form`](super::Form) out, paint it, and move the section past it.
437 pub fn place<'w>(&mut self, form: super::Form<'w, P>, ctx: &mut UiContext)
438 where
439 P: 'w,
440 {
441 let bottom = form.paint(&mut *self.pc, ctx);
442 self.content_y = bottom;
443 }
444
445 /// How far below the last of its content the section's frame ends — what a page leaves
446 /// under a list that fills the rest of the page.
447 pub fn bottom_inset(&self) -> f32 {
448 if self.relief_style { self.content_margin() } else { self.padding() + 12.0 + 8.0 }
449 }
450
451 pub fn finish(self) -> f32 {
452 let border: [f32; 4] = if self.is_child {
453 if self.focused {
454 [0.22, 0.38, 0.24, 1.0]
455 } else {
456 [0.18, 0.18, 0.25, 1.0]
457 }
458 } else {
459 if self.focused {
460 [0.30, 0.50, 0.32, 1.0] // Focused green
461 } else {
462 [0.25, 0.25, 0.35, 1.0] // Default gray
463 }
464 };
465 let pad = self.padding();
466 let x = self.left + pad;
467 let y = self.top + 7.0;
468 let w = self.cw - 2.0 * pad;
469 let h = self.content_y - y;
470 // Under relief the well's walls are the allocation's edges, so the
471 // floor below the content matches the inset beside and above it (see
472 // `new`). The outline frame sits `pad` in from its allocation and
473 // keeps its own slack.
474 let extra_bottom = if self.relief_style { self.content_margin() } else { pad + 12.0 };
475 let bottom = y + h + extra_bottom;
476
477 if self.relief_style {
478 // Sunken style: the well spans the full allocated rect — body top
479 // edge at the tab's bottom (the tab is flush ON the body, the
480 // designer union shape; label-less sections carve tabless from the
481 // allocation top), walls on the allocation's edges, and no
482 // trailing slack so the layout gap IS the visual gap.
483 let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
484 let frame = SectionFrame {
485 x: self.left,
486 y: body_y,
487 w: self.cw,
488 h: bottom - body_y,
489 tab: self.relief_tab,
490 focused: self.focused,
491 is_child: self.is_child,
492 };
493 if self.pc.section_relief(&frame) {
494 return self.content_y + extra_bottom;
495 }
496 }
497
498 let left_edge = x;
499 let right_edge = x + w;
500 if self.label_width > 0.0 {
501 let label_x = self.label_x;
502 let gap_margin = 6.0;
503 let gap_start = label_x - gap_margin;
504 let gap_end = label_x + self.label_width + gap_margin;
505 if gap_start > left_edge {
506 self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
507 }
508 if right_edge > gap_end {
509 self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
510 }
511 } else {
512 self.pc.rect(border, left_edge, y, w, 1.0);
513 }
514
515 self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
516 self.pc.rect(border, x, y, 1.0, h + extra_bottom);
517 self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
518 self.content_y + extra_bottom + 8.0
519 }
520 }
521
522 #[cfg(test)]
523 mod tests {
524 use super::*;
525
526 /// Where a section goes depends on the sections before it and never on its own height:
527 /// one column wide it takes the shortest column, as wide as the page it goes below
528 /// everything, and a run takes the columns whose lowest fill is highest up.
529 #[test]
530 fn a_section_is_placed_before_it_is_drawn() {
531 let (min, gap) = (grid_min_col_width(), grid_gap());
532 let width = 3.0 * min + 2.0 * gap + 1.0;
533 let mut flow = PageFlow::new();
534 flow.init(10.0, 20.0, width, 800.0);
535 let col = flow.column_width().unwrap();
536
537 let a = flow.place(col);
538 flow.commit(a, 100.0);
539 let b = flow.place(col);
540 flow.commit(b, 50.0);
541 let c = flow.place(col);
542 flow.commit(c, 70.0);
543 assert_eq!((a.x, a.y), (10.0, 20.0));
544 assert_eq!((b.y, c.y), (20.0, 20.0), "the first three stand side by side");
545 assert!(a.x < b.x && b.x < c.x);
546
547 let d = flow.place(col);
548 assert_eq!((d.x, d.y), (b.x, 20.0 + 50.0 + gap), "under the shortest column");
549 flow.commit(d, 10.0);
550
551 // Columns 1 and 2 reach 20+50+gap+10+gap and 20+70+gap; 0 and 1 reach further.
552 let pair = flow.place(2.0 * col + gap);
553 assert_eq!((pair.x, pair.y), (b.x, (20.0 + 50.0 + gap + 10.0 + gap).max(20.0 + 70.0 + gap)));
554 assert_eq!(pair.w, 2.0 * col + gap);
555 flow.commit(pair, 30.0);
556
557 let wide = flow.place(width);
558 assert_eq!((wide.x, wide.w), (10.0, width));
559 assert_eq!(wide.y, pair.y + 30.0 + gap, "below everything");
560 }
561
562 /// A page with fewer sections than fit across leaves no column empty.
563 #[test]
564 fn the_section_count_caps_the_columns() {
565 let width = 3.0 * grid_min_col_width() + 2.0 * grid_gap() + 1.0;
566 let mut flow = PageFlow::new();
567 flow.set_section_count(1);
568 flow.init(0.0, 0.0, width, 400.0);
569 assert_eq!(flow.column_width(), Some(width));
570 }
571
572 /// The builder draws each section once, in place, and the next one starts below it.
573 #[test]
574 fn each_section_is_drawn_once() {
575 let mut flow = PageFlow::new();
576 let mut pc = PopoverCollector::new();
577 let mut builder = PageLayoutBuilder::new(&mut flow, 0.0, 0.0, 600.0, 400.0, 300.0).with_section_count(1);
578 let mut tops = Vec::new();
579 for _ in 0..2 {
580 builder.add_section(&mut pc, "Section", false, |sec| {
581 tops.push(sec.top);
582 let mut form = sec.form();
583 form.column().draw(0.0, 40.0, false, |_, _, _| {});
584 sec.place(form, &mut UiContext::new());
585 });
586 }
587 assert_eq!(tops.len(), 2, "one call per section");
588 assert!(tops[1] > tops[0] + 40.0, "{tops:?}");
589 }
590 }