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

commitec39799797e54f056e1b79b4be2e06eb7d10607a
parent64c66639a9
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 14:57
fix(config): another program's keys never become the toolkit's

The flatten maps the config paths the toolkit reads to its own keys. An
unmapped path was cut down: the compositor's `layout` and `transparency`
blocks to their bare keys, anything else past its first segment. So the
compositor's `layout { grid_gap 18 }`, its window-tiling gap, arrived as the
toolkit's `grid_gap`, and any block's key could stand in for a toolkit one.
An unmapped path now keeps its whole name (`layout.grid_gap`); mapped paths
and flat top-level keys are unchanged. In the live config and every per-app
override, layout.grid_gap was the only path that reached a key the toolkit
reads this way. another_programs_keys_do_not_become_the_toolkits.

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

 CLAUDE.md              |  9 +++++++--
 src/layout/mod.rs      | 20 ++++++++++++++++++++
 src/layout/registry.rs | 21 ++++++++-------------
 3 files changed, 35 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index dfea012..68b0e29 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1809,9 +1809,14 @@ cce-system-interface) to confirm behavior, not just the test suite.
     first use; a `(mm)` length never reached a slot. Do not add a slot for a config key.
     One thing it changed on screen: those first-use scans ran after an app's own setter and
     overwrote it, so cce-system-interface's `set_grid_gap(root_plate_gap())` lost to the
-    config's `layout { grid_gap 18 }` — the compositor's window-tiling gap, which reaches the
-    toolkit because the flatten strips `layout.` off keys it does not map. Its multi-column
+    config's `layout { grid_gap 18 }` — the compositor's window-tiling gap. Its multi-column
     pages now stand their sections the root gap apart, as the app asks (kept by choice).
+    **And another program's key never becomes the toolkit's**: the flatten maps the paths
+    the toolkit reads to its keys and leaves every other path whole (`layout.grid_gap`).
+    Until the same day it cut an unmapped path down — the compositor's `layout` and
+    `transparency` blocks to their bare keys, anything else past its first segment — which
+    is how the tiling gap arrived as `grid_gap`. A new key the toolkit reads from a nested
+    block needs its mapping; `another_programs_keys_do_not_become_the_toolkits`.
   - `bridge.rs` — the flat-host render bridge: `RenderTarget`, `PopoverCollector`,
     `render_widget`, `render_popovers`, the carve types that cross it.
   - `section.rs` — a settings page's sections: `PageFlow` places them (a masonry of
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 055d8d7..5496bde 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -2147,6 +2147,26 @@ mod tests {
         assert_eq!(textbox_height(), len.to_px(), "a length in mm is honoured");
     }
 
+    /// Another program's config keeps its own name in the registry: the compositor's
+    /// `layout { grid_gap 18 }` (its window-tiling gap) is `layout.grid_gap`, never the
+    /// toolkit's `grid_gap`, which the settings app's section flow reads. A path the
+    /// toolkit maps still lands on its key, and a flat top-level key stays flat.
+    #[test]
+    fn another_programs_keys_do_not_become_the_toolkits() {
+        let val = crate::config::parse_kdl_to_json(
+            "layout {\n    grid_gap (i64)18\n    gap (i64)48\n}\ntransparency {\n    plate_opacity (f64)0.5\n}\nwindow_manager {\n    control_relief (bool)true\n}\nstyle {\n    control {\n        button height=(i64)30\n    }\n}\nplate_corner_radius (i64)14\n",
+        );
+        let mut flat = String::new();
+        super::flatten_json_to_flat_props(&val, "", &mut flat);
+        let keys: Vec<&str> = flat.lines().filter_map(|l| l.split('=').next()).map(str::trim).collect();
+        for leaked in ["grid_gap", "gap", "plate_opacity"] {
+            assert!(!keys.contains(&leaked), "{leaked} leaked into the toolkit's keys: {keys:?}");
+        }
+        for kept in ["layout.grid_gap", "layout.gap", "transparency.plate_opacity", "control_relief", "button_height", "plate_corner_radius"] {
+            assert!(keys.contains(&kept), "{kept} missing: {keys:?}");
+        }
+    }
+
     #[test]
     fn test_column_gap() {
         // A legacy key: set (by config or setter) it is honoured; unset it
diff --git a/src/layout/registry.rs b/src/layout/registry.rs
index 1ec8af1..d9abbac 100644
--- a/src/layout/registry.rs
+++ b/src/layout/registry.rs
@@ -416,19 +416,14 @@ pub(super) fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str,
                 "style.surface.plate.border_thickness" => "plate_border_thickness",
                 "input.touchpad.natural_scroll" => "touchpad_natural_scroll",
                 
-                other => {
-                    if let Some(rest) = other.strip_prefix("layout.") {
-                        rest
-                    } else if let Some(rest) = other.strip_prefix("transparency.") {
-                        rest
-                    } else {
-                        if let Some(idx) = other.find('.') {
-                            &other[idx + 1..]
-                        } else {
-                            other
-                        }
-                    }
-                }
+                // Anything else keeps its whole path. A key the toolkit reads is mapped above
+                // (or is a flat top-level key, which has no path to strip); the rest belong
+                // to other programs. Until 2026-10-08 an unmapped path was cut down — the
+                // `layout.` and `transparency.` blocks (the compositor's own) to their bare
+                // keys, anything else past its first segment — so the compositor's
+                // `layout { grid_gap 18 }`, its window-tiling gap, arrived as the toolkit's
+                // `grid_gap`, and any block's key could stand in for a toolkit one.
+                other => other,
             };
             
             if let Some(s) = val.as_str() {