GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(layout): retire legacy.rs; a page's sections are drawn once
The legacy layout engines are gone. What a settings page still needs moved to
layout/section.rs: PageFlow places sections in a masonry of columns, and
PageLayoutBuilder draws each section ONCE in the slot the flow gives it, then
hands its height back. Every section used to be drawn twice, once into a
throwaway target to measure it, through LayoutStrategy, whose allocate took the
height before the position. A section's position never depended on its own
height, so drawing once places everything where it was.
- LayoutStrategy, ColumnLayout, AdaptiveGrid, FlexLayout, RadialLayout and
the unused Column / Row / Section / UiFrame / Radial / ManualLayout are
deleted.
- ContainerLayout is a trait of its own (layout, measure), without the
init / allocate cursor nobody called.
- SplitterLayout and CircularPaneLayout (cce-designer's) are layout/panes.rs;
the system font helpers moved to registry.rs.
Checked in a scale-2 shadow against the previous binaries: all 14 settings
pages, cce-files (relief on and off, save mode) and cce-gallery draw
identically to the pixel, live readings aside.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 21 +-
docs/rfc-core-rebuild.md | 9 +
src/layout/legacy.rs | 1919 ------------------------------
src/layout/mod.rs | 88 +-
src/layout/panes.rs | 96 ++
src/layout/registry.rs | 36 +
src/layout/section.rs | 930 +++++++++++++++
src/scene/layout.rs | 5 +-
src/widget/container/container_layout.rs | 231 +---
src/widget/container/mod.rs | 2 +-
src/widget/mod.rs | 2 +-
11 files changed, 1130 insertions(+), 2209 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 3b10d4e..d2c5c7a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1771,14 +1771,27 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `src/layout/mod.rs` (~4.4k lines) — the sizing constants and the style getters/setters
(heights, radii, fonts — many `*_font_parsed()` — gaps, the relief and bevel profile
state), `reload_config`, and `read_preferred_fonts` / font-family resolution used by the
- cosmic-text path. It names no widget: what does is in the two modules below.
+ cosmic-text path. It names no widget: what does is in the modules below.
- `registry.rs` — the style registry (config flattened to one map of keys), its test overlay,
the font-string helpers.
- `bridge.rs` — the flat-host render bridge: `RenderTarget`, `PopoverCollector`,
`render_widget`, `render_popovers`, the carve types that cross it.
- - `src/layout/legacy.rs` (~2k lines) — the legacy layout engines (`Column`, `Section`, `Grid`,
- the `LayoutStrategy` family, `PageLayoutBuilder`, `VStack`), still used by
- cce-system-interface, cce-files and cce-gallery. New layout is `scene::layout`.
+ - `section.rs` — a settings page's sections: `PageFlow` places them (a masonry of
+ columns as wide as fit `grid_min_col_width`), `PageLayoutBuilder` draws each ONCE in
+ the slot the flow gives it and hands its height back, and `SectionContext` (with its
+ `VStack`) is what a section draws through — immediate-mode, a cursor down the content
+ box and a one- or two-column grid. cce-system-interface is its user. Since 2026-10-08:
+ until then it was `legacy.rs`, and every section was drawn TWICE, once into a
+ throwaway target to measure it, through the `LayoutStrategy` trait, whose `allocate`
+ took the height before the position. A section's position never depended on its own
+ height, so drawing once places everything where it was (all 14 settings pages
+ pixel-identical before and after in a scale-2 shadow, live readings aside).
+ `LayoutStrategy`, `ColumnLayout`, `AdaptiveGrid`, `FlexLayout`, `RadialLayout` and the
+ unused `Column` / `Row` / `Section` / `UiFrame` / `Radial` went with it; cce-files'
+ browse page is a `scene::layout` column now, and the container layouts
+ (`widget::ContainerLayout`, the gallery's Layout exhibit) are a trait of their own,
+ `layout` and `measure`, without the cursor. New layout is `scene::layout`.
+ - `panes.rs` — `SplitterLayout` and `CircularPaneLayout`, cce-designer's pane geometry.
- `color/` — the colour model and named colours (`colors` re-export module in `lib.rs`),
split the same way: `mod.rs` the constants, statics and getters; `load.rs` reading the
config into them (and `retired_surface_keys`); `math.rs` sRGB/linear, OKLab and the
diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index 945822b..4b5d7d0 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -327,6 +327,15 @@ Constraint respected: **each crate still builds standalone** — the new core is
- **Phase 2 — Layout pass.** Add `Style` + measure/arrange (taffy). Migrate containers to
emit `Style` instead of `LayoutStrategy`; delete `allocate` and render-twice measurement as
containers move over.
+ - **`LayoutStrategy`, `allocate` and render-twice: DELETED 2026-10-08.** The last users were
+ cce-system-interface's pages (`PageLayoutBuilder` over `AdaptiveGrid`), cce-files' browse
+ page (`ColumnLayout`) and the gallery's container-layout exhibit. A section's slot never
+ depended on its own height, so `layout::section::PageFlow` places it first and the
+ section draws once, in place (`each_section_is_drawn_once`); the browse page is a
+ `scene::layout` column; `ContainerLayout` keeps only `layout` / `measure`. Section
+ CONTENT is still immediate-mode `SectionContext` — porting each page's content to box
+ trees is not done. Every settings page, cce-files (relief on and off, save mode) and the
+ gallery drew identically to the pixel before and after, live readings aside.
- **Phase 3 — Paint unification.** Introduce `DisplayList` + `PaintCtx` (clip/transform);
route the backend `render()` through the arena walk; retire the top-level `widgets` Vec and
`render_widget`/`SectionContext`.
diff --git a/src/layout/legacy.rs b/src/layout/legacy.rs
deleted file mode 100644
index 5528c60..0000000
--- a/src/layout/legacy.rs
+++ /dev/null
@@ -1,1919 +0,0 @@
-//! The legacy layout engines — `Column`, `Row`, `Section`, `Grid`, the `LayoutStrategy`
-//! family (`FlexLayout`, `ColumnLayout`, `AdaptiveGrid`, `RadialLayout`), `PageLayoutBuilder`
-//! and `VStack` — from before `scene::layout`'s box model. Still used by
-//! cce-system-interface, cce-files and cce-gallery; new layout goes through
-//! `scene::layout`.
-
-use super::*;
-use crate::widget::WidgetHost;
-use crate::context::UiContext;
-
-pub struct UiFrame;
-
-impl UiFrame {
- pub fn start(scroll_offset: f32) -> Self {
- crate::widget::hover_animation::reset_frame_registration();
- crate::widget::hover_animation::set_scroll_offset(scroll_offset);
- Self
- }
-
- pub fn finish(self, pc: &mut dyn RenderTarget) {
- crate::widget::hover_animation::post_render_check();
- if let Some((qx, qy, qw, qh, qc)) = crate::widget::hover_animation::get_quad() {
- pc.rect(qc, qx, qy, qw, qh);
- }
- // render_popovers(pc);
- }
-}
-
-pub struct Column {
- ox: f32,
- oy: f32,
- cx: f32,
- pub y: f32,
- pub cw: f32,
-}
-
-impl Column {
- pub fn new(ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
- Self { ox, oy, cx, y: cy, cw }
- }
-
- pub fn ax(&self, x_off: f32) -> f32 {
- self.ox + self.cx + x_off
- }
-
- pub fn ay(&self) -> f32 {
- self.oy + self.y
- }
-
- pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- pc.rect(color, self.ax(x_off), self.ay(), w, h);
- self.y += h;
- }
-
- pub fn advance(&mut self, dy: f32) {
- self.y += dy;
- }
-
- pub fn spacing(&mut self, dy: f32) {
- self.y += dy;
- }
-
- pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
- let x = self.ax(8.0);
- let y = self.ay();
- pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
- self.y += 8.0;
- }
-
- pub fn header(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32) {
- let x = self.ax(x_off);
- let y = self.ay();
- pc.text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
- self.y += 22.0;
- }
-
- pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
- let x = self.ax(x_off);
- let y = self.ay() + y_off;
- pc.text(text, x, y, font_size, color);
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let top_room = w.label_strip();
- let total_h = wh + top_room;
- let x = self.ax(x_off);
- let y = self.ay();
- w.set_row_rect(self.ox + self.cx + 8.0, self.cw - 16.0);
- let clamped_w = ww.min((self.cw - x_off).max(0.0));
- render_widget(pc, w, x, y, clamped_w, total_h, ctx);
- self.y += total_h;
- }
-
- pub fn row<F: FnOnce(&mut Row)>(&mut self, pc: &mut dyn RenderTarget, h: f32, f: F) {
- let row_y = self.ay();
- let mut row = Row {
- pc: &mut *pc,
- base_x: self.ox + self.cx,
- y: row_y,
- cursor_x: 0.0,
- spacing: CONTROL_GAP,
- };
- f(&mut row);
- self.y += h;
- }
-}
-
-pub struct Row<'a> {
- pc: &'a mut dyn RenderTarget,
- base_x: f32,
- y: f32,
- pub cursor_x: f32,
- pub spacing: f32,
-}
-
-impl<'a> Row<'a> {
- pub fn set_spacing(&mut self, spacing: f32) {
- self.spacing = spacing;
- }
-
- pub fn gap(&mut self, width: f32) {
- self.cursor_x += width;
- }
-
- pub fn text(&mut self, text: &str, y_off: f32, font_size: f32, color: [f32; 4], width: f32) {
- self.pc
- .text(text, self.base_x + self.cursor_x, self.y + y_off, font_size, color);
- self.cursor_x += width + self.spacing;
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
- self.cursor_x += ww + self.spacing;
- }
-}
-
-fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
- let fam_lower = font_fam.to_lowercase();
- let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
- if is_mono {
- label.chars().count() as f32 * font_size * 0.60
- } else {
- let mut width = 0.0;
- for c in label.chars() {
- let factor = match c {
- 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
- 'f' | 'j' | 't' => 0.35,
- 'r' | 's' | 'c' | 'z' => 0.50,
- 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
- 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
- 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
- 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
- '0'..='9' => 0.60,
- _ => 0.60,
- };
- width += factor * font_size;
- }
- width
- }
-}
-
-pub struct Section {
- pub left: f32,
- pub top: f32,
- pub content_y: f32,
- pub cw: f32,
- pub label_width: f32,
- pub is_child: bool,
- pub grid: Grid,
- pub last_col: usize,
-}
-
-impl Section {
- pub const DEFAULT_MARGIN_X: f32 = 12.0;
- pub const DEFAULT_ROW_GAP: f32 = 8.0;
-
- fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
- estimate_label_width_helper(label, font_size, font_fam)
- }
-
- pub fn padding(&self) -> f32 {
- section_padding()
- }
-
- pub fn new(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str) -> Self {
- Self::new_opt(pc, left, top, cw, label, false)
- }
-
- pub fn new_opt(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str, is_child: bool) -> Self {
- let font_setting = if is_child {
- nested_section_label_font()
- } else {
- section_label_font()
- };
- let (font_fam, font_size_opt) = parse_font_string(&font_setting);
- let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
- let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
- let label_width = Self::estimate_label_width(label, font_size, &font_fam);
- let label_x = if is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => left + 12.0,
- 1 => left + (cw - label_width) / 2.0,
- 2 => left + cw - 12.0 - label_width,
- _ => left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else {
- left + (cw - label_width) / 2.0
- };
- pc.text_with_font(label, label_x, top, font_size, font_color, &font_fam);
-
- let pad = section_padding();
- let margin_x = 2.0 * pad + 12.0;
- let usable_w = (cw - 2.0 * margin_x).max(1.0);
- let min_col_width = 130.0;
- let gap = 8.0;
- let max_cols = if is_child {
- 1
- } else {
- ((usable_w + gap) / (min_col_width + gap)).floor().clamp(1.0, 2.0) as usize
- };
- let content_start_y = top + pad + 19.0;
- let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
-
- Self { left, top, content_y: content_start_y, cw, label_width, is_child, grid, last_col: usize::MAX }
- }
-
- /// Horizontal inset of content from this section's left edge — the same
- /// one `row_layout`, the column `Grid` and `widget` use, so everything in
- /// a section lines up. See `SectionContext::content_margin`.
- pub fn content_margin(&self) -> f32 {
- 2.0 * self.padding() + 12.0
- }
-
- pub fn content_left(&self) -> f32 {
- self.left + self.content_margin()
- }
-
- pub fn content_width(&self) -> f32 {
- (self.cw - 2.0 * self.content_margin()).max(0.0)
- }
-
- /// See `SectionContext::ax` — same mapping, same reason it is no longer
- /// stepped at `x_off == 12.0`.
- pub fn ax(&self, x_off: f32) -> f32 {
- self.left + 2.0 * self.padding() + x_off
- }
-
- pub fn ay(&self) -> f32 { self.content_y }
-
- pub fn spacing(&mut self, dy: f32) {
- if self.grid.col_heights.len() >= 2 {
- if self.last_col == usize::MAX {
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- } else if self.last_col < self.grid.col_heights.len() {
- self.grid.col_heights[self.last_col] += dy;
- }
- self.content_y = self.grid.max_height();
- } else {
- self.content_y += dy;
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- }
- }
-
- pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
- let x = self.ax(x_off);
- let y = self.ay() + y_off;
- // Bounded to the content box, like `SectionContext::text` — text is
- // the only thing a section draws that is not already sized to fit it.
- let right = self.content_left() + self.content_width();
- let bounds = if right > x {
- Some([x, y - font_size, right, y + 2.0 * font_size])
- } else {
- None
- };
- pc.text_with_bounds(text, x, y, font_size, color, bounds);
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let pad = self.padding();
- let top_room = w.label_strip();
- let total_h = wh + top_room;
-
- let name = w.type_name();
- let span_full = name == "Trackpad"
- || name == "Canvas"
- || name == "UsageBar"
- || name == "ProgressBar"
- || name == "ButtonStrip"
- || name == "Spreadsheet"
- || name == "Graph";
-
- if span_full {
- let margin_x = 2.0 * pad + 12.0;
- let x = self.left + margin_x;
- let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
-
- w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
- render_widget(pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.content_y = new_bottom;
- self.grid.col_heights.fill(new_bottom);
- } else {
- let max_h = self.grid.max_height();
- if self.content_y > max_h {
- self.grid.col_heights.fill(self.content_y);
- }
-
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let y = self.grid.col_heights[col];
-
- w.set_row_rect(x, self.grid.col_width);
- render_widget(pc, w, x, y, self.grid.col_width, total_h, ctx);
- self.grid.col_heights[col] += total_h;
- self.content_y = self.grid.max_height();
- }
- }
-
- pub fn widget_full<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, wh: f32, ctx: &mut UiContext) {
- let x_off = 12.0;
- let ww = self.cw - 2.0 * (self.padding() + x_off);
- self.widget(pc, w, x_off, ww, wh, ctx);
- }
-
- pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
- let pad = self.padding();
- let x = self.ax(pad);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
- pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
- self.content_y = max_h + 8.0;
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- let max_h = self.grid.max_height().max(self.content_y);
- pc.rect(color, self.ax(x_off), max_h, w, h);
- self.content_y = max_h + h;
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
- let margin_x = self.content_margin();
- let usable_w = self.content_width();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let col_w = (usable_w - total_gap).max(0.0) / count as f32;
-
- let mut cols = Vec::with_capacity(count);
- for i in 0..count {
- let x = self.left + margin_x + i as f32 * (col_w + gap);
- cols.push((x, col_w));
- }
- cols
- }
-
- pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- let max_h = self.grid.max_height().max(self.content_y);
- self.grid.col_heights.fill(max_h);
- self.content_y = max_h;
-
- let cols = self.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- f(i, x, w);
- }
- self.content_y += h;
-
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
- self.finish_focused(pc, false)
- }
-
- pub fn finish_focused(&mut self, pc: &mut dyn RenderTarget, focused: bool) -> f32 {
- let border: [f32; 4] = if self.is_child {
- if focused {
- [0.22, 0.38, 0.24, 1.0]
- } else {
- [0.18, 0.18, 0.25, 1.0]
- }
- } else {
- if focused {
- [0.30, 0.50, 0.32, 1.0]
- } else {
- [0.25, 0.25, 0.35, 1.0]
- }
- };
- let pad = self.padding();
- let x = self.left + pad;
- let y = self.top + 7.0;
- let w = self.cw - 2.0 * pad;
- let h = self.content_y - y;
-
- let left_edge = x;
- let right_edge = x + w;
- if self.label_width > 0.0 {
- let label_x = if self.is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => self.left + 12.0,
- 1 => self.left + (self.cw - self.label_width) / 2.0,
- 2 => self.left + self.cw - 12.0 - self.label_width,
- _ => self.left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else {
- self.left + (self.cw - self.label_width) / 2.0
- };
- let gap_margin = 6.0;
- let gap_start = label_x - gap_margin;
- let gap_end = label_x + self.label_width + gap_margin;
- if gap_start > left_edge {
- pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
- }
- if right_edge > gap_end {
- pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
- }
- } else {
- pc.rect(border, left_edge, y, w, 1.0);
- }
-
- let extra_bottom = pad + 12.0;
- pc.rect(border, x, y + h + extra_bottom, w, 1.0);
- pc.rect(border, x, y, 1.0, h + extra_bottom);
- pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
- self.content_y + extra_bottom + 8.0
- }
-
- pub fn vstack<'a>(&'a mut self, pc: &'a mut dyn RenderTarget, spacing: f32) -> SectionVStack<'a> {
- SectionVStack {
- section: self,
- pc,
- spacing,
- }
- }
-}
-
-pub struct SectionVStack<'a> {
- section: &'a mut Section,
- pc: &'a mut dyn RenderTarget,
- spacing: f32,
-}
-
-impl<'a> SectionVStack<'a> {
- pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, wh: f32, ctx: &mut UiContext) {
- self.section.widget(self.pc, w, Section::DEFAULT_MARGIN_X, ww, wh, ctx);
- self.section.spacing(self.spacing);
- }
-
- pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- self.section.row(count, gap, h, f);
- self.section.spacing(self.spacing);
- }
-}
-
-
-pub struct SplitterLayout {
- pub splitter1_x: f32,
- pub splitter2_x: f32,
- pub splitter_width: f32,
- pub min_column_width: f32,
-}
-
-impl SplitterLayout {
- pub fn new(width: f32, splitter_width: f32, min_column_width: f32) -> Self {
- let s1 = (width - 2.0 * splitter_width) / 3.0;
- let s2 = s1 + splitter_width + (width - 2.0 * splitter_width) / 3.0;
- Self {
- splitter1_x: s1,
- splitter2_x: s2,
- splitter_width,
- min_column_width,
- }
- }
-
- pub fn clamp(&mut self, total_width: f32, detached_circular_network: bool) {
- if detached_circular_network {
- let min_s2 = self.min_column_width;
- let max_s2 = (total_width - self.min_column_width).max(min_s2);
- self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
- } else {
- let min_s1 = self.min_column_width;
- let max_s1 = (self.splitter2_x - self.splitter_width - self.min_column_width).max(min_s1);
- self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
- let min_s2 = self.splitter1_x + self.splitter_width + self.min_column_width;
- let max_s2 = (total_width - self.min_column_width).max(min_s2);
- self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
- }
- }
-
- pub fn scale(&mut self, factor: f32) {
- self.splitter1_x *= factor;
- self.splitter2_x *= factor;
- }
-
- pub fn left_col(&self) -> (f32, f32) { // (x, width)
- (0.0, self.splitter1_x)
- }
-
- pub fn center_col(&self) -> (f32, f32) { // (x, width)
- let x = self.splitter1_x + self.splitter_width;
- (x, self.splitter2_x - x)
- }
-
- pub fn right_col(&self, total_width: f32) -> (f32, f32) { // (x, width)
- let x = self.splitter2_x + self.splitter_width;
- (x, (total_width - x).max(0.0))
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct Grid {
- pub left: f32,
- pub top: f32,
- pub width: f32,
- pub col_width: f32,
- pub gap: f32,
- pub col_heights: Vec<f32>,
- pub col_lefts: Vec<f32>,
-}
-
-impl Grid {
- pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
- let total_gap = gap * (count - 1) as f32;
- let col_width = if count > 0 {
- (width - total_gap).max(0.0) / count as f32
- } else {
- min_col_width
- };
- let left_offset = 0.0;
-
- let mut col_lefts = Vec::with_capacity(count);
- let col_heights = vec![top; count];
- for i in 0..count {
- col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
- }
-
- Self {
- left,
- top,
- width,
- col_width,
- gap,
- col_heights,
- col_lefts,
- }
- }
-
- pub fn next_column(&self) -> usize {
- let mut min_idx = 0;
- let mut min_h = self.col_heights[0];
- for i in 1..self.col_heights.len() {
- if self.col_heights[i] < min_h {
- min_h = self.col_heights[i];
- min_idx = i;
- }
- }
- min_idx
- }
-
- pub fn max_height(&self) -> f32 {
- let mut max_h = self.col_heights[0];
- for i in 1..self.col_heights.len() {
- if self.col_heights[i] > max_h {
- max_h = self.col_heights[i];
- }
- }
- max_h
- }
-}
-
-pub struct CircularPaneLayout {
- pub x: f32,
- pub y: f32,
- pub r: f32,
-}
-
-impl CircularPaneLayout {
- pub fn new(x: f32, y: f32, r: f32) -> Self {
- Self { x, y, r }
- }
-
- pub fn hit_test_content(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist_sq = dx * dx + dy * dy;
- dist_sq <= self.r * self.r && cy >= self.y - self.r + 45.0 + menubar_h + breadcrumb_h
- }
-
- pub fn hit_test_menubar(&self, cx: f32, cy: f32, menubar_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist = (dx * dx + dy * dy).sqrt();
- dist >= self.r - menubar_h && dist <= self.r && cy < self.y
- }
-
- pub fn hit_test_breadcrumb(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist_sq = dx * dx + dy * dy;
- dist_sq <= self.r * self.r && cy >= self.y - self.r + menubar_h && cy < self.y - self.r + 45.0 + menubar_h + breadcrumb_h
- }
-
- pub fn hit_test_border(&self, cx: f32, cy: f32, border_thickness: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist = (dx * dx + dy * dy).sqrt();
- dist >= self.r - border_thickness && dist <= self.r
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct Radial {
- pub center_x: f32,
- pub center_y: f32,
- pub aspect_ratio: f32,
- pub base_spacing: f32,
-}
-
-impl Radial {
- pub fn new(center_x: f32, center_y: f32, aspect_ratio: f32, base_spacing: f32) -> Self {
- Self { center_x, center_y, aspect_ratio, base_spacing }
- }
-
- pub fn widget_rect(&self, idx: usize, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if idx == 0 {
- (self.center_x - ww / 2.0, self.center_y - wh / 2.0, ww, wh)
- } else {
- let mut ring = 1;
- let mut ring_start = 1;
- loop {
- let ring_capacity = ring * 6;
- if idx < ring_start + ring_capacity {
- let pos_in_ring = idx - ring_start;
- let angle = (pos_in_ring as f32) * (2.0 * std::f32::consts::PI / ring_capacity as f32);
- let radius = (ring as f32) * self.base_spacing;
-
- let x_offset = radius * angle.cos() * self.aspect_ratio;
- let y_offset = radius * angle.sin();
-
- return (
- self.center_x + x_offset - ww / 2.0,
- self.center_y + y_offset - wh / 2.0,
- ww,
- wh,
- );
- }
- ring_start += ring_capacity;
- ring += 1;
- }
- }
- }
-
-}
-
-
-
-pub trait LayoutStrategy: std::fmt::Debug {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32);
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32);
- fn set_section_count(&mut self, _count: usize) {}
- fn get_column_width(&self) -> Option<f32> { None }
- fn get_gap(&self) -> f32 { 20.0 }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32;
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size;
- fn box_clone(&self) -> Box<dyn LayoutStrategy>;
-}
-
-impl Clone for Box<dyn LayoutStrategy> {
- fn clone(&self) -> Self {
- self.box_clone()
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum FlexDirection {
- Row,
- Column,
-}
-
-#[derive(Debug, Clone)]
-pub struct FlexLayout {
- left: f32,
- top: f32,
- width: f32,
- height: f32,
- direction: FlexDirection,
- spacing: f32,
- current_x: f32,
- current_y: f32,
-}
-
-impl FlexLayout {
- pub fn new(direction: FlexDirection, spacing: f32) -> Self {
- Self {
- left: 0.0,
- top: 0.0,
- width: 0.0,
- height: 0.0,
- direction,
- spacing,
- current_x: 0.0,
- current_y: 0.0,
- }
- }
-}
-
-impl LayoutStrategy for FlexLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.height = height;
- self.current_x = left;
- self.current_y = top;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- match self.direction {
- FlexDirection::Row => {
- let rx = self.current_x;
- let ry = self.current_y;
- self.current_x += ww + self.spacing;
- (rx, ry, ww, wh)
- }
- FlexDirection::Column => {
- let rx = self.current_x;
- let ry = self.current_y;
- self.current_y += wh + self.spacing;
- (rx, ry, ww, wh)
- }
- }
- }
-
- fn get_gap(&self) -> f32 {
- self.spacing
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
- let (cur_x, cur_y) = (x, y);
- // Blocks: the label row (`label_lead`) above every child's content, the
- // gap between blocks, so a row's controls are level and a carve-out tab
- // sits a full gap from its neighbour.
- let lead = crate::widget::container::container_layout::label_lead(children);
- match self.direction {
- FlexDirection::Row => {
- let mut cur_x = cur_x;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_w = child.rect().2;
- let child_h = crate::widget::container::container_layout::content_height(child);
- let use_h = if child_h > 0.0 { child_h } else { h };
- child.layout(
- crate::widget::Point { x: cur_x, y: cur_y + lead },
- crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
- ctx,
- );
- cur_x += child_w + self.spacing;
- }
- }
- (cur_x - x).max(0.0)
- }
- FlexDirection::Column => {
- let mut cur_y = cur_y;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_h = crate::widget::container::container_layout::content_height(child);
- let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- child.layout(
- crate::widget::Point { x, y: cur_y + lead },
- crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
- ctx,
- );
- cur_y += lead + use_h + self.spacing;
- }
- }
- (cur_y - y).max(0.0)
- }
- }
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- match self.direction {
- FlexDirection::Row => {
- let mut total_w = 0.0f32;
- let mut max_h = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_w += size.width;
- max_h = max_h.max(size.height);
- if i > 0 {
- total_w += self.spacing;
- }
- }
- }
- crate::widget::Size {
- width: total_w.clamp(constraints.min_width, constraints.max_width),
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
- FlexDirection::Column => {
- let mut total_h = 0.0f32;
- let mut max_w = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_h += size.height;
- max_w = max_w.max(size.width);
- if i > 0 {
- total_h += self.spacing;
- }
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: total_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct ColumnLayout {
- left: f32,
- top: f32,
- width: f32,
- current_y: f32,
- gap: f32,
-}
-
-impl ColumnLayout {
- pub fn new(gap: f32) -> Self {
- Self {
- left: 0.0,
- top: 0.0,
- width: 0.0,
- current_y: 0.0,
- gap,
- }
- }
-}
-
-impl LayoutStrategy for ColumnLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.current_y = top;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- let x = self.left;
- let y = self.current_y;
- self.current_y += wh + self.gap;
- (x, y, ww, wh)
- }
-
- fn get_column_width(&self) -> Option<f32> {
- Some(self.width)
- }
-
- fn get_gap(&self) -> f32 {
- self.gap
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let mut cur_y = y;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_h = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- child.set_rect(x, cur_y, w, use_h);
- cur_y += use_h + self.gap;
- }
- }
- (cur_y - y).max(0.0)
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let mut total_h = 0.0f32;
- let mut max_w = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_h += size.height;
- max_w = max_w.max(size.width);
- if i > 0 {
- total_h += self.gap;
- }
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: total_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct AdaptiveGrid {
- grid: Option<Grid>,
- #[allow(dead_code)]
- min_col_width: f32,
- #[allow(dead_code)]
- gap: f32,
- num_sections: Option<usize>,
-}
-
-impl AdaptiveGrid {
- pub fn new(min_col_width: f32, gap: f32) -> Self {
- Self {
- grid: None,
- min_col_width,
- gap,
- num_sections: None,
- }
- }
-}
-
-impl LayoutStrategy for AdaptiveGrid {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(max_cols).max(1)
- } else {
- max_cols
- };
- self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref mut grid) = self.grid {
- let num_cols = grid.col_heights.len();
- let num_cols_spanned = (((ww + grid.gap) / (grid.col_width + grid.gap)).round() as usize)
- .min(num_cols)
- .max(1);
-
- if num_cols_spanned >= num_cols {
- let y = grid.max_height();
- let x = grid.left;
- let allocated_w = grid.width;
- grid.col_heights.fill(y + wh + grid.gap);
- (x, y, allocated_w, wh)
- } else if num_cols_spanned == 1 {
- let col = grid.next_column();
- let x = grid.col_lefts[col];
- let y = grid.col_heights[col];
- grid.col_heights[col] += wh + grid.gap;
- (x, y, grid.col_width, wh)
- } else {
- let n = num_cols_spanned;
- let mut best_start_col = 0;
- let mut min_max_h = f32::MAX;
- for c in 0..=(num_cols - n) {
- let mut max_h = 0.0f32;
- for i in 0..n {
- if grid.col_heights[c + i] > max_h {
- max_h = grid.col_heights[c + i];
- }
- }
- if max_h < min_max_h {
- min_max_h = max_h;
- best_start_col = c;
- }
- }
- let x = grid.col_lefts[best_start_col];
- let y = min_max_h;
- let allocated_w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
- for i in 0..n {
- grid.col_heights[best_start_col + i] = y + wh + grid.gap;
- }
- (x, y, allocated_w, wh)
- }
- } else {
- (0.0, 0.0, 0.0, wh)
- }
- }
-
- fn set_section_count(&mut self, count: usize) {
- self.num_sections = Some(count);
- }
-
- fn get_column_width(&self) -> Option<f32> {
- self.grid.as_ref().map(|g| g.col_width)
- }
-
- fn get_gap(&self) -> f32 {
- crate::layout::grid_gap()
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let usable_w = w.max(1.0);
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(cols).max(1)
- } else {
- cols
- };
-
- let total_gap = gap * (count - 1) as f32;
- let available_w = (w - total_gap).max(1.0);
- let col_w = available_w / count as f32;
-
- let mut col_heights = vec![y; count];
-
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let ch = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if ch > 0.0 { ch } else { 44.0 };
-
- let mut min_col = 0;
- let mut min_h = col_heights[0];
- for i in 1..count {
- if col_heights[i] < min_h {
- min_h = col_heights[i];
- min_col = i;
- }
- }
-
- let cx = x + min_col as f32 * (col_w + gap);
- let cy = col_heights[min_col];
- child.set_rect(cx, cy, col_w, use_h);
- col_heights[min_col] += use_h + gap;
- }
- }
-
- let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
- (max_h - y).max(0.0)
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let usable_w = constraints.max_width.max(1.0);
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(cols).max(1)
- } else {
- cols
- };
-
- let mut col_heights = vec![0.0f32; count];
- let total_gap = gap * (count - 1) as f32;
- let available_w = (constraints.max_width - total_gap).max(1.0);
- let col_w = available_w / count as f32;
-
- let child_constraints = crate::widget::LayoutConstraints::new(col_w, col_w, constraints.min_height, constraints.max_height);
-
- for &child_ptr in children {
- unsafe {
- let size = (*child_ptr).measure(child_constraints, ctx);
- let mut min_col = 0;
- let mut min_h = col_heights[0];
- for i in 1..count {
- if col_heights[i] < min_h {
- min_h = col_heights[i];
- min_col = i;
- }
- }
- col_heights[min_col] += size.height + gap;
- }
- }
-
- let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
- crate::widget::Size {
- width: constraints.max_width,
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct RadialLayout {
- radial: Option<Radial>,
- aspect_ratio: f32,
- base_spacing: f32,
- idx: usize,
-}
-
-impl RadialLayout {
- pub fn new(aspect_ratio: f32, base_spacing: f32) -> Self {
- Self {
- radial: None,
- aspect_ratio,
- base_spacing,
- idx: 0,
- }
- }
-}
-
-impl LayoutStrategy for RadialLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- let cx = left + width / 2.0;
- let cy = top + height / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (width / height.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- self.radial = Some(Radial::new(cx, cy, aspect, self.base_spacing));
- self.idx = 0;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref radial) = self.radial {
- let rect = radial.widget_rect(self.idx, ww, wh);
- self.idx += 1;
- rect
- } else {
- (0.0, 0.0, ww, wh)
- }
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let cx = x + w / 2.0;
- let cy = y + h / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (w / h.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- let radial = Radial::new(cx, cy, aspect, self.base_spacing);
- for (idx, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let child = &mut *child_ptr;
- let cw = child.rect().2;
- let ch = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if ch > 0.0 { ch } else { 44.0 };
- let (rx, ry, rw, rh) = radial.widget_rect(idx, cw, use_h);
- child.set_rect(rx, ry, rw, rh);
- }
- }
- h
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let cx = constraints.max_width / 2.0;
- let cy = constraints.max_height / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (constraints.max_width / constraints.max_height.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- let radial = Radial::new(cx, cy, aspect, self.base_spacing);
- let mut max_w = 0.0f32;
- let mut max_h = 0.0f32;
- for (idx, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- let (rx, ry, rw, rh) = radial.widget_rect(idx, size.width, size.height);
- max_w = max_w.max(rx + rw);
- max_h = max_h.max(ry + rh);
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-/// Vertical slack for a section's content clip. The clip exists to stop
-/// content escaping its section SIDEWAYS, which is the axis a section's width
-/// actually fixes; a section's height is only known once its content has been
-/// placed, so bounding that axis too would risk cutting content off rather
-/// than keeping it in. Deliberately far larger than any section.
-const SECTION_CLIP_SLACK: f32 = 100_000.0;
-
-pub struct PageLayoutBuilder<'a, P> {
- pub strategy: &'a mut dyn LayoutStrategy,
- pub cx: f32,
- pub cy: f32,
- pub cw: f32,
- pub ch: f32,
- pub section_width: f32,
- pub idx: usize,
- _phantom: std::marker::PhantomData<P>,
-}
-
-impl<'a, P: RenderTarget + Default> PageLayoutBuilder<'a, P> {
- pub fn new(
- strategy: &'a mut dyn LayoutStrategy,
- cx: f32,
- cy: f32,
- cw: f32,
- ch: f32,
- section_width: f32,
- ) -> Self {
- strategy.init(cx, cy, cw, ch);
- Self {
- strategy,
- cx,
- cy,
- cw,
- ch,
- section_width,
- idx: 0,
- _phantom: std::marker::PhantomData,
- }
- }
-
- pub fn with_section_count(self, count: usize) -> Self {
- self.strategy.set_section_count(count);
- self.strategy.init(self.cx, self.cy, self.cw, self.ch);
- self
- }
-
- pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let width = self.strategy.get_column_width().unwrap_or(self.section_width);
- let mut dummy = P::default();
- let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
- render_fn(&mut dummy_ctx);
- let wh = dummy_ctx.finish();
- let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
- let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
- let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
- real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
- render_fn(&mut real_ctx);
- real_ctx.pc.pop_clip_rect();
- real_ctx.finish();
- self.idx += 1;
- }
-
- pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let mut dummy = P::default();
- let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
- render_fn(&mut dummy_ctx);
- let wh = dummy_ctx.finish();
- let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
- let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
- let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
- real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
- render_fn(&mut real_ctx);
- real_ctx.pc.pop_clip_rect();
- real_ctx.finish();
- self.idx += 1;
- }
-
- pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let col_width = self.strategy.get_column_width().unwrap_or(self.section_width);
- let gap = self.strategy.get_gap();
- let width = span as f32 * col_width + (span - 1) as f32 * gap;
- self.add_section_with_width(final_pc, width, label, focused, render_fn);
- }
-}
-
-pub struct SectionContext<'a, P> {
- pub pc: &'a mut P,
- pub left: f32,
- pub top: f32,
- pub content_y: f32,
- pub cw: f32,
- pub label_width: f32,
- pub label_x: f32,
- /// Whether the host renders sections as sunken wells (`section_relief_style`).
- pub relief_style: bool,
- /// The title tab box (x, y, w, h) when the host's relief styling laid the
- /// label out left-aligned — `finish` offers it with the section carve.
- /// None under relief styling means a label-less section: the well carves
- /// tabless, flush with the allocation top.
- pub relief_tab: Option<(f32, f32, f32, f32)>,
- pub focused: bool,
- pub is_child: bool,
- pub grid: Grid,
- pub last_col: usize,
- pub content_start_y: f32,
- pub row_gap: f32,
-}
-
-impl<'a, P: RenderTarget> SectionContext<'a, P> {
- pub const DEFAULT_MARGIN_X: f32 = 12.0;
- pub const DEFAULT_ROW_GAP: f32 = 8.0;
-
- fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
- estimate_label_width_helper(label, font_size, font_fam)
- }
-
- pub fn padding(&self) -> f32 {
- section_padding()
- }
-
- pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
- let font_setting = if is_child {
- nested_section_label_font()
- } else {
- section_label_font()
- };
- let (font_fam, font_size_opt) = parse_font_string(&font_setting);
- let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
- let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
- let label_width = Self::estimate_label_width(label, font_size, &font_fam);
- let relief_style = pc.section_relief_style();
- let label_x = if is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => left + 12.0,
- 1 => left + (cw - label_width) / 2.0,
- 2 => left + cw - 12.0 - label_width,
- _ => left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else if relief_style {
- // Sunken style: the title sits in a tab flush with the well's left
- // edge (the designer look), not centered on the border.
- left + 12.0
- } else {
- left + (cw - label_width) / 2.0
- };
- // Under relief styling the well fills the whole allocated rect (so the
- // page's gaps and margins are the visual gaps) — the tab tops the
- // allocation and the label centers inside it.
- let label_y = if relief_style { top + 4.0 } else { top };
- pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
-
- // The tab wraps the label, flush on the well's top-left corner; clamped
- // so off-default child alignments can't push it outside the well.
- let relief_tab = if relief_style && label_width > 0.0 {
- let tab_x = (label_x - 12.0).max(left);
- Some((tab_x, top, label_width + 24.0, font_size + 10.0))
- } else {
- None
- };
-
- let pad = section_padding();
- let margin_x = 2.0 * pad + 12.0;
- let usable_w = (cw - 2.0 * margin_x).max(1.0);
- let min_col_width = 130.0;
- let gap = 8.0;
- let max_cols = if is_child {
- 1
- } else {
- ((usable_w + gap) / (min_col_width + gap)).floor().clamp(1.0, 2.0) as usize
- };
- // Under relief styling the content stands off the well's top wall by
- // the same inset it keeps from the side walls (`margin_x`, which is
- // also what `finish` leaves below it), so a well reads as one even
- // frame. The outline style's `pad + 19` predates the title tab: under
- // relief the tab alone is `font_size + 10` tall, which left the first
- // line touching the well's top wall at the default padding while the
- // sides kept 20px and more.
- let content_start_y = if relief_style {
- relief_tab.map(|t| t.1 + t.3).unwrap_or(top) + margin_x
- } else {
- top + pad + 19.0
- };
- let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
-
- Self {
- pc,
- left,
- top,
- content_y: content_start_y,
- cw,
- label_width,
- label_x,
- relief_style,
- relief_tab,
- focused,
- is_child,
- grid,
- last_col: usize::MAX,
- content_start_y,
- row_gap: Self::DEFAULT_ROW_GAP,
- }
- }
-
- pub fn with_row_gap(mut self, gap: f32) -> Self {
- self.row_gap = gap;
- self
- }
-
- /// The section's content-box top edge: the body well's top (the tab's
- /// bottom) under relief styling, the outline's border line otherwise. Lets
- /// a page place content at an exact inset from the well's walls.
- pub fn well_top(&self) -> f32 {
- if self.relief_style {
- self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
- } else {
- self.top + 7.0
- }
- }
-
- pub fn set_row_gap(&mut self, gap: f32) {
- self.row_gap = gap;
- }
-
- /// Horizontal inset of section CONTENT from the section's left edge.
- ///
- /// One number, used by every content placer in here — `row_layout`, the
- /// column `Grid`, `widget`, `VStack` and `ax` (so `text`) — because they
- /// share a section and have to line up inside it. The section's border is
- /// drawn at `left + padding()` (see `finish`), so content clears the
- /// border by `padding() + 12`.
- pub fn content_margin(&self) -> f32 {
- 2.0 * self.padding() + 12.0
- }
-
- /// Left edge of the content box: where a row, a widget or a `text(_, 12.0,
- /// ..)` starts.
- pub fn content_left(&self) -> f32 {
- self.left + self.content_margin()
- }
-
- /// Width of the content box — the section's width less the inset on both
- /// sides. Nothing a section draws should extend past `content_left() +
- /// content_width()`.
- pub fn content_width(&self) -> f32 {
- (self.cw - 2.0 * self.content_margin()).max(0.0)
- }
-
- /// `x_off` px into the content box's coordinate space, where 12.0 is the
- /// content's own left edge — the offset 46 of the ~55 call sites in the
- /// tree already pass, and the one that lines text up with the buttons and
- /// widgets beside it.
- ///
- /// This used to add `padding()` only when `x_off >= 12.0`, which made the
- /// mapping DISCONTINUOUS: asking for 11 instead of 12 moved the text 5px
- /// LEFT rather than 1px, and silently dropped it out of alignment with
- /// every row in the same section. `cce-mail` and two others sit on the
- /// wrong side of that cliff today.
- pub fn ax(&self, x_off: f32) -> f32 {
- self.left + 2.0 * self.padding() + x_off
- }
-
- pub fn ay(&self) -> f32 {
- self.content_y
- }
-
- pub fn spacing(&mut self, dy: f32) {
- if self.grid.col_heights.len() >= 2 {
- if self.last_col == usize::MAX {
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- } else if self.last_col < self.grid.col_heights.len() {
- self.grid.col_heights[self.last_col] += dy;
- }
- self.content_y = self.grid.max_height();
- } else {
- self.content_y += dy;
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- }
- }
-
- pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
- let mut y = self.content_y + y_off;
- if y > self.content_start_y {
- y += self.row_gap;
- }
- let x = self.ax(x_off);
- // Bound it to the content box. A section's text was drawn unbounded,
- // so a string wider than its section simply kept going — over the
- // border, over whatever sat to the right, and off the window (the
- // settings app's GPU names did all three). Rows and widgets have
- // always been sized to the section; text was the one thing that could
- // leave it. The vertical band is generous on purpose: it is the
- // horizontal overrun that has to be cut, and a tight band would
- // shave descenders.
- let right = self.content_left() + self.content_width();
- let bounds = if right > x {
- Some([x, y - font_size, right, y + 2.0 * font_size])
- } else {
- None
- };
- self.pc.text_with_bounds(text, x, y, font_size, color, bounds);
- let new_bottom = y + font_size + 4.0;
- self.content_y = new_bottom;
- self.grid.col_heights.fill(new_bottom);
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let pad = self.padding();
- let top_room = w.label_strip();
- let total_h = wh + top_room;
-
- let name = w.type_name();
- let span_full = name == "Trackpad"
- || name == "Canvas"
- || name == "UsageBar"
- || name == "ProgressBar"
- || name == "ButtonStrip"
- || name == "Spreadsheet"
- || name == "Graph";
-
- if span_full {
- let margin_x = 2.0 * pad + 12.0;
- let x = self.left + margin_x;
- let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
- let mut max_h = self.grid.max_height().max(self.content_y);
- if max_h > self.content_start_y {
- max_h += self.row_gap;
- }
- let y = max_h;
-
- w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
- render_widget(self.pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.content_y = new_bottom;
- self.grid.col_heights.fill(new_bottom);
- } else {
- let max_h = self.grid.max_height();
- if self.content_y > max_h {
- self.grid.col_heights.fill(self.content_y);
- }
-
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let mut y = self.grid.col_heights[col];
- if y > self.content_start_y {
- y += self.row_gap;
- }
-
- let aligned_x = x;
- let aligned_w = self.grid.col_width;
-
- w.set_row_rect(aligned_x, aligned_w);
- render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
- self.grid.col_heights[col] = y + total_h;
- self.content_y = self.grid.max_height();
- }
- }
-
- pub fn widget_full<T: WidgetHost + 'static>(&mut self, w: &mut T, wh: f32, ctx: &mut UiContext) {
- let x_off = 12.0;
- let ww = self.cw - 2.0 * (self.padding() + x_off); // cw - 40.0
- self.widget(w, x_off, ww, wh, ctx);
- }
-
- pub fn separator(&mut self) {
- let pad = self.padding();
- let x = self.ax(pad);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
- self.pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
- self.content_y = max_h + 8.0;
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn rect(&mut self, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- let max_h = self.grid.max_height().max(self.content_y);
- self.pc.rect(color, self.ax(x_off), max_h, w, h);
- self.content_y = max_h + h;
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
- let margin_x = self.content_margin();
- let usable_w = self.content_width();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let col_w = (usable_w - total_gap).max(0.0) / count as f32;
-
- let mut cols = Vec::with_capacity(count);
- for i in 0..count {
- let x = self.left + margin_x + i as f32 * (col_w + gap);
- cols.push((x, col_w));
- }
- cols
- }
-
- /// A row whose columns are sized to what goes IN them: each gets the width
- /// it asked for in `needs`, and whatever is left over is shared equally.
- ///
- /// `row_layout` splits a row evenly and knows nothing about content, so it
- /// hands "Reboot" and "Hibernate" the same width — one floats in slack
- /// while the other is cut off, which is what a row of mismatched labels
- /// looks like. Sharing the SLACK equally rather than sizing proportionally
- /// is deliberate: proportional widths would make a two-character label a
- /// sliver, where what is wanted is "everyone fits, then everyone gets the
- /// same bonus".
- ///
- /// When the needs do not fit, every column is scaled by the same factor, so
- /// the row still cannot overflow its section and the shortfall is shared
- /// rather than landing entirely on the last column.
- pub fn row_layout_for(&self, needs: &[f32], gap: f32) -> Vec<(f32, f32)> {
- let count = needs.len();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let room = (self.content_width() - total_gap).max(0.0);
- let total_need: f32 = needs.iter().map(|n| n.max(0.0)).sum();
-
- let widths: Vec<f32> = if total_need <= room {
- let extra = (room - total_need) / count as f32;
- needs.iter().map(|n| n.max(0.0) + extra).collect()
- } else if total_need > 0.0 {
- let scale = room / total_need;
- needs.iter().map(|n| n.max(0.0) * scale).collect()
- } else {
- vec![room / count as f32; count]
- };
-
- let mut cols = Vec::with_capacity(count);
- let mut x = self.content_left();
- for w in widths {
- cols.push((x, w));
- x += w + gap;
- }
- cols
- }
-
-
- pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- let max_h = self.grid.max_height().max(self.content_y);
- self.grid.col_heights.fill(max_h);
- self.content_y = max_h;
-
- let cols = self.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- f(i, x, w);
- }
- self.content_y += h;
-
- self.grid.col_heights.fill(self.content_y);
- }
-
- pub fn vstack(&mut self, spacing: f32) -> VStack<'_, 'a, P> {
- VStack {
- context: self,
- spacing,
- }
- }
-
- pub fn add_section<F>(&mut self, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let pad = self.padding();
- let (left, top, cw, is_side_by_side) = if self.grid.col_heights.len() >= 2 {
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let mut y = self.grid.col_heights[col];
- if y > self.content_start_y {
- y += self.row_gap;
- }
- (x, y, self.grid.col_width, true)
- } else {
- let left = self.ax(0.0) + pad;
- let mut max_h = self.grid.max_height().max(self.content_y);
- if max_h > self.content_start_y {
- max_h += self.row_gap;
- }
- let top = max_h;
- let cw = self.cw - 2.0 * pad;
- (left, top, cw, false)
- };
-
- let mut sub_ctx = SectionContext::new(self.pc, left, top, cw, label, focused, true);
- render_fn(&mut sub_ctx);
- let new_bottom = sub_ctx.finish();
-
- if is_side_by_side {
- let col = self.last_col;
- self.grid.col_heights[col] = new_bottom;
- self.content_y = self.grid.max_height();
- } else {
- self.content_y = new_bottom;
- self.grid.col_heights.fill(new_bottom);
- }
- }
-
- pub fn finish(self) -> f32 {
- let border: [f32; 4] = if self.is_child {
- if self.focused {
- [0.22, 0.38, 0.24, 1.0]
- } else {
- [0.18, 0.18, 0.25, 1.0]
- }
- } else {
- if self.focused {
- [0.30, 0.50, 0.32, 1.0] // Focused green
- } else {
- [0.25, 0.25, 0.35, 1.0] // Default gray
- }
- };
- let pad = self.padding();
- let x = self.left + pad;
- let y = self.top + 7.0;
- let w = self.cw - 2.0 * pad;
- let h = self.content_y - y;
- // Under relief the well's walls are the allocation's edges, so the
- // floor below the content matches the inset beside and above it (see
- // `new`). The outline frame sits `pad` in from its allocation and
- // keeps its own slack.
- let extra_bottom = if self.relief_style { self.content_margin() } else { pad + 12.0 };
- let bottom = y + h + extra_bottom;
-
- if self.relief_style {
- // Sunken style: the well spans the full allocated rect — body top
- // edge at the tab's bottom (the tab is flush ON the body, the
- // designer union shape; label-less sections carve tabless from the
- // allocation top), walls on the allocation's edges, and no
- // trailing slack so the layout gap IS the visual gap.
- let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
- let frame = SectionFrame {
- x: self.left,
- y: body_y,
- w: self.cw,
- h: bottom - body_y,
- tab: self.relief_tab,
- focused: self.focused,
- is_child: self.is_child,
- };
- if self.pc.section_relief(&frame) {
- return self.content_y + extra_bottom;
- }
- }
-
- let left_edge = x;
- let right_edge = x + w;
- if self.label_width > 0.0 {
- let label_x = self.label_x;
- let gap_margin = 6.0;
- let gap_start = label_x - gap_margin;
- let gap_end = label_x + self.label_width + gap_margin;
- if gap_start > left_edge {
- self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
- }
- if right_edge > gap_end {
- self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
- }
- } else {
- self.pc.rect(border, left_edge, y, w, 1.0);
- }
-
- self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
- self.pc.rect(border, x, y, 1.0, h + extra_bottom);
- self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
- self.content_y + extra_bottom + 8.0
- }
-}
-
-
-pub struct VStack<'b, 'a, P> {
- pub context: &'b mut SectionContext<'a, P>,
- pub spacing: f32,
-}
-
-impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
- pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
- let pad = self.context.padding();
- let margin_x = 2.0 * pad + 12.0;
- let x = self.context.left + margin_x;
- let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
-
- let mut max_h = self.context.grid.max_height().max(self.context.content_y);
- if max_h > self.context.content_start_y {
- max_h += self.context.row_gap;
- }
- let y = max_h;
-
- let pref_h = w.preferred_height().unwrap_or(wh);
- let top_room = w.label_strip();
- let total_h = pref_h + top_room;
-
- w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
- render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.context.content_y = new_bottom;
- self.context.grid.col_heights.fill(new_bottom);
- self.context.spacing(self.spacing);
- }
-
- /// [`add_row`](Self::add_row) with per-column widths from `needs` — see
- /// [`SectionContext::row_layout_for`]. For a row of buttons, `needs` is
- /// each label's measured width plus the plate's own inset.
- pub fn add_row_for<F>(&mut self, needs: &[f32], gap: f32, h: f32, mut f: F)
- where
- F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
- {
- let max_h = self.context.grid.max_height().max(self.context.content_y);
- self.context.grid.col_heights.fill(max_h);
- self.context.content_y = max_h;
-
- let cols = self.context.row_layout_for(needs, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- self.context.content_y = max_h;
- f(self.context, i, x, w);
- }
-
- let new_bottom = max_h + h;
- self.context.content_y = new_bottom;
- self.context.grid.col_heights.fill(new_bottom);
- self.context.spacing(self.spacing);
- }
-
- pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
- {
- let max_h = self.context.grid.max_height().max(self.context.content_y);
- self.context.grid.col_heights.fill(max_h);
- self.context.content_y = max_h;
-
- let cols = self.context.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- self.context.content_y = max_h;
- f(self.context, i, x, w);
- }
-
- let new_bottom = max_h + h;
- self.context.content_y = new_bottom;
- self.context.grid.col_heights.fill(new_bottom);
- self.context.spacing(self.spacing);
- }
-}
-
-pub fn get_system_monospace_font() -> &'static str {
- static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
- MONOSPACE_FONT.get_or_init(|| {
- if let Ok(output) = std::process::Command::new("fc-match")
- .args(["-f", "%{family}", "monospace"])
- .output()
- {
- let name = String::from_utf8_lossy(&output.stdout);
- let parsed = name.split(',').next().unwrap_or("monospace").trim();
- if !parsed.is_empty() {
- return parsed.to_string();
- }
- }
- "monospace".to_string()
- })
-}
-
-pub fn get_system_sans_serif_font() -> &'static str {
- static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
- SANS_SERIF_FONT.get_or_init(|| {
- if let Ok(output) = std::process::Command::new("fc-match")
- .args(["-f", "%{family}", "sans-serif"])
- .output()
- {
- let name = String::from_utf8_lossy(&output.stdout);
- let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
- if !parsed.is_empty() {
- return parsed.to_string();
- }
- }
- "sans-serif".to_string()
- })
-}
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 19efb37..0d0e0b0 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -74,8 +74,10 @@ mod registry;
pub use registry::*;
mod bridge;
pub use bridge::*;
-mod legacy;
-pub use legacy::*;
+mod section;
+pub use section::*;
+mod panes;
+pub use panes::*;
static SECTION_PADDING: crate::style::StyleCell<f32> = crate::style::StyleCell::new(|s| &s.layout.SECTION_PADDING, |s| &mut s.layout.SECTION_PADDING);
@@ -3501,30 +3503,6 @@ pub fn read_preferred_fonts() -> (String, String, String, String) {
)
}
-impl crate::widget::ContainerLayout for FlexLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for ColumnLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for AdaptiveGrid {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for RadialLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
#[cfg(test)]
mod tests {
use crate::widget::WidgetHost;
@@ -3827,27 +3805,26 @@ mod tests {
/// derived from the getters instead, so the flow holds under any config.
#[test]
fn test_vstack_flow() {
- // `Section` seats its first column one `margin_x` in from its left edge.
- let first_col_x = 10.0 + 2.0 * section_padding() + Section::DEFAULT_MARGIN_X;
+ // A section's VStack seats every widget one content margin in from its left edge.
let mut mock_pc = MockRenderTarget { rects: Vec::new() };
- let mut sec = Section::new(&mut mock_pc, 10.0, 20.0, 200.0, "Test Section");
-
+ let mut sec = SectionContext::new(&mut mock_pc, 10.0, 20.0, 200.0, "Test Section", false, false);
+ let first_col_x = sec.content_left();
+ let gap = sec.row_gap;
let start_y = sec.ay();
- let mut stack = sec.vstack(&mut mock_pc, 10.0);
+ let mut stack = sec.vstack(10.0);
let mut dummy = crate::context::UiContext::new();
let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
stack.add_widget(&mut w1, 50.0, 30.0, &mut dummy);
- // Standard margin should be applied
assert_eq!(w1.x, first_col_x);
assert_eq!(w1.y, start_y);
let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
stack.add_widget(&mut w2, 60.0, 40.0, &mut dummy);
- // Second widget should start after first widget height + vstack spacing
- assert_eq!(w2.y, start_y + 30.0 + 10.0);
+ // The next starts below the first, the stack's spacing and the row gap on.
+ assert_eq!(w2.y, start_y + 30.0 + 10.0 + gap);
let mut base = crate::widget::Widget::new();
base.label = Some("Test Label".to_string());
@@ -3855,10 +3832,9 @@ mod tests {
stack.add_widget(&mut w3, 70.0, 50.0, &mut dummy);
let offset = w3.base.label_offset();
-
- // Third widget has label, so its y should be shifted by offset
+ // A labelled widget's control sits below its label strip.
assert!(offset > 0.0, "a labelled widget has a strip");
- assert_eq!(w3.base.y, start_y + 30.0 + 10.0 + 40.0 + 10.0 + offset);
+ assert_eq!(w3.base.y, start_y + 30.0 + 10.0 + gap + 40.0 + 10.0 + gap + offset);
}
#[test]
@@ -3893,44 +3869,20 @@ mod tests {
#[test]
fn test_subsection() {
let mut pc = PopoverCollector::new();
- let mut subsec = Section::new_opt(&mut pc, 10.0, 20.0, 300.0, "Test Subsec", true);
+ let mut subsec = SectionContext::new(&mut pc, 10.0, 20.0, 300.0, "Test Subsec", false, true);
assert_eq!(subsec.left, 10.0);
assert_eq!(subsec.top, 20.0);
assert_eq!(subsec.cw, 300.0);
assert!(subsec.is_child);
-
+
let mut dummy = crate::context::UiContext::new();
let mut w = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
- subsec.widget(&mut pc, &mut w, 12.0, 100.0, 40.0, &mut dummy);
-
- let bottom = subsec.finish(&mut pc);
+ subsec.widget(&mut w, 12.0, 100.0, 40.0, &mut dummy);
+
+ let bottom = subsec.finish();
assert!(bottom > 20.0);
}
- #[test]
- fn test_radial_layout() {
- let radial = Radial::new(100.0, 100.0, 1.5, 50.0);
-
- // Check first widget is centered at (100.0, 100.0)
- let rect0 = radial.widget_rect(0, 40.0, 30.0);
- assert_eq!(rect0, (100.0 - 20.0, 100.0 - 15.0, 40.0, 30.0));
-
- // Check Ring 1 (idx = 1) vs Ring 2 (idx = 7)
- let rect1 = radial.widget_rect(1, 40.0, 30.0);
- let rect7 = radial.widget_rect(7, 40.0, 30.0);
-
- let c1_x = rect1.0 + rect1.2 / 2.0;
- let c1_y = rect1.1 + rect1.3 / 2.0;
- let c7_x = rect7.0 + rect7.2 / 2.0;
- let c7_y = rect7.1 + rect7.3 / 2.0;
-
- let d1 = ((c1_x - 100.0).powi(2) + (c1_y - 100.0).powi(2)).sqrt();
- let d7 = ((c7_x - 100.0).powi(2) + (c7_y - 100.0).powi(2)).sqrt();
-
- // Ring 2 should be further out than Ring 1
- assert!(d7 > d1);
- assert!(d1 > 0.0);
- }
// The `Once`-initialised style getters below are checked against the
// statics their config pass fills, under whatever the live config says.
@@ -4251,7 +4203,7 @@ mod tests {
#[test]
fn test_mosaic_layout() {
use crate::widget::MosaicLayout;
- use crate::layout::LayoutStrategy;
+ use crate::widget::ContainerLayout;
let layout = MosaicLayout {
gap: 10.0,
@@ -4283,7 +4235,7 @@ mod tests {
#[test]
fn test_reverse_mosaic_layout() {
use crate::widget::ReverseMosaicLayout;
- use crate::layout::LayoutStrategy;
+ use crate::widget::ContainerLayout;
let layout = ReverseMosaicLayout {
gap: 10.0,
diff --git a/src/layout/panes.rs b/src/layout/panes.rs
new file mode 100644
index 0000000..963b9c2
--- /dev/null
+++ b/src/layout/panes.rs
@@ -0,0 +1,96 @@
+//! Pane geometry for a window of three resizable columns, and for a circular pane:
+//! cce-designer's. Plain arithmetic over rects and points, no layout pass.
+
+pub struct SplitterLayout {
+ pub splitter1_x: f32,
+ pub splitter2_x: f32,
+ pub splitter_width: f32,
+ pub min_column_width: f32,
+}
+
+impl SplitterLayout {
+ pub fn new(width: f32, splitter_width: f32, min_column_width: f32) -> Self {
+ let s1 = (width - 2.0 * splitter_width) / 3.0;
+ let s2 = s1 + splitter_width + (width - 2.0 * splitter_width) / 3.0;
+ Self {
+ splitter1_x: s1,
+ splitter2_x: s2,
+ splitter_width,
+ min_column_width,
+ }
+ }
+
+ pub fn clamp(&mut self, total_width: f32, detached_circular_network: bool) {
+ if detached_circular_network {
+ let min_s2 = self.min_column_width;
+ let max_s2 = (total_width - self.min_column_width).max(min_s2);
+ self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
+ } else {
+ let min_s1 = self.min_column_width;
+ let max_s1 = (self.splitter2_x - self.splitter_width - self.min_column_width).max(min_s1);
+ self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
+ let min_s2 = self.splitter1_x + self.splitter_width + self.min_column_width;
+ let max_s2 = (total_width - self.min_column_width).max(min_s2);
+ self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
+ }
+ }
+
+ pub fn scale(&mut self, factor: f32) {
+ self.splitter1_x *= factor;
+ self.splitter2_x *= factor;
+ }
+
+ pub fn left_col(&self) -> (f32, f32) { // (x, width)
+ (0.0, self.splitter1_x)
+ }
+
+ pub fn center_col(&self) -> (f32, f32) { // (x, width)
+ let x = self.splitter1_x + self.splitter_width;
+ (x, self.splitter2_x - x)
+ }
+
+ pub fn right_col(&self, total_width: f32) -> (f32, f32) { // (x, width)
+ let x = self.splitter2_x + self.splitter_width;
+ (x, (total_width - x).max(0.0))
+ }
+}
+
+pub struct CircularPaneLayout {
+ pub x: f32,
+ pub y: f32,
+ pub r: f32,
+}
+
+impl CircularPaneLayout {
+ pub fn new(x: f32, y: f32, r: f32) -> Self {
+ Self { x, y, r }
+ }
+
+ pub fn hit_test_content(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist_sq = dx * dx + dy * dy;
+ dist_sq <= self.r * self.r && cy >= self.y - self.r + 45.0 + menubar_h + breadcrumb_h
+ }
+
+ pub fn hit_test_menubar(&self, cx: f32, cy: f32, menubar_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist = (dx * dx + dy * dy).sqrt();
+ dist >= self.r - menubar_h && dist <= self.r && cy < self.y
+ }
+
+ pub fn hit_test_breadcrumb(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist_sq = dx * dx + dy * dy;
+ dist_sq <= self.r * self.r && cy >= self.y - self.r + menubar_h && cy < self.y - self.r + 45.0 + menubar_h + breadcrumb_h
+ }
+
+ pub fn hit_test_border(&self, cx: f32, cy: f32, border_thickness: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist = (dx * dx + dy * dy).sqrt();
+ dist >= self.r - border_thickness && dist <= self.r
+ }
+}
diff --git a/src/layout/registry.rs b/src/layout/registry.rs
index 8eb781a..95ac1b6 100644
--- a/src/layout/registry.rs
+++ b/src/layout/registry.rs
@@ -19,6 +19,42 @@ use std::collections::HashMap;
///
/// Fixing it at the registry rather than per accessor covers every slot it
/// holds in one place, including ones no test pins yet.
+/// The family fontconfig matches for `monospace` (`fc-match`), asked once.
+pub fn get_system_monospace_font() -> &'static str {
+ static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+ MONOSPACE_FONT.get_or_init(|| {
+ if let Ok(output) = std::process::Command::new("fc-match")
+ .args(["-f", "%{family}", "monospace"])
+ .output()
+ {
+ let name = String::from_utf8_lossy(&output.stdout);
+ let parsed = name.split(',').next().unwrap_or("monospace").trim();
+ if !parsed.is_empty() {
+ return parsed.to_string();
+ }
+ }
+ "monospace".to_string()
+ })
+}
+
+/// The family fontconfig matches for `sans-serif`, asked once.
+pub fn get_system_sans_serif_font() -> &'static str {
+ static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+ SANS_SERIF_FONT.get_or_init(|| {
+ if let Ok(output) = std::process::Command::new("fc-match")
+ .args(["-f", "%{family}", "sans-serif"])
+ .output()
+ {
+ let name = String::from_utf8_lossy(&output.stdout);
+ let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
+ if !parsed.is_empty() {
+ return parsed.to_string();
+ }
+ }
+ "sans-serif".to_string()
+ })
+}
+
#[cfg(test)]
mod test_overlay {
use std::cell::RefCell;
diff --git a/src/layout/section.rs b/src/layout/section.rs
new file mode 100644
index 0000000..ea342e8
--- /dev/null
+++ b/src/layout/section.rs
@@ -0,0 +1,930 @@
+//! A settings page's sections: where each stands on the page ([`PageFlow`]), and what
+//! goes inside one ([`SectionContext`]).
+//!
+//! A page is a masonry of sections — the page split into as many columns as fit
+//! `grid_min_col_width()`, at most the page's section count, each section dropped into the
+//! shortest column. A section is drawn ONCE, in place: where it goes depends only on the
+//! sections before it, never on its own height, so [`PageLayoutBuilder`] asks the flow for
+//! the slot, lets the section draw its content there, and hands the height it came to back
+//! to the flow. Until 2026-10-08 every section was drawn twice — once into a throwaway
+//! target to measure it, then for real — through the legacy `LayoutStrategy`, whose
+//! `allocate` took the height first; the flow's choices are the same, so the pages are too.
+//!
+//! What a section draws is immediate-mode: [`SectionContext`] keeps a cursor down its content
+//! box and a one- or two-column grid for the widgets that do not span it. Its geometry is the
+//! settings app's, and documented on each placer.
+
+use super::*;
+use crate::widget::WidgetHost;
+use crate::context::UiContext;
+
+fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
+ let fam_lower = font_fam.to_lowercase();
+ let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
+ if is_mono {
+ label.chars().count() as f32 * font_size * 0.60
+ } else {
+ let mut width = 0.0;
+ for c in label.chars() {
+ let factor = match c {
+ 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
+ 'f' | 'j' | 't' => 0.35,
+ 'r' | 's' | 'c' | 'z' => 0.50,
+ 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
+ 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
+ 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
+ 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
+ '0'..='9' => 0.60,
+ _ => 0.60,
+ };
+ width += factor * font_size;
+ }
+ width
+ }
+}
+
+/// A section's column grid, and the flow's: the column lefts, their width, and how far down
+/// each is filled.
+#[derive(Debug, Clone)]
+pub struct Grid {
+ pub left: f32,
+ pub top: f32,
+ pub width: f32,
+ pub col_width: f32,
+ pub gap: f32,
+ pub col_heights: Vec<f32>,
+ pub col_lefts: Vec<f32>,
+}
+
+impl Grid {
+ pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
+ let total_gap = gap * (count - 1) as f32;
+ let col_width = if count > 0 {
+ (width - total_gap).max(0.0) / count as f32
+ } else {
+ min_col_width
+ };
+ let left_offset = 0.0;
+
+ let mut col_lefts = Vec::with_capacity(count);
+ let col_heights = vec![top; count];
+ for i in 0..count {
+ col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
+ }
+
+ Self {
+ left,
+ top,
+ width,
+ col_width,
+ gap,
+ col_heights,
+ col_lefts,
+ }
+ }
+
+ pub fn next_column(&self) -> usize {
+ let mut min_idx = 0;
+ let mut min_h = self.col_heights[0];
+ for i in 1..self.col_heights.len() {
+ if self.col_heights[i] < min_h {
+ min_h = self.col_heights[i];
+ min_idx = i;
+ }
+ }
+ min_idx
+ }
+
+ pub fn max_height(&self) -> f32 {
+ let mut max_h = self.col_heights[0];
+ for i in 1..self.col_heights.len() {
+ if self.col_heights[i] > max_h {
+ max_h = self.col_heights[i];
+ }
+ }
+ max_h
+ }
+}
+
+/// Where a page's sections stand (see the module docs).
+#[derive(Debug, Clone, Default)]
+pub struct PageFlow {
+ grid: Option<Grid>,
+ sections: Option<usize>,
+}
+
+/// Where [`PageFlow::place`] put a section, for [`PageFlow::commit`] to fill once its height
+/// is known.
+#[derive(Debug, Clone, Copy)]
+pub struct Slot {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ span: SlotSpan,
+}
+
+#[derive(Debug, Clone, Copy)]
+enum SlotSpan {
+ /// Every column: they all end below it.
+ All,
+ /// One column.
+ One(usize),
+ /// `n` columns from `first`.
+ Run { first: usize, n: usize },
+ /// The flow was never laid over a page.
+ Nowhere,
+}
+
+impl PageFlow {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Lay the flow over the page's content rect. The column count is what fits
+ /// `grid_min_col_width()` with `grid_gap()` between, capped by the section count.
+ pub fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
+ let min_col_width = grid_min_col_width();
+ let gap = grid_gap();
+ let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
+ let count = if let Some(n) = self.sections {
+ n.min(max_cols).max(1)
+ } else {
+ max_cols
+ };
+ self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
+ }
+
+ /// At most this many columns: a page with fewer sections than fit across does not leave
+ /// columns empty. Takes effect at the next [`init`](Self::init).
+ pub fn set_section_count(&mut self, count: usize) {
+ self.sections = Some(count);
+ }
+
+ /// One column's width, once laid over a page.
+ pub fn column_width(&self) -> Option<f32> {
+ self.grid.as_ref().map(|g| g.col_width)
+ }
+
+ /// The gap between sections, both ways.
+ pub fn gap(&self) -> f32 {
+ grid_gap()
+ }
+
+ /// Where a section `width` wide goes: as many columns as it is wide (rounded), at the
+ /// left of the run whose lowest fill is highest-up, just below that fill. A section as
+ /// wide as the page goes below everything.
+ pub fn place(&self, width: f32) -> Slot {
+ let Some(grid) = self.grid.as_ref() else {
+ return Slot { x: 0.0, y: 0.0, w: 0.0, span: SlotSpan::Nowhere };
+ };
+ let num_cols = grid.col_heights.len();
+ let n = (((width + grid.gap) / (grid.col_width + grid.gap)).round() as usize).clamp(1, num_cols);
+ if n >= num_cols {
+ Slot { x: grid.left, y: grid.max_height(), w: grid.width, span: SlotSpan::All }
+ } else if n == 1 {
+ let col = grid.next_column();
+ Slot { x: grid.col_lefts[col], y: grid.col_heights[col], w: grid.col_width, span: SlotSpan::One(col) }
+ } else {
+ let mut first = 0;
+ let mut lowest = f32::MAX;
+ for c in 0..=(num_cols - n) {
+ let fill = grid.col_heights[c..c + n].iter().fold(0.0f32, |a, &h| a.max(h));
+ if fill < lowest {
+ lowest = fill;
+ first = c;
+ }
+ }
+ let w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
+ Slot { x: grid.col_lefts[first], y: lowest, w, span: SlotSpan::Run { first, n } }
+ }
+ }
+
+ /// The section placed at `slot` came to `height`: its columns now end a gap below it.
+ pub fn commit(&mut self, slot: Slot, height: f32) {
+ let Some(grid) = self.grid.as_mut() else { return };
+ match slot.span {
+ SlotSpan::All => grid.col_heights.fill(slot.y + height + grid.gap),
+ SlotSpan::One(col) => grid.col_heights[col] += height + grid.gap,
+ SlotSpan::Run { first, n } => {
+ for h in &mut grid.col_heights[first..first + n] {
+ *h = slot.y + height + grid.gap;
+ }
+ }
+ SlotSpan::Nowhere => {}
+ }
+ }
+}
+
+/// Vertical slack for a section's content clip. The clip exists to stop
+/// content escaping its section SIDEWAYS, which is the axis a section's width
+/// actually fixes; a section's height is only known once its content has been
+/// placed, so bounding that axis too would risk cutting content off rather
+/// than keeping it in. Deliberately far larger than any section.
+const SECTION_CLIP_SLACK: f32 = 100_000.0;
+
+/// A page's sections, placed by a [`PageFlow`] and drawn once each.
+pub struct PageLayoutBuilder<'a, P> {
+ pub flow: &'a mut PageFlow,
+ pub cx: f32,
+ pub cy: f32,
+ pub cw: f32,
+ pub ch: f32,
+ pub section_width: f32,
+ pub idx: usize,
+ _phantom: std::marker::PhantomData<P>,
+}
+
+impl<'a, P: RenderTarget> PageLayoutBuilder<'a, P> {
+ /// Lay `flow` over the page's content rect. `section_width` is a section's width when the
+ /// flow has none to offer (it always does once laid over a page).
+ pub fn new(flow: &'a mut PageFlow, cx: f32, cy: f32, cw: f32, ch: f32, section_width: f32) -> Self {
+ flow.init(cx, cy, cw, ch);
+ Self { flow, cx, cy, cw, ch, section_width, idx: 0, _phantom: std::marker::PhantomData }
+ }
+
+ /// At most `count` columns (see [`PageFlow::set_section_count`]).
+ pub fn with_section_count(self, count: usize) -> Self {
+ self.flow.set_section_count(count);
+ self.flow.init(self.cx, self.cy, self.cw, self.ch);
+ self
+ }
+
+ /// A section one column wide.
+ pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, render_fn: F)
+ where
+ F: FnOnce(&mut SectionContext<'_, P>),
+ {
+ let width = self.flow.column_width().unwrap_or(self.section_width);
+ self.add_section_with_width(final_pc, width, label, focused, render_fn);
+ }
+
+ /// A section `width` wide: as many columns as that rounds to.
+ pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, render_fn: F)
+ where
+ F: FnOnce(&mut SectionContext<'_, P>),
+ {
+ let slot = self.flow.place(width);
+ let mut sec = SectionContext::new(final_pc, slot.x, slot.y, slot.w, label, focused, false);
+ let (clip_x, clip_w) = (sec.content_left(), sec.content_width());
+ sec.pc.push_clip_rect(clip_x, slot.y - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
+ render_fn(&mut sec);
+ sec.pc.pop_clip_rect();
+ let bottom = sec.finish();
+ self.flow.commit(slot, bottom - slot.y);
+ self.idx += 1;
+ }
+
+ /// A section `span` columns wide.
+ pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
+ where
+ F: FnOnce(&mut SectionContext<'_, P>),
+ {
+ let col_width = self.flow.column_width().unwrap_or(self.section_width);
+ let gap = self.flow.gap();
+ let width = span as f32 * col_width + (span - 1) as f32 * gap;
+ self.add_section_with_width(final_pc, width, label, focused, render_fn);
+ }
+}
+
+pub struct SectionContext<'a, P> {
+ pub pc: &'a mut P,
+ pub left: f32,
+ pub top: f32,
+ pub content_y: f32,
+ pub cw: f32,
+ pub label_width: f32,
+ pub label_x: f32,
+ /// Whether the host renders sections as sunken wells (`section_relief_style`).
+ pub relief_style: bool,
+ /// The title tab box (x, y, w, h) when the host's relief styling laid the
+ /// label out left-aligned — `finish` offers it with the section carve.
+ /// None under relief styling means a label-less section: the well carves
+ /// tabless, flush with the allocation top.
+ pub relief_tab: Option<(f32, f32, f32, f32)>,
+ pub focused: bool,
+ pub is_child: bool,
+ pub grid: Grid,
+ pub last_col: usize,
+ pub content_start_y: f32,
+ pub row_gap: f32,
+}
+
+impl<'a, P: RenderTarget> SectionContext<'a, P> {
+ pub const DEFAULT_MARGIN_X: f32 = 12.0;
+ pub const DEFAULT_ROW_GAP: f32 = 8.0;
+
+ fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
+ estimate_label_width_helper(label, font_size, font_fam)
+ }
+
+ pub fn padding(&self) -> f32 {
+ section_padding()
+ }
+
+ pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
+ let font_setting = if is_child {
+ nested_section_label_font()
+ } else {
+ section_label_font()
+ };
+ let (font_fam, font_size_opt) = parse_font_string(&font_setting);
+ let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
+ let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
+ let label_width = Self::estimate_label_width(label, font_size, &font_fam);
+ let relief_style = pc.section_relief_style();
+ let label_x = if is_child {
+ let base_x = match nested_section_label_alignment() {
+ 0 => left + 12.0,
+ 1 => left + (cw - label_width) / 2.0,
+ 2 => left + cw - 12.0 - label_width,
+ _ => left + 12.0,
+ };
+ base_x + nested_section_label_offset()
+ } else if relief_style {
+ // Sunken style: the title sits in a tab flush with the well's left
+ // edge (the designer look), not centered on the border.
+ left + 12.0
+ } else {
+ left + (cw - label_width) / 2.0
+ };
+ // Under relief styling the well fills the whole allocated rect (so the
+ // page's gaps and margins are the visual gaps) — the tab tops the
+ // allocation and the label centers inside it.
+ let label_y = if relief_style { top + 4.0 } else { top };
+ pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
+
+ // The tab wraps the label, flush on the well's top-left corner; clamped
+ // so off-default child alignments can't push it outside the well.
+ let relief_tab = if relief_style && label_width > 0.0 {
+ let tab_x = (label_x - 12.0).max(left);
+ Some((tab_x, top, label_width + 24.0, font_size + 10.0))
+ } else {
+ None
+ };
+
+ let pad = section_padding();
+ let margin_x = 2.0 * pad + 12.0;
+ let usable_w = (cw - 2.0 * margin_x).max(1.0);
+ let min_col_width = 130.0;
+ let gap = 8.0;
+ let max_cols = if is_child {
+ 1
+ } else {
+ ((usable_w + gap) / (min_col_width + gap)).floor().clamp(1.0, 2.0) as usize
+ };
+ // Under relief styling the content stands off the well's top wall by
+ // the same inset it keeps from the side walls (`margin_x`, which is
+ // also what `finish` leaves below it), so a well reads as one even
+ // frame. The outline style's `pad + 19` predates the title tab: under
+ // relief the tab alone is `font_size + 10` tall, which left the first
+ // line touching the well's top wall at the default padding while the
+ // sides kept 20px and more.
+ let content_start_y = if relief_style {
+ relief_tab.map(|t| t.1 + t.3).unwrap_or(top) + margin_x
+ } else {
+ top + pad + 19.0
+ };
+ let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
+
+ Self {
+ pc,
+ left,
+ top,
+ content_y: content_start_y,
+ cw,
+ label_width,
+ label_x,
+ relief_style,
+ relief_tab,
+ focused,
+ is_child,
+ grid,
+ last_col: usize::MAX,
+ content_start_y,
+ row_gap: Self::DEFAULT_ROW_GAP,
+ }
+ }
+
+ pub fn with_row_gap(mut self, gap: f32) -> Self {
+ self.row_gap = gap;
+ self
+ }
+
+ /// The section's content-box top edge: the body well's top (the tab's
+ /// bottom) under relief styling, the outline's border line otherwise. Lets
+ /// a page place content at an exact inset from the well's walls.
+ pub fn well_top(&self) -> f32 {
+ if self.relief_style {
+ self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
+ } else {
+ self.top + 7.0
+ }
+ }
+
+ pub fn set_row_gap(&mut self, gap: f32) {
+ self.row_gap = gap;
+ }
+
+ /// Horizontal inset of section CONTENT from the section's left edge.
+ ///
+ /// One number, used by every content placer in here — `row_layout`, the
+ /// column `Grid`, `widget`, `VStack` and `ax` (so `text`) — because they
+ /// share a section and have to line up inside it. The section's border is
+ /// drawn at `left + padding()` (see `finish`), so content clears the
+ /// border by `padding() + 12`.
+ pub fn content_margin(&self) -> f32 {
+ 2.0 * self.padding() + 12.0
+ }
+
+ /// Left edge of the content box: where a row, a widget or a `text(_, 12.0,
+ /// ..)` starts.
+ pub fn content_left(&self) -> f32 {
+ self.left + self.content_margin()
+ }
+
+ /// Width of the content box — the section's width less the inset on both
+ /// sides. Nothing a section draws should extend past `content_left() +
+ /// content_width()`.
+ pub fn content_width(&self) -> f32 {
+ (self.cw - 2.0 * self.content_margin()).max(0.0)
+ }
+
+ /// `x_off` px into the content box's coordinate space, where 12.0 is the
+ /// content's own left edge — the offset 46 of the ~55 call sites in the
+ /// tree already pass, and the one that lines text up with the buttons and
+ /// widgets beside it.
+ ///
+ /// This used to add `padding()` only when `x_off >= 12.0`, which made the
+ /// mapping DISCONTINUOUS: asking for 11 instead of 12 moved the text 5px
+ /// LEFT rather than 1px, and silently dropped it out of alignment with
+ /// every row in the same section. `cce-mail` and two others sit on the
+ /// wrong side of that cliff today.
+ pub fn ax(&self, x_off: f32) -> f32 {
+ self.left + 2.0 * self.padding() + x_off
+ }
+
+ pub fn ay(&self) -> f32 {
+ self.content_y
+ }
+
+ pub fn spacing(&mut self, dy: f32) {
+ if self.grid.col_heights.len() >= 2 {
+ if self.last_col == usize::MAX {
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ } else if self.last_col < self.grid.col_heights.len() {
+ self.grid.col_heights[self.last_col] += dy;
+ }
+ self.content_y = self.grid.max_height();
+ } else {
+ self.content_y += dy;
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ }
+ }
+
+ pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+ let mut y = self.content_y + y_off;
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+ let x = self.ax(x_off);
+ // Bound it to the content box. A section's text was drawn unbounded,
+ // so a string wider than its section simply kept going — over the
+ // border, over whatever sat to the right, and off the window (the
+ // settings app's GPU names did all three). Rows and widgets have
+ // always been sized to the section; text was the one thing that could
+ // leave it. The vertical band is generous on purpose: it is the
+ // horizontal overrun that has to be cut, and a tight band would
+ // shave descenders.
+ let right = self.content_left() + self.content_width();
+ let bounds = if right > x {
+ Some([x, y - font_size, right, y + 2.0 * font_size])
+ } else {
+ None
+ };
+ self.pc.text_with_bounds(text, x, y, font_size, color, bounds);
+ let new_bottom = y + font_size + 4.0;
+ self.content_y = new_bottom;
+ self.grid.col_heights.fill(new_bottom);
+ }
+
+ pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
+ if let Some(pref) = w.preferred_height() {
+ wh = pref;
+ }
+ let pad = self.padding();
+ let top_room = w.label_strip();
+ let total_h = wh + top_room;
+
+ let name = w.type_name();
+ let span_full = name == "Trackpad"
+ || name == "Canvas"
+ || name == "UsageBar"
+ || name == "ProgressBar"
+ || name == "ButtonStrip"
+ || name == "Spreadsheet"
+ || name == "Graph";
+
+ if span_full {
+ let margin_x = 2.0 * pad + 12.0;
+ let x = self.left + margin_x;
+ let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
+ let mut max_h = self.grid.max_height().max(self.content_y);
+ if max_h > self.content_start_y {
+ max_h += self.row_gap;
+ }
+ let y = max_h;
+
+ w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
+ render_widget(self.pc, w, x, y, clamped_w, total_h, ctx);
+
+ let new_bottom = y + total_h;
+ self.content_y = new_bottom;
+ self.grid.col_heights.fill(new_bottom);
+ } else {
+ let max_h = self.grid.max_height();
+ if self.content_y > max_h {
+ self.grid.col_heights.fill(self.content_y);
+ }
+
+ let col = self.grid.next_column();
+ self.last_col = col;
+ let x = self.grid.col_lefts[col];
+ let mut y = self.grid.col_heights[col];
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+
+ let aligned_x = x;
+ let aligned_w = self.grid.col_width;
+
+ w.set_row_rect(aligned_x, aligned_w);
+ render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
+ self.grid.col_heights[col] = y + total_h;
+ self.content_y = self.grid.max_height();
+ }
+ }
+
+ pub fn widget_full<T: WidgetHost + 'static>(&mut self, w: &mut T, wh: f32, ctx: &mut UiContext) {
+ let x_off = 12.0;
+ let ww = self.cw - 2.0 * (self.padding() + x_off); // cw - 40.0
+ self.widget(w, x_off, ww, wh, ctx);
+ }
+
+ pub fn separator(&mut self) {
+ let pad = self.padding();
+ let x = self.ax(pad);
+ let max_h = self.grid.max_height().max(self.content_y);
+ let y = max_h;
+ self.pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
+ self.content_y = max_h + 8.0;
+ self.grid.col_heights.fill(self.content_y);
+ }
+
+ pub fn rect(&mut self, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ let max_h = self.grid.max_height().max(self.content_y);
+ self.pc.rect(color, self.ax(x_off), max_h, w, h);
+ self.content_y = max_h + h;
+ self.grid.col_heights.fill(self.content_y);
+ }
+
+ pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
+ let margin_x = self.content_margin();
+ let usable_w = self.content_width();
+ if count == 0 {
+ return Vec::new();
+ }
+ let total_gap = gap * (count - 1) as f32;
+ let col_w = (usable_w - total_gap).max(0.0) / count as f32;
+
+ let mut cols = Vec::with_capacity(count);
+ for i in 0..count {
+ let x = self.left + margin_x + i as f32 * (col_w + gap);
+ cols.push((x, col_w));
+ }
+ cols
+ }
+
+ /// A row whose columns are sized to what goes IN them: each gets the width
+ /// it asked for in `needs`, and whatever is left over is shared equally.
+ ///
+ /// `row_layout` splits a row evenly and knows nothing about content, so it
+ /// hands "Reboot" and "Hibernate" the same width — one floats in slack
+ /// while the other is cut off, which is what a row of mismatched labels
+ /// looks like. Sharing the SLACK equally rather than sizing proportionally
+ /// is deliberate: proportional widths would make a two-character label a
+ /// sliver, where what is wanted is "everyone fits, then everyone gets the
+ /// same bonus".
+ ///
+ /// When the needs do not fit, every column is scaled by the same factor, so
+ /// the row still cannot overflow its section and the shortfall is shared
+ /// rather than landing entirely on the last column.
+ pub fn row_layout_for(&self, needs: &[f32], gap: f32) -> Vec<(f32, f32)> {
+ let count = needs.len();
+ if count == 0 {
+ return Vec::new();
+ }
+ let total_gap = gap * (count - 1) as f32;
+ let room = (self.content_width() - total_gap).max(0.0);
+ let total_need: f32 = needs.iter().map(|n| n.max(0.0)).sum();
+
+ let widths: Vec<f32> = if total_need <= room {
+ let extra = (room - total_need) / count as f32;
+ needs.iter().map(|n| n.max(0.0) + extra).collect()
+ } else if total_need > 0.0 {
+ let scale = room / total_need;
+ needs.iter().map(|n| n.max(0.0) * scale).collect()
+ } else {
+ vec![room / count as f32; count]
+ };
+
+ let mut cols = Vec::with_capacity(count);
+ let mut x = self.content_left();
+ for w in widths {
+ cols.push((x, w));
+ x += w + gap;
+ }
+ cols
+ }
+
+
+ pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(usize, f32, f32),
+ {
+ let max_h = self.grid.max_height().max(self.content_y);
+ self.grid.col_heights.fill(max_h);
+ self.content_y = max_h;
+
+ let cols = self.row_layout(count, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ f(i, x, w);
+ }
+ self.content_y += h;
+
+ self.grid.col_heights.fill(self.content_y);
+ }
+
+ pub fn vstack(&mut self, spacing: f32) -> VStack<'_, 'a, P> {
+ VStack {
+ context: self,
+ spacing,
+ }
+ }
+
+ pub fn add_section<F>(&mut self, label: &str, focused: bool, mut render_fn: F)
+ where
+ F: FnMut(&mut SectionContext<'_, P>),
+ {
+ let pad = self.padding();
+ let (left, top, cw, is_side_by_side) = if self.grid.col_heights.len() >= 2 {
+ let col = self.grid.next_column();
+ self.last_col = col;
+ let x = self.grid.col_lefts[col];
+ let mut y = self.grid.col_heights[col];
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+ (x, y, self.grid.col_width, true)
+ } else {
+ let left = self.ax(0.0) + pad;
+ let mut max_h = self.grid.max_height().max(self.content_y);
+ if max_h > self.content_start_y {
+ max_h += self.row_gap;
+ }
+ let top = max_h;
+ let cw = self.cw - 2.0 * pad;
+ (left, top, cw, false)
+ };
+
+ let mut sub_ctx = SectionContext::new(self.pc, left, top, cw, label, focused, true);
+ render_fn(&mut sub_ctx);
+ let new_bottom = sub_ctx.finish();
+
+ if is_side_by_side {
+ let col = self.last_col;
+ self.grid.col_heights[col] = new_bottom;
+ self.content_y = self.grid.max_height();
+ } else {
+ self.content_y = new_bottom;
+ self.grid.col_heights.fill(new_bottom);
+ }
+ }
+
+ pub fn finish(self) -> f32 {
+ let border: [f32; 4] = if self.is_child {
+ if self.focused {
+ [0.22, 0.38, 0.24, 1.0]
+ } else {
+ [0.18, 0.18, 0.25, 1.0]
+ }
+ } else {
+ if self.focused {
+ [0.30, 0.50, 0.32, 1.0] // Focused green
+ } else {
+ [0.25, 0.25, 0.35, 1.0] // Default gray
+ }
+ };
+ let pad = self.padding();
+ let x = self.left + pad;
+ let y = self.top + 7.0;
+ let w = self.cw - 2.0 * pad;
+ let h = self.content_y - y;
+ // Under relief the well's walls are the allocation's edges, so the
+ // floor below the content matches the inset beside and above it (see
+ // `new`). The outline frame sits `pad` in from its allocation and
+ // keeps its own slack.
+ let extra_bottom = if self.relief_style { self.content_margin() } else { pad + 12.0 };
+ let bottom = y + h + extra_bottom;
+
+ if self.relief_style {
+ // Sunken style: the well spans the full allocated rect — body top
+ // edge at the tab's bottom (the tab is flush ON the body, the
+ // designer union shape; label-less sections carve tabless from the
+ // allocation top), walls on the allocation's edges, and no
+ // trailing slack so the layout gap IS the visual gap.
+ let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
+ let frame = SectionFrame {
+ x: self.left,
+ y: body_y,
+ w: self.cw,
+ h: bottom - body_y,
+ tab: self.relief_tab,
+ focused: self.focused,
+ is_child: self.is_child,
+ };
+ if self.pc.section_relief(&frame) {
+ return self.content_y + extra_bottom;
+ }
+ }
+
+ let left_edge = x;
+ let right_edge = x + w;
+ if self.label_width > 0.0 {
+ let label_x = self.label_x;
+ let gap_margin = 6.0;
+ let gap_start = label_x - gap_margin;
+ let gap_end = label_x + self.label_width + gap_margin;
+ if gap_start > left_edge {
+ self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
+ }
+ if right_edge > gap_end {
+ self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
+ }
+ } else {
+ self.pc.rect(border, left_edge, y, w, 1.0);
+ }
+
+ self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
+ self.pc.rect(border, x, y, 1.0, h + extra_bottom);
+ self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
+ self.content_y + extra_bottom + 8.0
+ }
+}
+
+
+pub struct VStack<'b, 'a, P> {
+ pub context: &'b mut SectionContext<'a, P>,
+ pub spacing: f32,
+}
+
+impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
+ pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
+ let pad = self.context.padding();
+ let margin_x = 2.0 * pad + 12.0;
+ let x = self.context.left + margin_x;
+ let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
+
+ let mut max_h = self.context.grid.max_height().max(self.context.content_y);
+ if max_h > self.context.content_start_y {
+ max_h += self.context.row_gap;
+ }
+ let y = max_h;
+
+ let pref_h = w.preferred_height().unwrap_or(wh);
+ let top_room = w.label_strip();
+ let total_h = pref_h + top_room;
+
+ w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
+ render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
+
+ let new_bottom = y + total_h;
+ self.context.content_y = new_bottom;
+ self.context.grid.col_heights.fill(new_bottom);
+ self.context.spacing(self.spacing);
+ }
+
+ /// [`add_row`](Self::add_row) with per-column widths from `needs` — see
+ /// [`SectionContext::row_layout_for`]. For a row of buttons, `needs` is
+ /// each label's measured width plus the plate's own inset.
+ pub fn add_row_for<F>(&mut self, needs: &[f32], gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
+ {
+ let max_h = self.context.grid.max_height().max(self.context.content_y);
+ self.context.grid.col_heights.fill(max_h);
+ self.context.content_y = max_h;
+
+ let cols = self.context.row_layout_for(needs, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ self.context.content_y = max_h;
+ f(self.context, i, x, w);
+ }
+
+ let new_bottom = max_h + h;
+ self.context.content_y = new_bottom;
+ self.context.grid.col_heights.fill(new_bottom);
+ self.context.spacing(self.spacing);
+ }
+
+ pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
+ {
+ let max_h = self.context.grid.max_height().max(self.context.content_y);
+ self.context.grid.col_heights.fill(max_h);
+ self.context.content_y = max_h;
+
+ let cols = self.context.row_layout(count, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ self.context.content_y = max_h;
+ f(self.context, i, x, w);
+ }
+
+ let new_bottom = max_h + h;
+ self.context.content_y = new_bottom;
+ self.context.grid.col_heights.fill(new_bottom);
+ self.context.spacing(self.spacing);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Where a section goes depends on the sections before it and never on its own height:
+ /// one column wide it takes the shortest column, as wide as the page it goes below
+ /// everything, and a run takes the columns whose lowest fill is highest up.
+ #[test]
+ fn a_section_is_placed_before_it_is_drawn() {
+ let (min, gap) = (grid_min_col_width(), grid_gap());
+ let width = 3.0 * min + 2.0 * gap + 1.0;
+ let mut flow = PageFlow::new();
+ flow.init(10.0, 20.0, width, 800.0);
+ let col = flow.column_width().unwrap();
+
+ let a = flow.place(col);
+ flow.commit(a, 100.0);
+ let b = flow.place(col);
+ flow.commit(b, 50.0);
+ let c = flow.place(col);
+ flow.commit(c, 70.0);
+ assert_eq!((a.x, a.y), (10.0, 20.0));
+ assert_eq!((b.y, c.y), (20.0, 20.0), "the first three stand side by side");
+ assert!(a.x < b.x && b.x < c.x);
+
+ let d = flow.place(col);
+ assert_eq!((d.x, d.y), (b.x, 20.0 + 50.0 + gap), "under the shortest column");
+ flow.commit(d, 10.0);
+
+ // Columns 1 and 2 reach 20+50+gap+10+gap and 20+70+gap; 0 and 1 reach further.
+ let pair = flow.place(2.0 * col + gap);
+ assert_eq!((pair.x, pair.y), (b.x, (20.0 + 50.0 + gap + 10.0 + gap).max(20.0 + 70.0 + gap)));
+ assert_eq!(pair.w, 2.0 * col + gap);
+ flow.commit(pair, 30.0);
+
+ let wide = flow.place(width);
+ assert_eq!((wide.x, wide.w), (10.0, width));
+ assert_eq!(wide.y, pair.y + 30.0 + gap, "below everything");
+ }
+
+ /// A page with fewer sections than fit across leaves no column empty.
+ #[test]
+ fn the_section_count_caps_the_columns() {
+ let width = 3.0 * grid_min_col_width() + 2.0 * grid_gap() + 1.0;
+ let mut flow = PageFlow::new();
+ flow.set_section_count(1);
+ flow.init(0.0, 0.0, width, 400.0);
+ assert_eq!(flow.column_width(), Some(width));
+ }
+
+ /// The builder draws each section once, in place, and the next one starts below it.
+ #[test]
+ fn each_section_is_drawn_once() {
+ let mut flow = PageFlow::new();
+ let mut pc = PopoverCollector::new();
+ let mut builder = PageLayoutBuilder::new(&mut flow, 0.0, 0.0, 600.0, 400.0, 300.0).with_section_count(1);
+ let mut tops = Vec::new();
+ for _ in 0..2 {
+ builder.add_section(&mut pc, "Section", false, |sec| {
+ tops.push(sec.top);
+ sec.spacing(40.0);
+ });
+ }
+ assert_eq!(tops.len(), 2, "one call per section");
+ assert!(tops[1] > tops[0] + 40.0, "{tops:?}");
+ }
+}
diff --git a/src/scene/layout.rs b/src/scene/layout.rs
index c56d197..5ab4082 100644
--- a/src/scene/layout.rs
+++ b/src/scene/layout.rs
@@ -1,8 +1,9 @@
//! Hand-rolled two-phase layout engine — Phase 2 of the core rebuild.
//!
-//! The legacy toolkit computes layout *inline during paint*, smeared across `LayoutStrategy`, a
+//! The legacy toolkit computed layout *inline during paint*, smeared across `LayoutStrategy`, a
//! child-driven `allocate` bump-cursor, and hand-written `set_rect` calls with absolute
-//! coordinates — with `SectionContext` literally rendering twice to measure. There is no layout
+//! coordinates — with `SectionContext` literally rendering twice to measure (both gone since
+//! 2026-10-08). There is no layout
//! pass that is independent of paint, which is why animated/relayout-able UI is hard and why a
//! widget can end up sized by two different owners (the breadcrumb bug).
//!
diff --git a/src/widget/container/container_layout.rs b/src/widget/container/container_layout.rs
index 88ebe85..52421f5 100644
--- a/src/widget/container/container_layout.rs
+++ b/src/widget/container/container_layout.rs
@@ -1,7 +1,13 @@
use crate::widget::*;
use crate::context::UiContext;
-pub trait ContainerLayout: crate::layout::LayoutStrategy {
+/// How a container places its children: a rect in, every child's rect set, the height used
+/// out. The gallery's Layout dropdown shows each.
+pub trait ContainerLayout: std::fmt::Debug {
+ /// Place `children` in the rect at (`x`, `y`), `w` by `h`; returns the height they take.
+ fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32;
+ /// What `children` need under `constraints`.
+ fn measure(&self, constraints: LayoutConstraints, children: &[*mut (dyn WidgetHost + 'static)], ctx: &UiContext) -> Size;
fn box_clone_container(&self) -> Box<dyn ContainerLayout>;
}
@@ -11,26 +17,11 @@ impl Clone for Box<dyn ContainerLayout> {
}
}
+/// Every child over the whole rect.
#[derive(Debug, Clone, Copy, Default)]
-pub struct OverlayLayout {
- left: f32,
- top: f32,
- width: f32,
- height: f32,
-}
-
-impl crate::layout::LayoutStrategy for OverlayLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.height = height;
- }
-
- fn allocate(&mut self, _ww: f32, _wh: f32) -> (f32, f32, f32, f32) {
- (self.left, self.top, self.width, self.height)
- }
+pub struct OverlayLayout;
+impl ContainerLayout for OverlayLayout {
fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn WidgetHost + 'static)], _ctx: &mut UiContext) -> f32 {
for &child_ptr in children {
unsafe {
@@ -56,60 +47,11 @@ impl crate::layout::LayoutStrategy for OverlayLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for OverlayLayout {
- fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
- Box::new(*self)
- }
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq)]
-pub struct ManualLayout {
- left: f32,
- top: f32,
- width: f32,
- height: f32,
-}
-
-impl crate::layout::LayoutStrategy for ManualLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.height = height;
- }
-
- fn allocate(&mut self, _ww: f32, _wh: f32) -> (f32, f32, f32, f32) {
- (self.left, self.top, self.width, self.height)
- }
-
- fn layout(&self, _x: f32, _y: f32, _w: f32, _h: f32, _children: &[*mut (dyn WidgetHost + 'static)], _ctx: &mut UiContext) -> f32 {
- self.height
- }
-
- fn measure(&self, constraints: LayoutConstraints, _children: &[*mut (dyn WidgetHost + 'static)], _ctx: &UiContext) -> Size {
- Size {
- width: self.width.clamp(constraints.min_width, constraints.max_width),
- height: self.height.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for ManualLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(*self)
}
}
-
/// A child's content height for a strategy to allot: its preferred (intrinsic)
/// height, else its landed rect less the detached-label strip.
pub fn content_height(child: &dyn WidgetHost) -> f32 {
@@ -131,8 +73,6 @@ pub struct VerticalLayout {
pub padding_x: f32,
pub padding_y: f32,
pub spacing: f32,
- left: f32,
- current_y: f32,
}
impl Default for VerticalLayout {
@@ -141,29 +81,11 @@ impl Default for VerticalLayout {
padding_x: 0.0,
padding_y: 0.0,
spacing: crate::layout::control_gap(),
- left: 0.0,
- current_y: 0.0,
}
}
}
-impl crate::layout::LayoutStrategy for VerticalLayout {
- fn init(&mut self, left: f32, top: f32, _width: f32, _height: f32) {
- self.left = left;
- self.current_y = top + self.padding_y;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- let x = self.left + self.padding_x;
- let y = self.current_y;
- self.current_y += wh + self.spacing;
- (x, y, ww, wh)
- }
-
- fn get_gap(&self) -> f32 {
- self.spacing
- }
-
+impl ContainerLayout for VerticalLayout {
fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let left_x = x + self.padding_x;
let available_w = (w - 2.0 * self.padding_x).max(1.0);
@@ -211,12 +133,6 @@ impl crate::layout::LayoutStrategy for VerticalLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for VerticalLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(*self)
}
@@ -228,44 +144,9 @@ pub struct GridLayout {
pub gap: f32,
pub padding_x: f32,
pub padding_y: f32,
- pub grid: Option<crate::layout::Grid>,
}
-impl crate::layout::LayoutStrategy for GridLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- let count = self.columns.max(1);
- let usable_w = (width - 2.0 * self.padding_x).max(1.0);
- let grid = crate::layout::Grid::new(
- left + self.padding_x,
- top + self.padding_y,
- usable_w,
- usable_w / count as f32,
- self.gap,
- count,
- );
- self.grid = Some(grid);
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref mut grid) = self.grid {
- let col = grid.next_column();
- let x = grid.col_lefts[col];
- let y = grid.col_heights[col];
- grid.col_heights[col] += wh + grid.gap;
- (x, y, grid.col_width, wh)
- } else {
- (0.0, 0.0, ww, wh)
- }
- }
-
- fn get_column_width(&self) -> Option<f32> {
- self.grid.as_ref().map(|g| g.col_width)
- }
-
- fn get_gap(&self) -> f32 {
- self.gap
- }
-
+impl ContainerLayout for GridLayout {
fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let count = children.len();
if count == 0 {
@@ -342,12 +223,6 @@ impl crate::layout::LayoutStrategy for GridLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-impl ContainerLayout for GridLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(self.clone())
}
@@ -359,44 +234,9 @@ pub struct AdaptiveGridLayout {
pub gap: f32,
pub padding_x: f32,
pub padding_y: f32,
- pub grid: Option<crate::layout::Grid>,
}
-impl crate::layout::LayoutStrategy for AdaptiveGridLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- let usable_w = (width - 2.0 * self.padding_x).max(1.0);
- let cols = (((usable_w + self.gap) / (self.min_col_width + self.gap)).floor().max(1.0)) as usize;
- let grid = crate::layout::Grid::new(
- left + self.padding_x,
- top + self.padding_y,
- usable_w,
- self.min_col_width,
- self.gap,
- cols,
- );
- self.grid = Some(grid);
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref mut grid) = self.grid {
- let col = grid.next_column();
- let x = grid.col_lefts[col];
- let y = grid.col_heights[col];
- grid.col_heights[col] += wh + grid.gap;
- (x, y, grid.col_width, wh)
- } else {
- (0.0, 0.0, ww, wh)
- }
- }
-
- fn get_column_width(&self) -> Option<f32> {
- self.grid.as_ref().map(|g| g.col_width)
- }
-
- fn get_gap(&self) -> f32 {
- self.gap
- }
-
+impl ContainerLayout for AdaptiveGridLayout {
fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let usable_w = (w - 2.0 * self.padding_x).max(1.0);
let cols = (((usable_w + self.gap) / (self.min_col_width + self.gap)).floor().max(1.0)) as usize;
@@ -405,7 +245,6 @@ impl crate::layout::LayoutStrategy for AdaptiveGridLayout {
gap: self.gap,
padding_x: self.padding_x,
padding_y: self.padding_y,
- grid: None,
};
grid.layout(x, y, w, h, children, ctx)
}
@@ -418,17 +257,10 @@ impl crate::layout::LayoutStrategy for AdaptiveGridLayout {
gap: self.gap,
padding_x: self.padding_x,
padding_y: self.padding_y,
- grid: None,
};
grid.measure(constraints, children, ctx)
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-impl ContainerLayout for AdaptiveGridLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(self.clone())
}
@@ -451,11 +283,7 @@ impl Default for ColumnsLayout {
}
}
-impl crate::layout::LayoutStrategy for ColumnsLayout {
- fn init(&mut self, _left: f32, _top: f32, _width: f32, _height: f32) {}
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) { (0.0, 0.0, ww, wh) }
- fn get_gap(&self) -> f32 { self.spacing }
-
+impl ContainerLayout for ColumnsLayout {
fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let count = children.len();
if count == 0 {
@@ -503,12 +331,6 @@ impl crate::layout::LayoutStrategy for ColumnsLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for ColumnsLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(*self)
}
@@ -596,11 +418,7 @@ impl Packer {
}
}
-impl crate::layout::LayoutStrategy for MosaicLayout {
- fn init(&mut self, _left: f32, _top: f32, _width: f32, _height: f32) {}
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) { (0.0, 0.0, ww, wh) }
- fn get_gap(&self) -> f32 { self.gap }
-
+impl ContainerLayout for MosaicLayout {
fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let count = children.len();
if count == 0 {
@@ -657,12 +475,6 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for MosaicLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(*self)
}
@@ -685,11 +497,7 @@ impl Default for ReverseMosaicLayout {
}
}
-impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
- fn init(&mut self, _left: f32, _top: f32, _width: f32, _height: f32) {}
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) { (0.0, 0.0, ww, wh) }
- fn get_gap(&self) -> f32 { self.gap }
-
+impl ContainerLayout for ReverseMosaicLayout {
fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let count = children.len();
if count == 0 {
@@ -766,13 +574,8 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
}
}
- fn box_clone(&self) -> Box<dyn crate::layout::LayoutStrategy> {
- Box::new(*self)
- }
-}
-
-impl ContainerLayout for ReverseMosaicLayout {
fn box_clone_container(&self) -> Box<dyn ContainerLayout> {
Box::new(*self)
}
}
+
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index caaa728..e2f64d1 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -10,7 +10,7 @@ pub mod treelist;
pub mod dialog;
pub mod group;
-pub use container_layout::{ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout, ColumnsLayout, MosaicLayout, ReverseMosaicLayout};
+pub use container_layout::{ContainerLayout, OverlayLayout, VerticalLayout, GridLayout, AdaptiveGridLayout, ColumnsLayout, MosaicLayout, ReverseMosaicLayout};
pub use content_bg::ContentBg;
pub use parameters_bg::ParametersBg;
pub use menu::MenuBar;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 0f78eca..48004f0 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -695,7 +695,7 @@ pub use self::input::{
};
pub use self::container::{
Dialog, Group, GroupFrame,
- ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
+ ContainerLayout, OverlayLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
ContentBg, ParametersBg,
ScrollBox, MenuBar, SheetColumn, Spreadsheet, Breadcrumb,