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

commite915e02cb8f0dd19d377d006f1a8ae6ef4e7c9d8
parent693ae08436
authorLucas Galante <[email protected]>
date2026-09-24 12:55
The spacing ladder: root, pane and control rungs, and presets that pick one

Spacing lived as nine overlapping keys read two different ways — the
plate root's padding and gap on the style registry, plate_padding,
page_margin, column_gap, control_panel_{padding,gap} and grid_gap as
line-scraped statics that never saw a nested KDL key, and CONTROL_GAP a
literal — so every app named its own number. It is now one ladder:

  root  : root_plate_inset (roll + padding), root_plate_gap
  pane  : plate_padding, plate_gap        (style.surface.plate.{padding,gap})
  control: control_gap                    (style.control.gap)

plate_gap and control_gap are new keys, falling back to the root gap and
to CONTROL_GAP; the legacy keys are kept and honoured when set, and when
unset land on their rung (page_margin and control_panel_padding on the
pane padding, column_gap on the root gap, control_panel_gap on the pane
gap) — the radius rule, applied to spacing. All of them read the registry
first, so a nested config key works and live-reloads.

The box model gets the ladder as presets — Style::root_column/root_row,
pane_column/pane_row, controls_column/controls_row — so an app on the
standard plate picks a rung instead of naming a padding; the legacy
strategies' Defaults and ColumnLayout::pane/controls read the same
getters instead of the literal.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/layout.rs                            | 196 ++++++++++++++-----------------
 src/scene/layout.rs                      |  45 +++++++
 src/widget/container/container_layout.rs |  20 ++--
 src/widget/layout_helper.rs              |  12 ++
 4 files changed, 155 insertions(+), 118 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index e715d80..490dff3 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -296,6 +296,8 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 "style.surface.desktop.grid_cell_width" => "grid_cell_width",
                 "style.surface.desktop.grid_cell_height" => "grid_cell_height",
                 "style.surface.plate.padding" => "plate_padding",
+                "style.surface.plate.gap" => "plate_gap",
+                "style.control.gap" => "control_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",
@@ -526,9 +528,9 @@ static RAMP_HEIGHT: RwLock<f32> = RwLock::new(32.0);
 static BUTTON_STRIP_SPACING: RwLock<f32> = RwLock::new(8.0);
 static SCROLLBAR_WIDTH: RwLock<f32> = RwLock::new(4.0);
 static SCROLLBAR_INSET: RwLock<f32> = RwLock::new(16.0);
-static COLUMN_GAP: RwLock<f32> = RwLock::new(16.0);
-static CONTROL_PANEL_PADDING: RwLock<f32> = RwLock::new(16.0);
-static CONTROL_PANEL_GAP: RwLock<f32> = RwLock::new(12.0);
+static COLUMN_GAP: RwLock<Option<f32>> = RwLock::new(None);
+static CONTROL_PANEL_PADDING: RwLock<Option<f32>> = RwLock::new(None);
+static CONTROL_PANEL_GAP: RwLock<Option<f32>> = RwLock::new(None);
 static TREE_OPACITY: RwLock<f32> = RwLock::new(1.0);
 static TREE_BLUR: RwLock<f32> = RwLock::new(0.0);
 
@@ -667,7 +669,7 @@ pub fn reload_config() {
                 let val_str = rest.trim_end_matches('"').trim();
                 if let Ok(val) = val_str.parse::<f32>() {
                     if let Ok(mut lock) = PAGE_MARGIN.write() {
-                        *lock = val;
+                        *lock = Some(val);
                     }
                 }
             }
@@ -694,7 +696,7 @@ pub fn reload_config() {
                 let val_str = rest.trim_end_matches('"').trim();
                 if let Ok(val) = val_str.parse::<f32>() {
                     if let Ok(mut lock) = COLUMN_GAP.write() {
-                        *lock = val;
+                        *lock = Some(val);
                     }
                 }
             }
@@ -703,7 +705,7 @@ pub fn reload_config() {
                 let val_str = rest.trim_end_matches('"').trim();
                 if let Ok(val) = val_str.parse::<f32>() {
                     if let Ok(mut lock) = CONTROL_PANEL_PADDING.write() {
-                        *lock = val;
+                        *lock = Some(val);
                     }
                 }
             }
@@ -712,7 +714,7 @@ pub fn reload_config() {
                 let val_str = rest.trim_end_matches('"').trim();
                 if let Ok(val) = val_str.parse::<f32>() {
                     if let Ok(mut lock) = CONTROL_PANEL_GAP.write() {
-                        *lock = val;
+                        *lock = Some(val);
                     }
                 }
             }
@@ -1477,26 +1479,14 @@ pub fn set_nested_section_label_offset(offset: f32) {
 }
 
 
+/// The pane rung's padding: from a pane plate's rim to its content, in
+/// logical px (`style.surface.plate.padding`). The second rung of the
+/// spacing ladder — [`root_plate_inset`] / [`root_plate_gap`] on the root
+/// plate, this and [`plate_gap`] inside a pane plate, [`control_gap`]
+/// between controls. Registry-backed (live-reloadable); the legacy flat
+/// `plate_padding = N` line still loads as a fallback.
 pub fn plate_padding() -> f32 {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-    INIT.call_once(|| {
-        if let Some(content) = read_config() {
-            for line in content.lines() {
-                let trimmed = line.trim();
-                if let Some(rest) = trimmed.strip_prefix("plate_padding") {
-                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
-                    let val_str = rest.trim_end_matches('"').trim();
-                    if let Ok(val) = val_str.parse::<f32>() {
-                        if let Ok(mut lock) = PLATE_PADDING.write() {
-                            *lock = val;
-                        }
-                    }
-                }
-            }
-        }
-    });
-    *PLATE_PADDING.read().unwrap()
+    registry_float("plate_padding").unwrap_or_else(|| *PLATE_PADDING.read().unwrap())
 }
 
 pub fn set_plate_padding(padding: f32) {
@@ -1505,33 +1495,20 @@ pub fn set_plate_padding(padding: f32) {
     }
 }
 
-static PAGE_MARGIN: RwLock<f32> = RwLock::new(20.0);
+static PAGE_MARGIN: RwLock<Option<f32>> = RwLock::new(None);
 
+/// Legacy: the page-level margin (`style.surface.page.margin`). Unset, it
+/// IS the pane rung's [`plate_padding`] — a page is a pane — so an app
+/// still reading it lands on the ladder. Set, it is honoured as before.
 pub fn page_margin() -> f32 {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-    INIT.call_once(|| {
-        if let Some(content) = read_config() {
-            for line in content.lines() {
-                let trimmed = line.trim();
-                if let Some(rest) = trimmed.strip_prefix("page_margin") {
-                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
-                    let val_str = rest.trim_end_matches('"').trim();
-                    if let Ok(val) = val_str.parse::<f32>() {
-                        if let Ok(mut lock) = PAGE_MARGIN.write() {
-                            *lock = val;
-                        }
-                    }
-                }
-            }
-        }
-    });
-    *PAGE_MARGIN.read().unwrap()
+    registry_float("page_margin")
+        .or_else(|| *PAGE_MARGIN.read().unwrap())
+        .unwrap_or_else(plate_padding)
 }
 
 pub fn set_page_margin(margin: f32) {
     if let Ok(mut lock) = PAGE_MARGIN.write() {
-        *lock = margin;
+        *lock = Some(margin);
     }
 }
 
@@ -1595,87 +1572,45 @@ pub fn set_grid_gap(gap: f32) {
     }
 }
 
+/// Legacy: the inter-column gap (`style.layout.column.gap`). Unset, it is
+/// the root plate's [`root_plate_gap`] — columns are siblings on the plate.
 pub fn column_gap() -> f32 {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-    INIT.call_once(|| {
-        if let Some(content) = read_config() {
-            for line in content.lines() {
-                let trimmed = line.trim();
-                if let Some(rest) = trimmed.strip_prefix("column_gap") {
-                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
-                    let val_str = rest.trim_end_matches('"').trim();
-                    if let Ok(val) = val_str.parse::<f32>() {
-                        if let Ok(mut lock) = COLUMN_GAP.write() {
-                            *lock = val;
-                        }
-                    }
-                }
-            }
-        }
-    });
-    *COLUMN_GAP.read().unwrap()
+    registry_float("column_gap")
+        .or_else(|| *COLUMN_GAP.read().unwrap())
+        .unwrap_or_else(root_plate_gap)
 }
 
 pub fn set_column_gap(gap: f32) {
     if let Ok(mut lock) = COLUMN_GAP.write() {
-        *lock = gap;
+        *lock = Some(gap);
     }
 }
 
+/// Legacy: a control panel's padding (`style.control.control_panel.padding`).
+/// Unset, it is the pane rung's [`plate_padding`] — a control panel is a pane.
 pub fn control_panel_padding() -> f32 {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-    INIT.call_once(|| {
-        if let Some(content) = read_config() {
-            for line in content.lines() {
-                let trimmed = line.trim();
-                if let Some(rest) = trimmed.strip_prefix("control_panel_padding") {
-                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
-                    let val_str = rest.trim_end_matches('"').trim();
-                    if let Ok(val) = val_str.parse::<f32>() {
-                        if let Ok(mut lock) = CONTROL_PANEL_PADDING.write() {
-                            *lock = val;
-                        }
-                    }
-                }
-            }
-        }
-    });
-    *CONTROL_PANEL_PADDING.read().unwrap()
+    registry_float("control_panel_padding")
+        .or_else(|| *CONTROL_PANEL_PADDING.read().unwrap())
+        .unwrap_or_else(plate_padding)
 }
 
 pub fn set_control_panel_padding(padding: f32) {
     if let Ok(mut lock) = CONTROL_PANEL_PADDING.write() {
-        *lock = padding;
+        *lock = Some(padding);
     }
 }
 
+/// Legacy: a control panel's gap (`style.control.control_panel.gap`).
+/// Unset, it is the pane rung's [`plate_gap`].
 pub fn control_panel_gap() -> f32 {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-    INIT.call_once(|| {
-        if let Some(content) = read_config() {
-            for line in content.lines() {
-                let trimmed = line.trim();
-                if let Some(rest) = trimmed.strip_prefix("control_panel_gap") {
-                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
-                    let val_str = rest.trim_end_matches('"').trim();
-                    if let Ok(val) = val_str.parse::<f32>() {
-                        if let Ok(mut lock) = CONTROL_PANEL_GAP.write() {
-                            *lock = val;
-                        }
-                    }
-                }
-            }
-        }
-    });
-    *CONTROL_PANEL_GAP.read().unwrap()
+    registry_float("control_panel_gap")
+        .or_else(|| *CONTROL_PANEL_GAP.read().unwrap())
+        .unwrap_or_else(plate_gap)
 }
 
 pub fn set_control_panel_gap(gap: f32) {
     if let Ok(mut lock) = CONTROL_PANEL_GAP.write() {
-        *lock = gap;
+        *lock = Some(gap);
     }
 }
 
@@ -2248,6 +2183,30 @@ pub fn root_plate_inset() -> f32 {
     bevel_width() + root_plate_padding()
 }
 
+/// One style-registry float, initialising the registry on first use — the
+/// one read every rung getter goes through.
+fn registry_float(slot: &str) -> Option<f32> {
+    lazy_init_style_registry();
+    get_style_registry().read().unwrap().get_float(slot)
+}
+
+/// Gap between siblings INSIDE a pane plate, in logical px
+/// (`style.surface.plate.gap`) — the pane rung's twin of
+/// [`root_plate_gap`]. Unset, it is the root gap: one number reads as one
+/// rhythm across both rungs unless a config says otherwise.
+pub fn plate_gap() -> f32 {
+    registry_float("plate_gap").unwrap_or_else(root_plate_gap)
+}
+
+/// Gap between controls, in logical px (`style.control.gap`) — the control
+/// rung of the ladder: what the layout strategies put between a form's
+/// controls (and between a detached label's block and the next), in both
+/// axes. Unset, it is [`CONTROL_GAP`], one control height, the value every
+/// strategy's `Default` carried as a literal.
+pub fn control_gap() -> f32 {
+    registry_float("control_gap").unwrap_or(CONTROL_GAP)
+}
+
 /// 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
@@ -6821,11 +6780,32 @@ mod tests {
 
     #[test]
     fn test_column_gap() {
+        // A legacy key: set (by config or setter) it is honoured; unset it
+        // lands on the ladder — the root plate's gap.
         let gap = column_gap();
-        assert_eq!(gap, *super::COLUMN_GAP.read().unwrap());
+        match (registry_float("column_gap"), *super::COLUMN_GAP.read().unwrap()) {
+            (Some(v), _) | (None, Some(v)) => assert_eq!(gap, v),
+            (None, None) => assert_eq!(gap, root_plate_gap()),
+        }
         assert!(gap.is_finite() && gap >= 0.0, "column gap {gap}");
     }
 
+    #[test]
+    fn spacing_ladder_falls_back_rung_by_rung() {
+        // Every rung getter is finite and non-negative, and the inset is the
+        // roll plus the padding, whatever the config says.
+        for v in [root_plate_padding(), root_plate_gap(), root_plate_inset(), plate_padding(), plate_gap(), control_gap()] {
+            assert!(v.is_finite() && v >= 0.0, "{v}");
+        }
+        assert_eq!(root_plate_inset(), bevel_width() + root_plate_padding());
+        if registry_float("plate_gap").is_none() {
+            assert_eq!(plate_gap(), root_plate_gap());
+        }
+        if registry_float("control_gap").is_none() {
+            assert_eq!(control_gap(), CONTROL_GAP);
+        }
+    }
+
     #[test]
     fn test_print_fonts() {
         let db = crate::widget::get_font_db();
diff --git a/src/scene/layout.rs b/src/scene/layout.rs
index 84e1136..3634de2 100644
--- a/src/scene/layout.rs
+++ b/src/scene/layout.rs
@@ -221,6 +221,51 @@ impl Style {
     pub fn grid(columns: usize, col_gap: f32, row_gap: f32) -> Self {
         Style { mode: LayoutMode::Grid(GridSpec { columns: columns.max(1), col_gap, row_gap }), ..Default::default() }
     }
+    // ── The spacing ladder as presets ─────────────────────────────────
+    // An app on the standard root plate never names a padding or gap: it
+    // picks the rung. Root presets inset by `root_plate_inset` (the plate's
+    // roll plus one padding) and space siblings by `root_plate_gap`; pane
+    // presets by `plate_padding` / `plate_gap`; the controls presets space
+    // a form's controls by `control_gap` with no inset of their own, since
+    // they sit inside a pane or root preset that already has one.
+
+    /// A column of siblings standing on the root plate, stretched across it.
+    pub fn root_column() -> Self {
+        Self::column()
+            .padding(crate::layout::root_plate_inset())
+            .gap(crate::layout::root_plate_gap())
+            .cross_align(CrossAlign::Stretch)
+    }
+    /// A row of siblings standing on the root plate.
+    pub fn root_row() -> Self {
+        Self::row()
+            .padding(crate::layout::root_plate_inset())
+            .gap(crate::layout::root_plate_gap())
+            .cross_align(CrossAlign::Stretch)
+    }
+    /// A column inside a pane plate, inset from its rim.
+    pub fn pane_column() -> Self {
+        Self::column()
+            .padding(crate::layout::plate_padding())
+            .gap(crate::layout::plate_gap())
+            .cross_align(CrossAlign::Stretch)
+    }
+    /// A row inside a pane plate.
+    pub fn pane_row() -> Self {
+        Self::row()
+            .padding(crate::layout::plate_padding())
+            .gap(crate::layout::plate_gap())
+            .cross_align(CrossAlign::Stretch)
+    }
+    /// A column of controls: the control gap between them, no inset.
+    pub fn controls_column() -> Self {
+        Self::column().gap(crate::layout::control_gap())
+    }
+    /// A row of controls: the control gap between them, no inset.
+    pub fn controls_row() -> Self {
+        Self::row().gap(crate::layout::control_gap())
+    }
+
     pub fn gap(mut self, v: f32) -> Self {
         self.gap = v;
         self
diff --git a/src/widget/container/container_layout.rs b/src/widget/container/container_layout.rs
index ffa61e8..3dd3668 100644
--- a/src/widget/container/container_layout.rs
+++ b/src/widget/container/container_layout.rs
@@ -140,7 +140,7 @@ impl Default for VerticalLayout {
         Self {
             padding_x: 0.0,
             padding_y: 0.0,
-            spacing: crate::layout::CONTROL_GAP,
+            spacing: crate::layout::control_gap(),
             left: 0.0,
             current_y: 0.0,
         }
@@ -444,9 +444,9 @@ pub struct ColumnsLayout {
 impl Default for ColumnsLayout {
     fn default() -> Self {
         Self {
-            padding_x: crate::layout::CONTROL_GAP,
-            padding_y: crate::layout::CONTROL_GAP,
-            spacing: crate::layout::CONTROL_GAP,
+            padding_x: crate::layout::control_gap(),
+            padding_y: crate::layout::control_gap(),
+            spacing: crate::layout::control_gap(),
         }
     }
 }
@@ -524,9 +524,9 @@ pub struct MosaicLayout {
 impl Default for MosaicLayout {
     fn default() -> Self {
         Self {
-            gap: crate::layout::CONTROL_GAP,
-            padding_x: crate::layout::CONTROL_GAP,
-            padding_y: crate::layout::CONTROL_GAP,
+            gap: crate::layout::control_gap(),
+            padding_x: crate::layout::control_gap(),
+            padding_y: crate::layout::control_gap(),
         }
     }
 }
@@ -679,9 +679,9 @@ pub struct ReverseMosaicLayout {
 impl Default for ReverseMosaicLayout {
     fn default() -> Self {
         Self {
-            gap: crate::layout::CONTROL_GAP,
-            padding_x: crate::layout::CONTROL_GAP,
-            padding_y: crate::layout::CONTROL_GAP,
+            gap: crate::layout::control_gap(),
+            padding_x: crate::layout::control_gap(),
+            padding_y: crate::layout::control_gap(),
         }
     }
 }
diff --git a/src/widget/layout_helper.rs b/src/widget/layout_helper.rs
index ce42c68..58c5289 100644
--- a/src/widget/layout_helper.rs
+++ b/src/widget/layout_helper.rs
@@ -21,6 +21,18 @@ impl ColumnLayout {
         }
     }
 
+    /// A column inside a pane plate: the pane rung's padding as the margin
+    /// and its gap between widgets (`plate_padding` / `plate_gap`).
+    pub fn pane(x: f32, y: f32, width: f32) -> Self {
+        Self::new(x, y, width, crate::layout::plate_gap(), crate::layout::plate_padding())
+    }
+
+    /// A column of controls with the control gap between them and no
+    /// margin of its own (`control_gap`).
+    pub fn controls(x: f32, y: f32, width: f32) -> Self {
+        Self::new(x, y, width, crate::layout::control_gap(), 0.0)
+    }
+
     /// `height` is the CONTENT height; the widget's block adds its label strip.
     pub fn add_widget(&mut self, widget: &mut dyn WidgetHost, height: f32) {
         let total_h = height + widget.label_strip();