git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit2b15dc5434ebc3bace7683dfefd0f5fb065a1686
parent3791b03157
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 18:53
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");