GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(layout): a section's contents are a Form on scene::layout; the cursor is gone
A page asks its section for a Form (SectionContext::form), declares what goes
in it -- retained widgets (widget, widget_w), text (text, text_fill, lines),
rows and columns, blocks of text lines with no gap, rules, space, pieces it
paints itself (draw) and one that takes the rest of the page (fill, with
Form::fill_height) -- and hands it back (SectionContext::place), which
solves the tree across the content box and paints each piece where it
landed, in declaration order. The spacing is the ladder's: the form is a
controls_column, a row a controls_row, both control_gap apart.
SectionContext keeps the frame (title tab, well, finish) and the content
box. The cursor it placed contents with is deleted: VStack, add_row /
add_row_for, row_layout / row_layout_for, row, text, spacing, separator,
rect, widget / widget_full, nested add_section, ax / ay, the row gap and the
hidden one-or-two-column grid widgets fell into by type name. Its tests go
with it; form.rs has its own (a column a control gap apart across the box,
growing cells sharing a row's slack, fill taking the rest, text cut at its
piece's edge). cce-system-interface, its one user, moves in the same push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 22 ++-
src/layout/form.rs | 357 +++++++++++++++++++++++++++++++++++++++++++
src/layout/mod.rs | 301 +------------------------------------
src/layout/section.rs | 409 +++++---------------------------------------------
4 files changed, 413 insertions(+), 676 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f7886fa..b036a53 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1826,7 +1826,7 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `layout/` — the style getters and the code that was filed beside them, one module whose
`mod.rs` re-exports every submodule, so `crate::layout::…` paths are unchanged (split
2026-10-07 from one 7.4k-line file):
- - `src/layout/mod.rs` (~2.6k lines) — the sizing constants and the style getters/setters
+ - `src/layout/mod.rs` (~2.3k 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 modules below.
@@ -1853,9 +1853,9 @@ cce-system-interface) to confirm behavior, not just the test suite.
`render_widget`, `render_popovers`, the carve types that cross it.
- `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:
+ the slot the flow gives it and hands its height back, and `SectionContext` frames a
+ section (title tab, well) around what a page puts in it. 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
@@ -1866,6 +1866,20 @@ cce-system-interface) to confirm behavior, not just the test suite.
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`.
+ - `form.rs` — what goes INSIDE a section, on `scene::layout` (since 2026-10-08): a page
+ asks the section for a `Form` (`SectionContext::form`), declares its contents into it
+ — retained widgets (`widget`, `widget_w`), text (`text`, `text_fill`, `lines`), rows
+ and columns, `block`s of text lines with no gap, `rule`s, `space`, pieces it paints
+ itself (`draw`), and one that takes the rest of the page (`fill`, with
+ `Form::fill_height`) — and hands it back (`SectionContext::place`), which solves the
+ tree across the content box and paints each piece where it landed, in declaration
+ order. The spacing is the ladder's: the form a `controls_column`, a row a
+ `controls_row`, both `control_gap` apart; a page states a size only where a piece has
+ one of its own (a list's height, a button's width). Until the same day a section
+ placed its contents with a cursor (`VStack`, `add_row` / `add_row_for`, `row_layout`,
+ `text`, `spacing`) and a hidden one-or-two-column grid that widgets fell into unless
+ their type name said otherwise, and every page added insets of its own (`+ 14`,
+ `- 28`, `44.0`); the 14 settings pages moved onto the form and the cursor went.
- `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/src/layout/form.rs b/src/layout/form.rs
new file mode 100644
index 0000000..36abb0e
--- /dev/null
+++ b/src/layout/form.rs
@@ -0,0 +1,357 @@
+//! A section's contents as a box-model tree: declared first, solved by `scene::layout`, then
+//! painted where the solver put each piece.
+//!
+//! [`SectionContext`](super::SectionContext) frames a section (its title tab, its well) and
+//! used to place what goes in it with a cursor: every row was handed its y and width, and a
+//! page added insets of its own on top. A [`Form`] replaces the cursor. A page asks the section
+//! for one ([`SectionContext::form`]), declares its contents into it — retained widgets, text
+//! lines, rows of cells, blocks it paints itself — and hands it back
+//! ([`SectionContext::place`]), which lays the tree out across the section's content box,
+//! paints each piece in the order it was declared, and moves the section past it.
+//!
+//! The spacing is the ladder's, never the page's: a form is a [`Style::controls_column`],
+//! a row a [`Style::controls_row`], both spaced by `control_gap`. A page states sizes only
+//! where a piece has a size of its own (a list's height, a button's width).
+
+use crate::scene::arena::{Arena, NodeId};
+use crate::scene::layout::{arrange, measure, CrossAlign, LayoutBox, Length, Rect, Size, Style};
+use crate::widget::{UiContext, WidgetHost, WidgetHostExt};
+
+use super::{render_widget, RenderTarget};
+
+/// What a piece paints once it is placed: the target, its rect, the widget context.
+pub type Draw<'w, P> = Box<dyn FnOnce(&mut P, Rect, &mut UiContext) + 'w>;
+
+/// The tree a page declares a section's contents into. See the module docs.
+pub struct Form<'w, P> {
+ arena: Arena<LayoutBox>,
+ root: NodeId,
+ /// Where the content box starts and how wide it is.
+ x: f32,
+ y: f32,
+ width: f32,
+ /// A height to fill: set by [`Form::fill_height`], so a growing piece (a list that takes
+ /// the rest of the page) has room to grow into.
+ height: Option<f32>,
+ /// The row a widget lights when hovered or focused: the section's span, not the cell's.
+ row_span: (f32, f32),
+ pieces: Vec<(NodeId, Draw<'w, P>)>,
+}
+
+impl<'w, P: RenderTarget + 'w> Form<'w, P> {
+ /// A form across a content box at `(x, y)`, `width` wide; widgets light `row_span`.
+ pub fn new(x: f32, y: f32, width: f32, row_span: (f32, f32)) -> Self {
+ let mut arena = Arena::new();
+ let root = arena.insert(LayoutBox::container(
+ Style::controls_column().cross_align(CrossAlign::Stretch).width(Length::Fixed(width)),
+ ));
+ Form { arena, root, x, y, width, height: None, row_span, pieces: Vec::new() }
+ }
+
+ /// The content box's width — what a page wraps text to.
+ pub fn width(&self) -> f32 {
+ self.width
+ }
+
+ /// Where the form starts.
+ pub fn top(&self) -> f32 {
+ self.y
+ }
+
+ /// Give the form this height to fill, so a piece declared with
+ /// [`Group::fill`] takes what the rest leave of it.
+ pub fn fill_height(&mut self, height: f32) {
+ self.height = Some(height.max(0.0));
+ }
+
+ /// The form's top-level column.
+ pub fn column(&mut self) -> Group<'_, 'w, P> {
+ let node = self.root;
+ Group { form: self, node, axis_row: false }
+ }
+
+ /// Lay the tree out and paint every piece in the order it was declared. Returns the
+ /// bottom of what was placed.
+ pub fn paint(mut self, pc: &mut P, ctx: &mut UiContext) -> f32 {
+ let measured = measure(&mut self.arena, self.root);
+ let height = self.height.unwrap_or(measured.height);
+ arrange(&mut self.arena, self.root, Rect { x: self.x, y: self.y, width: self.width, height });
+ for (node, draw) in self.pieces {
+ let rect = self.arena.value(node).map(|b| b.rect).unwrap_or(Rect::ZERO);
+ draw(pc, rect, ctx);
+ }
+ self.y + height
+ }
+
+ fn add(&mut self, parent: NodeId, style: Style, size: Size, draw: Option<Draw<'w, P>>) -> NodeId {
+ let node = self.arena.insert(LayoutBox::leaf(style, size));
+ self.arena.append_child(parent, node);
+ if let Some(draw) = draw {
+ self.pieces.push((node, draw));
+ }
+ node
+ }
+}
+
+/// A column or a row of a [`Form`], what pieces are declared into.
+pub struct Group<'f, 'w, P> {
+ form: &'f mut Form<'w, P>,
+ node: NodeId,
+ axis_row: bool,
+}
+
+impl<'f, 'w, P: RenderTarget + 'w> Group<'f, 'w, P> {
+ /// The leaf style for a piece of this group: across a column it stretches to the width;
+ /// along a row it takes its own width and grows only when asked.
+ fn leaf_style(&self, grow: bool) -> Style {
+ if grow { Style::default().grow(1.0) } else { Style::default() }
+ }
+
+ /// The span a widget lights when hovered or focused: down a column, the section's row;
+ /// in a row, its own cell (`None`).
+ fn row_span(&self) -> Option<(f32, f32)> {
+ if self.axis_row { None } else { Some(self.form.row_span) }
+ }
+
+ /// A retained widget at its own height (its preferred height, or `fallback`, plus its
+ /// detached label). In a row it shares the row's width with the other growing cells.
+ pub fn widget<T: WidgetHost + 'static>(&mut self, w: &'w mut T, fallback: f32) -> &mut Self {
+ let h = w.preferred_height().unwrap_or(fallback) + w.label_strip();
+ let style = self.leaf_style(self.axis_row);
+ let span = self.row_span();
+ let draw: Draw<'w, P> = Box::new(move |pc, r, ctx| {
+ let (x, width) = span.unwrap_or((r.x, r.width));
+ w.set_row_rect(x, width);
+ render_widget(pc, w, r.x, r.y, r.width, r.height, ctx);
+ });
+ self.form.add(self.node, style, Size::new(0.0, h), Some(draw));
+ self
+ }
+
+ /// A retained widget at a width of its own (a toggle that is not as wide as its row), not
+ /// growing. Its height is as for [`widget`](Self::widget).
+ pub fn widget_w<T: WidgetHost + 'static>(&mut self, w: &'w mut T, width: f32, fallback: f32) -> &mut Self {
+ let h = w.preferred_height().unwrap_or(fallback) + w.label_strip();
+ let span = self.row_span();
+ let draw: Draw<'w, P> = Box::new(move |pc, r, ctx| {
+ let (x, width) = span.unwrap_or((r.x, r.width));
+ w.set_row_rect(x, width);
+ render_widget(pc, w, r.x, r.y, r.width, r.height, ctx);
+ });
+ self.form.add(self.node, Style::default(), Size::new(width, h), Some(draw));
+ self
+ }
+
+ /// A piece the page paints itself, `w` × `h` (in a column the width is the column's;
+ /// in a row `w` is the cell's width, and it grows into the row's slack when `grow`).
+ pub fn draw(&mut self, w: f32, h: f32, grow: bool, draw: impl FnOnce(&mut P, Rect, &mut UiContext) + 'w) -> &mut Self {
+ let style = self.leaf_style(grow);
+ self.form.add(self.node, style, Size::new(w, h), Some(Box::new(draw)));
+ self
+ }
+
+ /// A piece that takes the height the rest of the form leaves (see
+ /// [`Form::fill_height`]), at least `min_h`.
+ pub fn fill(&mut self, min_h: f32, draw: impl FnOnce(&mut P, Rect, &mut UiContext) + 'w) -> &mut Self {
+ let style = Style { min_height: min_h, ..Style::default() }.grow(1.0);
+ self.form.add(self.node, style, Size::new(0.0, min_h), Some(Box::new(draw)));
+ self
+ }
+
+ /// A line of text in the default face, as tall as a section's text line.
+ pub fn text(&mut self, text: impl Into<String>, size: f32, color: [f32; 4]) -> &mut Self {
+ let text = text.into();
+ let w = text_width(&text, size);
+ self.draw(w, line_height(size), false, move |pc, r, _| {
+ pc.text_with_bounds(&text, r.x, r.y, size, color, Some([r.x, r.y - size, r.x + r.width, r.y + 2.0 * size]));
+ })
+ }
+
+ /// A line of text that takes the slack of its row (or the width of its column) and is cut
+ /// at the edge of what it was given — a value beside a label that may run long.
+ pub fn text_fill(&mut self, text: impl Into<String>, size: f32, color: [f32; 4]) -> &mut Self {
+ let text = text.into();
+ self.draw(0.0, line_height(size), true, move |pc, r, _| {
+ pc.text_with_bounds(&text, r.x, r.y, size, color, Some([r.x, r.y - size, r.x + r.width, r.y + 2.0 * size]));
+ })
+ }
+
+ /// Lines of text, one under the next, as one piece.
+ pub fn lines(&mut self, lines: Vec<String>, size: f32, color: [f32; 4]) -> &mut Self {
+ let w = lines.iter().map(|l| text_width(l, size)).fold(0.0, f32::max);
+ let h = line_height(size) * lines.len() as f32;
+ self.draw(w, h, false, move |pc, r, _| {
+ for (i, line) in lines.iter().enumerate() {
+ let y = r.y + i as f32 * line_height(size);
+ pc.text_with_bounds(line, r.x, y, size, color, Some([r.x, y - size, r.x + r.width, y + 2.0 * size]));
+ }
+ })
+ }
+
+ /// A one-pixel rule across the group, parting its zones.
+ pub fn rule(&mut self, color: [f32; 4]) -> &mut Self {
+ self.draw(0.0, 1.0, false, move |pc, r, _| pc.rect(color, r.x, r.y, r.width, 1.0))
+ }
+
+ /// Empty room of the given extent (along a row, a width; down a column, a height),
+ /// growing into the slack when `grow` — what pushes a row's later cells to its end.
+ pub fn space(&mut self, extent: f32, grow: bool) -> &mut Self {
+ let size = if self.axis_row { Size::new(extent, 0.0) } else { Size::new(0.0, extent) };
+ let style = self.leaf_style(grow);
+ self.form.add(self.node, style, size, None);
+ self
+ }
+
+ /// A row of cells, the control gap between them, centred on each other.
+ pub fn row(&mut self, build: impl FnOnce(&mut Group<'_, 'w, P>)) -> &mut Self {
+ self.group(Style::controls_row().cross_align(CrossAlign::Center), true, build)
+ }
+
+ /// Lines of text one under the next with no gap between them, stretched across: a block
+ /// of text, not a column of controls.
+ pub fn block(&mut self, build: impl FnOnce(&mut Group<'_, 'w, P>)) -> &mut Self {
+ self.group(Style::column().cross_align(CrossAlign::Stretch), false, build)
+ }
+
+ /// A column of pieces, the control gap between them, stretched across.
+ pub fn column(&mut self, build: impl FnOnce(&mut Group<'_, 'w, P>)) -> &mut Self {
+ self.group(Style::controls_column().cross_align(CrossAlign::Stretch), false, build)
+ }
+
+ /// A nested group with a style of its own (a grid, a tighter gap).
+ pub fn group(&mut self, style: Style, row: bool, build: impl FnOnce(&mut Group<'_, 'w, P>)) -> &mut Self {
+ let node = self.form.arena.insert(LayoutBox::container(style));
+ self.form.arena.append_child(self.node, node);
+ let mut g = Group { form: self.form, node, axis_row: row };
+ build(&mut g);
+ self
+ }
+
+ /// The width of the form this group is in.
+ pub fn form_width(&self) -> f32 {
+ self.form.width
+ }
+}
+
+/// How tall a section's text line is at `size`: the size and the 4 px a line has always had
+/// below it.
+pub fn line_height(size: f32) -> f32 {
+ size + 4.0
+}
+
+/// The width `text` takes at `size` in the default face, as the renderer shapes it.
+pub fn text_width(text: &str, size: f32) -> f32 {
+ crate::geometry_font_system()
+ .lock()
+ .ok()
+ .and_then(|mut fs| crate::backend::text::shaped_cluster_offsets(&mut fs, text, size, None).last().map(|&(_, total)| total))
+ .unwrap_or(0.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::cell::RefCell;
+ use std::rc::Rc;
+
+ /// A target that keeps the bounds every text was given.
+ #[derive(Default)]
+ struct Texts {
+ bounds: Vec<(String, Option<[f32; 4]>)>,
+ }
+ impl RenderTarget for Texts {
+ fn rect(&mut self, _: [f32; 4], _: f32, _: f32, _: f32, _: f32) {}
+ fn text(&mut self, content: &str, _: f32, _: f32, _: f32, _: [f32; 4]) {
+ self.bounds.push((content.to_string(), None));
+ }
+ fn text_with_bounds(&mut self, content: &str, _: f32, _: f32, _: f32, _: [f32; 4], bounds: Option<[f32; 4]>) {
+ self.bounds.push((content.to_string(), bounds));
+ }
+ }
+
+ type Seen = Rc<RefCell<Vec<Rect>>>;
+
+ /// A piece of the given size that records where it was put.
+ fn piece<'w>(g: &mut Group<'_, 'w, Texts>, seen: &Seen, w: f32, h: f32, grow: bool) {
+ let seen = seen.clone();
+ g.draw(w, h, grow, move |_, r, _| seen.borrow_mut().push(r));
+ }
+
+ /// A form spans its content box, and its pieces stand one control gap apart, in the
+ /// order they were declared; the section moves past the last.
+ #[test]
+ fn a_form_stacks_its_pieces_a_control_gap_apart_across_the_box() {
+ let gap = crate::layout::control_gap();
+ let seen: Seen = Rc::default();
+ let mut form: Form<'_, Texts> = Form::new(10.0, 20.0, 300.0, (0.0, 320.0));
+ {
+ let mut col = form.column();
+ piece(&mut col, &seen, 0.0, 30.0, false);
+ piece(&mut col, &seen, 0.0, 12.0, false);
+ }
+ let bottom = form.paint(&mut Texts::default(), &mut UiContext::new());
+ let r = seen.borrow();
+ assert_eq!((r[0].x, r[0].y, r[0].width, r[0].height), (10.0, 20.0, 300.0, 30.0));
+ assert_eq!((r[1].x, r[1].y, r[1].width), (10.0, 20.0 + 30.0 + gap, 300.0));
+ assert_eq!(bottom, 20.0 + 30.0 + gap + 12.0);
+ }
+
+ /// Cells that grow share a row's slack equally on top of what each asked for — a row of
+ /// buttons sized to their labels — and the row is the form's width, gaps included.
+ #[test]
+ fn a_rows_growing_cells_share_its_slack() {
+ let gap = crate::layout::control_gap();
+ let seen: Seen = Rc::default();
+ let mut form: Form<'_, Texts> = Form::new(0.0, 0.0, 400.0, (0.0, 400.0));
+ form.column().row(|r| {
+ piece(r, &seen, 50.0, 20.0, true);
+ piece(r, &seen, 100.0, 20.0, true);
+ });
+ form.paint(&mut Texts::default(), &mut UiContext::new());
+ let r = seen.borrow();
+ let extra = (400.0 - gap - 150.0) / 2.0;
+ assert!((r[0].width - (50.0 + extra)).abs() < 1e-3, "{r:?}");
+ assert!((r[1].width - (100.0 + extra)).abs() < 1e-3, "{r:?}");
+ assert!((r[1].x + r[1].width - 400.0).abs() < 1e-3, "the row ends at the form's edge");
+ }
+
+ /// Given a height to fill, a fill piece takes what the other pieces leave of it.
+ #[test]
+ fn a_fill_piece_takes_the_rest_of_the_height() {
+ let gap = crate::layout::control_gap();
+ let seen: Seen = Rc::default();
+ let mut form: Form<'_, Texts> = Form::new(0.0, 0.0, 200.0, (0.0, 200.0));
+ form.fill_height(300.0);
+ {
+ let mut col = form.column();
+ piece(&mut col, &seen, 0.0, 40.0, false);
+ let s = seen.clone();
+ col.fill(10.0, move |_, r, _| s.borrow_mut().push(r));
+ }
+ let bottom = form.paint(&mut Texts::default(), &mut UiContext::new());
+ let r = seen.borrow();
+ assert!((r[1].height - (300.0 - 40.0 - gap)).abs() < 1e-3, "{r:?}");
+ assert_eq!(bottom, 300.0);
+ }
+
+ /// Text is cut where its piece ends: a line in a column at the content box's edge, a value
+ /// at its cell's.
+ #[test]
+ fn text_is_cut_at_the_edge_of_its_piece() {
+ let mut form: Form<'_, Texts> = Form::new(10.0, 0.0, 200.0, (0.0, 200.0));
+ {
+ let mut col = form.column();
+ col.text("a line far wider than the box it was put in", 12.0, [1.0; 4]);
+ col.row(|r| {
+ r.draw(60.0, 16.0, false, |_, _, _| {});
+ r.text_fill("a value", 12.0, [1.0; 4]);
+ });
+ }
+ let mut out = Texts::default();
+ form.paint(&mut out, &mut UiContext::new());
+ for (text, bounds) in &out.bounds {
+ let right = bounds.expect("bounded")[2];
+ assert!((right - 210.0).abs() < 1e-3, "{text}: {bounds:?}");
+ }
+ }
+}
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 9597ab0..94ce809 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -22,6 +22,8 @@ mod bridge;
pub use bridge::*;
mod section;
pub use section::*;
+mod form;
+pub use form::{line_height as form_line_height, text_width as form_text_width, Form, Group as FormGroup};
/// The height every text-bearing control falls back to when its own
/// `style.control.<name>.height` is unset: button, toggle (and the checkbox
/// row), dropdown, textbox (and the keybind recorder), spinbox, font selector,
@@ -1768,35 +1770,6 @@ mod tests {
use super::*;
- struct MockRenderTarget {
- rects: Vec<([f32; 4], f32, f32, f32, f32)>,
- }
-
- impl RenderTarget for MockRenderTarget {
- fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
- self.rects.push((color, x, y, w, h));
- }
- fn text(&mut self, _content: &str, _x: f32, _y: f32, _size: f32, _color: [f32; 4]) {}
- }
-
- #[derive(Default)]
- struct ProbeTarget {
- rects: Vec<([f32; 4], f32, f32, f32, f32)>,
- bounded: Vec<(String, f32, f32, Option<[f32; 4]>)>,
- }
-
- impl RenderTarget for ProbeTarget {
- fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
- self.rects.push((color, x, y, w, h));
- }
- fn text(&mut self, content: &str, x: f32, y: f32, _size: f32, _color: [f32; 4]) {
- self.bounded.push((content.to_string(), x, y, None));
- }
- fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, _size: f32, _color: [f32; 4], bounds: Option<[f32; 4]>) {
- self.bounded.push((content.to_string(), x, y, bounds));
- }
- }
-
/// A graph's wires and ports reach a flat host: `render_widget` hands
/// its strokes to `RenderTarget::line` (and arcs and discs to `arc` and
/// `circle`), where it used to drop them, so cce-files' Graph page drew
@@ -1869,121 +1842,10 @@ mod tests {
assert!(pc.0.iter().any(|n| n == "chevron-down"), "the arrow reached the host: {:?}", pc.0);
}
- /// Everything a section places horizontally — text, button rows, widgets,
- /// the column grid — must share ONE inset, or content does not line up
- /// with the content beside it. Text used to sit `padding()` to the left of
- /// every row in the same section.
- #[test]
- fn section_text_and_rows_share_one_inset() {
- let mut pc = ProbeTarget::default();
- let (left, cw) = (100.0f32, 320.0f32);
- let sec: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc, left, 50.0, cw, "Probe", false, false);
-
- let cols = sec.row_layout(2, 8.0);
- assert_eq!(sec.ax(12.0), cols[0].0, "text at the conventional 12.0 must start where a row starts");
- assert_eq!(sec.ax(12.0), sec.content_left());
-
- // Symmetric: the right-hand inset from the section border matches the
- // left-hand one.
- let pad = sec.padding();
- let (border_l, border_r) = (left + pad, left + cw - pad);
- let row_right = cols[1].0 + cols[1].1;
- assert_eq!(cols[0].0 - border_l, border_r - row_right);
- }
-
- /// Even division gives a long label and a short one the same box, so one
- /// is clipped while the other floats in slack — the row of Suspend /
- /// Hibernate / Reboot / Power Off that prompted this. `row_layout_for`
- /// gives each column what it needs and shares the leftover equally.
- #[test]
- fn a_row_sized_for_content_fits_every_column() {
- let mut pc = ProbeTarget::default();
- let sec: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc, 100.0, 50.0, 400.0, "Probe", false, false);
- let needs = [80.0f32, 30.0, 50.0, 40.0];
- let cols = sec.row_layout_for(&needs, 8.0);
-
- for (i, &(_, w)) in cols.iter().enumerate() {
- assert!(w >= needs[i], "column {i} got {w}, less than the {} it needs", needs[i]);
- }
- // The slack is shared equally, so every column overshoots by the same
- // amount — not proportionally, which would starve the short ones.
- let slack: Vec<f32> = cols.iter().zip(needs.iter()).map(|(&(_, w), n)| w - n).collect();
- for s in &slack {
- assert!((s - slack[0]).abs() < 1.0e-3, "slack shared unevenly: {slack:?}");
- }
- // And the row still ends inside the section.
- let (lx, lw) = *cols.last().unwrap();
- assert!(lx + lw <= sec.content_left() + sec.content_width() + 1.0e-3);
- }
- /// When the labels genuinely do not fit, everyone shrinks by the same
- /// factor rather than the last column absorbing the whole shortfall.
- #[test]
- fn an_overfull_row_shrinks_every_column_together() {
- let mut pc = ProbeTarget::default();
- let sec: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc, 100.0, 50.0, 200.0, "Probe", false, false);
- let needs = [300.0f32, 150.0];
- let cols = sec.row_layout_for(&needs, 8.0);
- let ratio0 = cols[0].1 / needs[0];
- let ratio1 = cols[1].1 / needs[1];
- assert!((ratio0 - ratio1).abs() < 1.0e-3, "shrink was not shared: {ratio0} vs {ratio1}");
- let (lx, lw) = cols[1];
- assert!(lx + lw <= sec.content_left() + sec.content_width() + 1.0e-3, "overfull row escaped the section");
- }
-
- /// `ax` used to add `padding()` only for `x_off >= 12.0`, so asking for one
- /// pixel less moved content a whole `padding()` the other way. Callers do
- /// pass 11.0 and 13.0 in this tree, and the step put them in different
- /// coordinate spaces from each other.
- #[test]
- fn section_ax_is_continuous() {
- let mut pc = ProbeTarget::default();
- let sec: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc, 100.0, 50.0, 320.0, "Probe", false, false);
- for off in [0.0f32, 1.0, 11.0, 11.999, 12.0, 13.0, 24.0] {
- assert!(
- (sec.ax(off) - sec.ax(0.0) - off).abs() < 1.0e-3,
- "ax must be a plain translation; it stepped at {off}"
- );
- }
- }
- /// A string wider than its section used to be drawn unbounded, running over
- /// the border and out of the window. Every section text now carries bounds
- /// no wider than the content box.
- #[test]
- fn section_text_is_bounded_to_the_content_box() {
- let mut pc = ProbeTarget::default();
- let (left, cw) = (100.0f32, 320.0f32);
- {
- let mut sec: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc, left, 50.0, cw, "Probe", false, false);
- let right = sec.content_left() + sec.content_width();
- sec.text(
- "NVIDIA Corporation AD104M [GeForce RTX 4080 Max-Q / Mobile] and then some",
- 12.0, 0.0, 12.0, [1.0; 4],
- );
- assert!(right < left + cw, "content box must sit inside the section");
- }
- // Pick our string out by content: the section's own label is drawn
- // through this target too, and it is not content.
- let (_, _, _, bounds) = pc
- .bounded
- .iter()
- .find(|(t, ..)| t.starts_with("NVIDIA"))
- .cloned()
- .expect("the section text must reach the render target");
- let b = bounds.expect("section text must be bounded");
- // Recompute the expectation from the same inputs the section used.
- let mut pc2 = ProbeTarget::default();
- let probe: SectionContext<'_, ProbeTarget> =
- SectionContext::new(&mut pc2, left, 50.0, cw, "Probe", false, false);
- assert_eq!(b[2], probe.content_left() + probe.content_width());
- assert!(b[2] <= left + cw - probe.padding(), "bound must not exceed the section border");
- }
+
+
struct MockWidget {
base: crate::widget::Widget,
@@ -2006,65 +1868,6 @@ mod tests {
}
}
- struct MockWidgetWithLabel {
- base: crate::widget::Widget,
- }
-
- impl WidgetHost for MockWidgetWithLabel {
- crate::impl_widget_base!(MockWidgetWithLabel);
- fn rect(&self) -> (f32, f32, f32, f32) {
- let offset = self.label_strip();
- (self.base.x, self.base.y - offset, self.base.w, self.base.h + offset)
- }
- fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
- let offset = self.label_strip();
- self.base.x = x;
- self.base.y = y + offset;
- self.base.w = w;
- self.base.h = (h - offset).max(0.0);
- }
- }
-
- /// The vstack flow is checked against the LIVE style — the label margin, the
- /// detached-label font and the section padding are process-global and the suite
- /// runs in parallel, so pinning them here would be a window every other test
- /// could see (a label measured in one font by its own call and in another by the
- /// group hull's is exactly the flake this cost us). Every expectation below is
- /// derived from the getters instead, so the flow holds under any config.
- #[test]
- fn test_vstack_flow() {
- // 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 = 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(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);
-
- 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);
-
- // 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());
- let mut w3 = MockWidgetWithLabel { base };
- stack.add_widget(&mut w3, 70.0, 50.0, &mut dummy);
-
- let offset = w3.base.label_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 + gap + 40.0 + 10.0 + gap + offset);
- }
-
#[test]
fn test_grid_layout() {
// Test single column layout (width = 200, min_col_width = 300)
@@ -2094,22 +1897,6 @@ mod tests {
assert_eq!(grid2.max_height(), 75.0);
}
- #[test]
- fn test_subsection() {
- let mut pc = PopoverCollector::new();
- 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 w, 12.0, 100.0, 40.0, &mut dummy);
-
- let bottom = subsec.finish();
- assert!(bottom > 20.0);
- }
/// These keys have ONE home, the style registry (since 2026-10-08): the setter writes
/// it, the getter reads it, an unset key is its default, and a length in other units
@@ -2197,89 +1984,9 @@ mod tests {
}
}
- #[test]
- fn test_section_context_grid() {
- let mut mock_pc = MockRenderTarget { rects: Vec::new() };
- let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Section", false, false);
- assert_eq!(ctx.left, 10.0);
- assert_eq!(ctx.top, 20.0);
- assert_eq!(ctx.cw, 500.0);
-
- let mut ui_ctx = 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 };
- ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx);
-
- let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
- ctx.widget(&mut w2, 12.0, 100.0, 30.0, &mut ui_ctx);
-
- // Since cw=500, we should have multiple columns!
- // The first widget goes into column 0, second into column 1.
- assert_ne!(w1.x, w2.x);
-
- let bottom = ctx.finish();
- assert!(bottom > 20.0);
- }
-
- #[test]
- fn test_child_section_single_column_controls() {
- let mut mock_pc = MockRenderTarget { rects: Vec::new() };
- let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Child Section", false, true);
- assert_eq!(ctx.grid.col_heights.len(), 1);
-
- let mut ui_ctx = 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 };
- ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx);
-
- let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
- ctx.widget(&mut w2, 12.0, 100.0, 30.0, &mut ui_ctx);
-
- // Since it's a child section, we should have a single column only, so w1.x == w2.x.
- assert_eq!(w1.x, w2.x);
- }
-
- #[test]
- fn test_parent_section_side_by_side_child_sections() {
- let mut mock_pc = MockRenderTarget { rects: Vec::new() };
- let mut parent_ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Parent Section", false, false);
- assert_eq!(parent_ctx.grid.col_heights.len(), 2);
-
- let mut sub_left_1 = 0.0;
- let mut sub_left_2 = 0.0;
-
- parent_ctx.add_section("Child Section 1", false, |subsec1| {
- sub_left_1 = subsec1.left;
- });
- parent_ctx.add_section("Child Section 2", false, |subsec2| {
- sub_left_2 = subsec2.left;
- });
- // The two child sections should be rendered side-by-side in different columns, so sub_left_1 != sub_left_2.
- assert_ne!(sub_left_1, sub_left_2);
- }
-
- #[test]
- fn test_section_context_spacing_preserves_columns() {
- let mut mock_pc = MockRenderTarget { rects: Vec::new() };
- let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Section", false, false);
- assert_eq!(ctx.grid.col_heights.len(), 2);
- let mut ui_ctx = 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 };
- ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx); // placed in col 0
-
- let height_col_0_before = ctx.grid.col_heights[0];
- let height_col_1_before = ctx.grid.col_heights[1];
- assert_ne!(height_col_0_before, height_col_1_before);
-
- ctx.spacing(12.0);
-
- let height_col_0_after = ctx.grid.col_heights[0];
- let height_col_1_after = ctx.grid.col_heights[1];
- assert_eq!(height_col_0_after, height_col_0_before + 12.0);
- assert_eq!(height_col_1_after, height_col_1_before);
- assert_ne!(height_col_0_after, height_col_1_after);
- }
#[test]
fn test_spinbox_button_padding_config() {
diff --git a/src/layout/section.rs b/src/layout/section.rs
index 9fe37c6..57e51ae 100644
--- a/src/layout/section.rs
+++ b/src/layout/section.rs
@@ -10,13 +10,12 @@
//! 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.
+//! What goes inside a section is a [`Form`](super::Form): a box-model tree the page declares,
+//! which the section lays out across its content box with `scene::layout` and paints
+//! ([`SectionContext::form`], [`SectionContext::place`]). Until 2026-10-08 it was a cursor
+//! down the content box with a one- or two-column grid, and every page added its own insets.
-use crate::widget::WidgetHostExt;
use super::*;
-use crate::widget::WidgetHost;
use crate::context::UiContext;
fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
@@ -291,6 +290,7 @@ pub struct SectionContext<'a, P> {
pub pc: &'a mut P,
pub left: f32,
pub top: f32,
+ /// How far down the section's content reaches.
pub content_y: f32,
pub cw: f32,
pub label_width: f32,
@@ -304,15 +304,12 @@ pub struct SectionContext<'a, P> {
pub relief_tab: Option<(f32, f32, f32, f32)>,
pub focused: bool,
pub is_child: bool,
- pub grid: Grid,
- pub last_col: usize,
+ /// Where the content box starts.
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)
@@ -365,14 +362,6 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
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
@@ -385,7 +374,6 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
} 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,
@@ -399,21 +387,12 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
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.
+ /// bottom) under relief styling, the outline's border line otherwise.
pub fn well_top(&self) -> f32 {
if self.relief_style {
self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
@@ -422,297 +401,51 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
}
}
- 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`.
+ /// Horizontal inset of section CONTENT from the section's left edge. 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.
+ /// Left edge of the content box.
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()`.
+ /// Width of the content box — the section's width less the inset on both sides.
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;
+ /// A [`Form`](super::Form) across this section's content box, starting below whatever the
+ /// section already holds (the control gap after it). Declare the contents into it, then
+ /// hand it to [`place`](Self::place).
+ pub fn form<'w>(&self) -> super::Form<'w, P>
+ where
+ P: 'w,
+ {
+ let mut y = self.content_y;
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;
+ y += crate::layout::control_gap();
}
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
+ super::Form::new(self.content_left(), y, self.content_width(), (self.left + pad, self.cw - 2.0 * pad))
}
- /// 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)
+ /// Lay a [`Form`](super::Form) out, paint it, and move the section past it.
+ pub fn place<'w>(&mut self, form: super::Form<'w, P>, ctx: &mut UiContext)
where
- F: FnMut(usize, f32, f32),
+ P: 'w,
{
- 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);
+ let bottom = form.paint(&mut *self.pc, ctx);
+ self.content_y = bottom;
}
- 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);
- }
+ /// How far below the last of its content the section's frame ends — what a page leaves
+ /// under a list that fills the rest of the page.
+ pub fn bottom_inset(&self) -> f32 {
+ if self.relief_style { self.content_margin() } else { self.padding() + 12.0 + 8.0 }
}
pub fn finish(self) -> f32 {
@@ -786,82 +519,6 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
}
}
-
-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::*;
@@ -922,7 +579,9 @@ mod tests {
for _ in 0..2 {
builder.add_section(&mut pc, "Section", false, |sec| {
tops.push(sec.top);
- sec.spacing(40.0);
+ let mut form = sec.form();
+ form.column().draw(0.0, 40.0, false, |_, _, _| {});
+ sec.place(form, &mut UiContext::new());
});
}
assert_eq!(tops.len(), 2, "one call per section");