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

commit6bd1571a7dfe82e1c3a00d3e86795d6c17af14d6
parent2b15dc5434
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 19:10
feat(layout): list_gap, the ladder's rung inside a list row, and lay_row

What a scrolling list draws per visible row (its buttons, a glyph, its name)
is out of a Form's reach, and each list placed it with numbers of its own.
`lay_row(rect, &[Cell])` lays one row's cells by scene::layout: list_gap()
apart and in from the row's ends, each centred on the row's height, a
growing cell taking the slack. `list_gap()` (`style.control.list_gap`,
unset = CONTROL_TEXT_INSET, 8 px) is the rung below the controls: a row is
one control tall, so the control gap would part it into islands. CLAUDE.md
adds it to the ladder table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md              |  8 +++++++
 src/layout/form.rs     | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/layout/mod.rs      | 11 +++++++++-
 src/layout/registry.rs |  1 +
 4 files changed, 78 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b036a53..7433a0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1588,6 +1588,7 @@ live-reloads), each with a getter in `layout`:
 | root plate | `root_plate_inset()` = `bevel_width` + `style.surface.plate.root.padding` | `root_plate_gap()` (`…root.gap`) |
 | pane plate | `plate_padding()` (`style.surface.plate.padding`) | `plate_gap()` (`…plate.gap`, unset = the root gap) |
 | controls | — (inside a pane or root inset) | `control_gap()` (`style.control.gap`, unset = `CONTROL_GAP`) |
+| inside a list row | `list_gap()` from the list's wall | `list_gap()` (`style.control.list_gap`, unset = `CONTROL_TEXT_INSET`) |
 
 The root inset carries the roll because the padding is a run of FLAT face —
 the same run the gap leaves between two panes — and the face only begins
@@ -1880,6 +1881,13 @@ cce-system-interface) to confirm behavior, not just the test suite.
     `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.
+    What a scrolling list draws per visible row — its buttons, a glyph, its name — a
+    `Form` (one tree per section) does not reach; `lay_row(rect, &[Cell])` lays one
+    row's cells by `scene::layout`, `list_gap()` apart and in from its ends, centred on
+    its height, a growing cell taking the slack. `list_gap` is the ladder's rung below
+    the controls: a row is one control tall, so the control gap would part it into
+    islands. The settings app's lists and its process table (columns as wide as their
+    content, COMMAND taking the slack) are laid out so.
 - `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
index 36abb0e..31ba968 100644
--- a/src/layout/form.rs
+++ b/src/layout/form.rs
@@ -233,6 +233,50 @@ impl<'f, 'w, P: RenderTarget + 'w> Group<'f, 'w, P> {
     }
 }
 
+/// One cell of [`lay_row`]: its width (its own, or the least it takes when it grows), its
+/// height, and whether it grows into the row's slack.
+#[derive(Debug, Clone, Copy)]
+pub struct Cell {
+    pub width: f32,
+    pub height: f32,
+    pub grow: bool,
+}
+
+impl Cell {
+    /// A cell of its own width.
+    pub fn fixed(width: f32, height: f32) -> Self {
+        Cell { width, height, grow: false }
+    }
+    /// A cell that takes the row's slack.
+    pub fn grow(height: f32) -> Self {
+        Cell { width: 0.0, height, grow: true }
+    }
+}
+
+/// A list row's cells laid across `rect` by `scene::layout`: `list_gap` in from the row's
+/// ends and between cells, each centred on the row's height. For what a scrolling list draws
+/// per visible row — its buttons, its glyph, its name — where a `Form` (one tree for a whole
+/// section) does not reach.
+pub fn lay_row(rect: Rect, cells: &[Cell]) -> Vec<Rect> {
+    let gap = crate::layout::list_gap();
+    let mut arena = Arena::new();
+    let style = Style::row().gap(gap).cross_align(CrossAlign::Center).width(Length::Fixed(rect.width)).height(Length::Fixed(rect.height));
+    let style = Style { padding: crate::scene::layout::Edges { left: gap, right: gap, top: 0.0, bottom: 0.0 }, ..style };
+    let row = arena.insert(LayoutBox::container(style));
+    let ids: Vec<NodeId> = cells
+        .iter()
+        .map(|c| {
+            let st = if c.grow { Style::default().grow(1.0) } else { Style::default() };
+            let id = arena.insert(LayoutBox::leaf(st, Size::new(c.width, c.height)));
+            arena.append_child(row, id);
+            id
+        })
+        .collect();
+    measure(&mut arena, row);
+    arrange(&mut arena, row, rect);
+    ids.into_iter().map(|id| arena.value(id).map(|b| b.rect).unwrap_or(Rect::ZERO)).collect()
+}
+
 /// 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 {
@@ -334,6 +378,21 @@ mod tests {
         assert_eq!(bottom, 300.0);
     }
 
+    /// A row's cells stand `list_gap` apart and in from its ends, each centred on its height,
+    /// and a growing cell takes what the fixed ones leave.
+    #[test]
+    fn a_list_rows_cells_are_a_list_gap_apart() {
+        let g = crate::layout::list_gap();
+        let rect = Rect { x: 10.0, y: 100.0, width: 300.0, height: 30.0 };
+        let r = lay_row(rect, &[Cell::fixed(24.0, 24.0), Cell::fixed(24.0, 24.0), Cell::grow(16.0), Cell::fixed(50.0, 24.0)]);
+        assert_eq!(r[0].x, 10.0 + g);
+        assert_eq!(r[1].x, r[0].x + 24.0 + g);
+        assert_eq!(r[2].x, r[1].x + 24.0 + g);
+        assert!((r[3].x + 50.0 - (310.0 - g)).abs() < 1e-3, "{r:?}");
+        assert!((r[2].width - (300.0 - 2.0 * g - 3.0 * g - 98.0)).abs() < 1e-3, "{r:?}");
+        assert_eq!((r[0].y, r[2].y), (103.0, 107.0), "centred on the row");
+    }
+
     /// 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]
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 94ce809..bc92d81 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -23,7 +23,7 @@ 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};
+pub use form::{lay_row, line_height as form_line_height, text_width as form_text_width, Cell, 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,
@@ -823,6 +823,15 @@ pub fn control_gap() -> f32 {
     registry_float("control_gap").unwrap_or(CONTROL_GAP)
 }
 
+/// Gap inside a list row, in logical px (`style.control.list_gap`) — the rung below the
+/// controls: what a scrolling list's row puts between its cells (a row's buttons, its glyph
+/// and its name) and between its content and the list's wall. A row is one control's height,
+/// so the control gap (one control height) would part it into islands. Unset, it is
+/// [`CONTROL_TEXT_INSET`], the inset text keeps from a control's wall.
+pub fn list_gap() -> f32 {
+    registry_float("list_gap").unwrap_or(CONTROL_TEXT_INSET)
+}
+
 /// Roll-off width for the wall where a bar (menubar / status bar / the demo's
 /// header band) steps down into the window plate. Wider than the plate's own
 /// perimeter roll on purpose: the carve depth saturates at `bevel_width` in the
diff --git a/src/layout/registry.rs b/src/layout/registry.rs
index d9abbac..1a46613 100644
--- a/src/layout/registry.rs
+++ b/src/layout/registry.rs
@@ -363,6 +363,7 @@ pub(super) fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str,
                 "style.surface.plate.padding" => "plate_padding",
                 "style.surface.plate.gap" => "plate_gap",
                 "style.control.gap" => "control_gap",
+                "style.control.list_gap" => "list_gap",
                 // The context menu's own radius (`menu_corner_radius`): a
                 // popover's corner is control-scale, not pane-scale.
                 "style.surface.menu.corner_radius" => "menu_corner_radius",