git.lucas.co / cce-ui
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 }