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

commitae3f967e85deb7d5f9a3d78735d064f7254ea5ca
parentee1b97ff70
authorLucas Galante <[email protected]>
date2026-08-01 23:57
feat: config-persisted relief profiles

style.surface.relief.profile / edge_profile carry the custom wall and
plate-roll curves as ramp specs. reload_config parses and installs them
after the style registry loads, so every process starts with the styled
walls — not just the editor that wrote them. The identity-smooth spec is
the analytic sentinel (cce-designer's Edge Profile convention): for the
wall curve it coincides with the analytic smoothstep anyway, for the
roll it must not read as a straight chamfer. relief joins PROP_NODES so
config writes land as properties on the existing relief node instead of
growing duplicate child nodes beside depth=/width=.

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

 src/config.rs | 26 +++++++++++++++++++++++-
 src/layout.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 90 insertions(+), 1 deletion(-)

diff --git a/src/config.rs b/src/config.rs
index 814ee97..5bb7ec6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -308,7 +308,7 @@ const PROP_NODES: &[&str] = &[
     "gestures", "key_bindings", "pointer_bind", "gesture_bind",
     "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
     "status", "overlay", "backplate", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
-    "menubar", "statusbar", "node"
+    "menubar", "statusbar", "node", "relief"
 ];
 
 fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
@@ -702,6 +702,30 @@ mod tests {
         assert_eq!(prop_val.as_f64().unwrap(), 0.75);
     }
 
+    #[test]
+    fn relief_keys_write_as_properties_and_round_trip() {
+        // `relief` is a PROP_NODES member: style.surface.relief.* must land as
+        // properties on the existing relief node (the config.kdl shape), not
+        // as duplicate child nodes shadowing the depth=/width= properties.
+        let content = "style {\n    surface {\n        relief depth=(f64)0.15 width=(f64)9.3\n    }\n}\n";
+        let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
+        let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
+        assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
+        assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.depth", "0.3", "style"));
+        let out = doc.to_string();
+        // Still one relief node, no child block grown under it.
+        assert_eq!(out.matches("relief").count(), 1, "out: {out}");
+        assert!(!out.contains("relief {"), "out: {out}");
+
+        // The reload path reads through parse_kdl_to_json: the new property
+        // must surface at the same dotted path the style registry maps.
+        let val = parse_kdl_to_json(&out);
+        let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
+        assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
+        assert_eq!(relief.get("depth").unwrap().as_f64().unwrap(), 0.3);
+        assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
+    }
+
     #[test]
     fn test_get_kdl_type_annotation() {
         let content = "input {\n    accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n    touchpad {\n        gestures pinch=(bool)true\n    }\n}\n";
diff --git a/src/layout.rs b/src/layout.rs
index 6879af9..2d22ee5 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -119,6 +119,10 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 // spelling survives only as a compat alias).
                 "style.surface.relief.depth" | "window_manager.bevel_depth" => "bevel_depth",
                 "style.surface.relief.width" | "window_manager.bevel_width" => "bevel_width",
+                // Ramp-spec strings for the custom wall/roll profiles
+                // (written by cce-bevel, installed by reload_config).
+                "style.surface.relief.profile" => "bevel_profile_spec",
+                "style.surface.relief.edge_profile" => "roll_profile_spec",
                 "style.container.section.depth" => "section_depth",
                 "window_manager.bevel_shader" => "bevel_shader",
                 "window_manager.control_relief" => "control_relief",
@@ -1108,9 +1112,44 @@ pub fn reload_config() {
         if let Ok(raw_kdl) = std::fs::read_to_string(crate::config::get_config_path()) {
             crate::color::reload_colors(&raw_kdl);
         }
+        // The configured relief profiles, applied last so every process (not
+        // just the editor that wrote them) starts with the styled walls.
+        apply_relief_profile_config();
     }
 }
 
+/// The untouched editor curve — the "analytic" sentinel in the config'd
+/// profile specs (cce-designer's Edge Profile convention). For the wall curve
+/// identity-smooth IS the analytic smoothstep, so skipping it changes
+/// nothing; for the roll it would be a straight chamfer, not the analytic
+/// superellipse quadrant, so it must read as "no custom profile".
+pub const RELIEF_PROFILE_IDENTITY_SPEC: &str = "smooth;0.000:0.000,1.000:1.000";
+
+/// Parse-and-install the relief profiles config carries as ramp specs
+/// (`style.surface.relief.profile` / `edge_profile` → the style registry's
+/// `bevel_profile_spec` / `roll_profile_spec`). Absent, identity, or
+/// unparseable specs clear back to the analytic profiles.
+fn apply_relief_profile_config() {
+    let (wall, edge) = {
+        let reg = get_style_registry().read().unwrap();
+        (reg.get_string("bevel_profile_spec"), reg.get_string("roll_profile_spec"))
+    };
+    match parse_relief_profile_spec(wall.as_deref()) {
+        Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
+        None => clear_bevel_profile(),
+    }
+    match parse_relief_profile_spec(edge.as_deref()) {
+        Some((keys, smooth)) => set_roll_profile_keys(&keys, smooth),
+        None => clear_roll_profile(),
+    }
+}
+
+/// A config'd profile spec → installable keys. `None` (falling back to the
+/// analytic profile) for absent, identity-sentinel, or unparseable specs.
+fn parse_relief_profile_spec(spec: Option<&str>) -> Option<(Vec<(f32, f32)>, bool)> {
+    spec.filter(|s| *s != RELIEF_PROFILE_IDENTITY_SPEC).and_then(crate::widget::parse_ramp_spec)
+}
+
 fn mod_rest(rest: &str) -> &str {
     let rest = rest.trim();
     if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
@@ -5932,5 +5971,31 @@ mod tests {
         assert!(crate::layout::bevel_profile_slopes().is_none());
         assert!(crate::layout::bevel_profile_generation() > gen);
     }
+
+    #[test]
+    fn relief_profile_specs_parse_with_identity_sentinel() {
+        // Absent and identity-smooth mean "analytic" — nothing to install.
+        assert!(crate::layout::parse_relief_profile_spec(None).is_none());
+        assert!(crate::layout::parse_relief_profile_spec(Some(
+            crate::layout::RELIEF_PROFILE_IDENTITY_SPEC
+        ))
+        .is_none());
+        // Garbage falls back to analytic instead of poisoning the walls.
+        assert!(crate::layout::parse_relief_profile_spec(Some("not a spec")).is_none());
+        // A real curve installs: keys and line type round-trip.
+        let (keys, smooth) = crate::layout::parse_relief_profile_spec(Some(
+            "linear;0.000:0.200,0.500:1.000,1.000:0.800",
+        ))
+        .expect("custom spec parses");
+        assert!(!smooth);
+        assert_eq!(keys.len(), 3);
+        assert!((keys[1].0 - 0.5).abs() < 0.001 && (keys[1].1 - 1.0).abs() < 0.001);
+        // Identity under a LINEAR line type is a real profile (a straight
+        // chamfer), not the sentinel — only the smooth spelling is analytic.
+        assert!(crate::layout::parse_relief_profile_spec(Some(
+            "linear;0.000:0.000,1.000:1.000"
+        ))
+        .is_some());
+    }
 }