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

commita66349d5dd2fabb1cb32c46dc339f36f05a802a6
parent8dcaf0c92e
authorLucas Galante <[email protected]>
date2026-08-18 14:07
relief_spec: the (relief) config value type + cce-relief --key editing

A single-string relief material — 'w=<px> d=<depth> k=<s>,<b>,<c>
p=<ramp spec>' (ReliefSpec, parse/serialize round-trip tested) — so any
config key can carry its own material instead of following the DE-wide
style.surface.relief keys. layout::install_wall_profile_spec is the
consumer entry point: an app whose feature carries a (relief) value
installs its wall profile process-wide (same identity/unparseable
filtering as the config loader).

cce-relief gains --key <dotted.key>: seeds width/depth/wall knobs from
that key's value (installing it live so the preview shows it from the
first frame), and Save serializes the whole material back into that one
key — the DE-wide keys untouched. Verified end-to-end in a shadow
session: seed, knob scroll, Save, and the written value re-parsed.

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

 src/bin/cce-relief.rs | 103 +++++++++++++++++++++++++++++++++++++++++----
 src/config.rs         |  14 ++++++-
 src/layout.rs         |  12 ++++++
 src/lib.rs            |   1 +
 src/relief_spec.rs    | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 234 insertions(+), 9 deletions(-)

diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 492a8c0..b986d1a 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -25,6 +25,15 @@
 //! or, with `--config <path>`, to that file instead (a per-app override
 //! like cce-designer's), which also seeds the knobs/depth/width on open.
 //!
+//! `--key <dotted.key>` edits a single `(relief)` VALUE in place instead
+//! (`cce_ui::relief_spec::ReliefSpec` — width/depth/wall knobs/wall
+//! profile folded into one string), e.g.
+//! `cce-relief --key style.surface.desktop.line_relief` for the desktop
+//! grid's lines. Seeds come from that key, edits still preview live, and
+//! Save rewrites only that key — the DE-wide material is untouched. The
+//! edge section is not part of a `(relief)` value; a feature material has
+//! one wall curve.
+//!
 //! The curve family is the two-exponent rational ease
 //! `h(w) = w^a / (w^a + (1-w)^b)` over a bias pre-warp `w = v^g` — monotone,
 //! endpoint-exact, with the shoulder (a) and base fillet (b) shaped
@@ -456,6 +465,12 @@ struct BevelPopup {
     /// specific config file (a per-app override like cce-designer's), else
     /// the shared config.kdl. Knob/depth/width seeds prefer this file.
     config_path: std::path::PathBuf,
+    /// `--key <dotted.key>`: edit a single `(relief)` VALUE in place —
+    /// Save serializes width/depth/wall knobs/wall profile into that one
+    /// key instead of the `style.surface.relief.*` material keys, and the
+    /// seeds come from it. The edge section still previews but is not part
+    /// of a `(relief)` value (a feature material has one wall curve).
+    target_key: Option<String>,
     /// Short label for a retargeted config ("cce-designer"), shown in the
     /// title and status so it's obvious which material is being edited.
     target_label: Option<String>,
@@ -1026,6 +1041,33 @@ impl BevelPopup {
     /// triples ride along so this editor reopens where you left it.
     fn save_to_config(&mut self) {
         let p = self.config_path.to_string_lossy().into_owned();
+        // `--key` mode: the whole material folds into ONE `(relief)` value
+        // at that key — width, depth, the wall curve, and the knob triple
+        // behind it (so reopening with --key seeds these sliders). The edge
+        // section is not part of a feature material; an untouched analytic
+        // wall writes no profile at all.
+        if let Some(key) = self.target_key.clone() {
+            let spec = cce_ui::relief_spec::ReliefSpec {
+                width: self.width_slider.inner().get_scaled_value(),
+                depth: Some(self.depth_slider.inner().get_scaled_value()),
+                knobs: Some(self.wall.values()),
+                profile: self.wall.custom.then(|| self.wall.last_spec.clone()),
+            };
+            let ok = cce_ui::config::write_config_value_typed(
+                &p,
+                &key,
+                &spec.serialize(),
+                "style",
+                Some("relief"),
+            );
+            self.status = if ok {
+                println!("saved {key} -> {p}");
+                format!("Saved — {key} holds this material.")
+            } else {
+                "Save FAILED — see config.kdl permissions.".to_string()
+            };
+            return;
+        }
         let depth = format!("{:.3}", self.depth_slider.inner().get_scaled_value());
         let width = format!("{:.2}", self.width_slider.inner().get_scaled_value());
         let knob_str = |k: &ProfileKnobs| {
@@ -1092,11 +1134,15 @@ impl Application for BevelPopup {
         let shared_path = cce_ui::config::get_config_path();
         let mut config_path = shared_path.clone();
         let args: Vec<String> = std::env::args().collect();
+        let mut target_key: Option<String> = None;
         let mut i = 1;
         while i < args.len() {
             if args[i] == "--config" && i + 1 < args.len() {
                 config_path = std::path::PathBuf::from(&args[i + 1]);
                 i += 1;
+            } else if args[i] == "--key" && i + 1 < args.len() {
+                target_key = Some(args[i + 1].clone());
+                i += 1;
             }
             i += 1;
         }
@@ -1124,12 +1170,35 @@ impl Application for BevelPopup {
         let rel_f32 = |k: &str| {
             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_f64()).map(|f| f as f32)
         };
+        // `--key` seeds: the single `(relief)` value at that key wins over
+        // both the target file's material keys and the registry. Installing
+        // it live BEFORE the knob structs are built means the preview shows
+        // the key's material from the first frame, and the wall's
+        // `installed` flag reads the truth from the registry as usual.
+        let key_spec = target_key.as_ref().and_then(|k| {
+            std::fs::read_to_string(&config_path)
+                .ok()
+                .map(|c| cce_ui::config::parse_kdl_to_json(&c))
+                .and_then(|v| v.pointer(&format!("/{}", k.replace('.', "/"))).cloned())
+                .and_then(|v| v.as_str().map(String::from))
+                .and_then(|s| cce_ui::relief_spec::ReliefSpec::parse(&s))
+        });
+        if let Some(ks) = &key_spec {
+            if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
+                reg.set_float("bevel_width", ks.width);
+                if let Some(d) = ks.depth {
+                    reg.set_float("bevel_depth", d);
+                }
+            }
+            cce_ui::layout::install_wall_profile_spec(ks.profile.as_deref());
+        }
         let (wall_seed, edge_seed) = {
             let reg = cce_ui::layout::get_style_registry().read().unwrap();
             (
-                rel_str("profile_knobs")
-                    .as_deref()
-                    .and_then(parse_knobs)
+                key_spec
+                    .as_ref()
+                    .and_then(|s| s.knobs)
+                    .or_else(|| rel_str("profile_knobs").as_deref().and_then(parse_knobs))
                     .or_else(|| reg.get_string("bevel_profile_knobs").as_deref().and_then(parse_knobs)),
                 rel_str("edge_knobs")
                     .as_deref()
@@ -1138,8 +1207,24 @@ impl Application for BevelPopup {
             )
         };
 
-        let depth = rel_f32("depth").unwrap_or_else(cce_ui::layout::bevel_depth);
-        let width = rel_f32("width").unwrap_or_else(cce_ui::layout::bevel_width);
+        let depth = key_spec
+            .as_ref()
+            .and_then(|s| s.depth)
+            .or_else(|| rel_f32("depth"))
+            .unwrap_or_else(cce_ui::layout::bevel_depth);
+        let width = key_spec
+            .as_ref()
+            .map(|s| s.width)
+            .or_else(|| rel_f32("width"))
+            .unwrap_or_else(cce_ui::layout::bevel_width);
+        // A key target labels the window by the key, not the file.
+        let target_label = match &target_key {
+            Some(k) => {
+                let parts: Vec<&str> = k.split('.').collect();
+                Some(parts[parts.len().saturating_sub(2)..].join("."))
+            }
+            None => target_label,
+        };
         let (dmin, dmax) = DEPTH_RANGE;
         let (wmin, wmax) = WIDTH_RANGE;
         // The persisted per-app plate opacity, falling back to the DE look.
@@ -1188,11 +1273,13 @@ impl Application for BevelPopup {
             save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
             reset_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Reset"),
             plate_opacity,
-            status: match &target_label {
-                Some(l) => format!("Edits apply live; Save writes {l}'s config."),
-                None => "Edits apply live; Save writes config.kdl.".to_string(),
+            status: match (&target_key, &target_label) {
+                (Some(_), Some(l)) => format!("Edits apply live; Save writes the {l} key."),
+                (None, Some(l)) => format!("Edits apply live; Save writes {l}'s config."),
+                _ => "Edits apply live; Save writes config.kdl.".to_string(),
             },
             config_path,
+            target_key,
             target_label,
             ui_context: cce_ui::context::UiContext::new(),
             width: 520,
diff --git a/src/config.rs b/src/config.rs
index 4bf266f..f2fc67e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -719,7 +719,19 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
 #[cfg(test)]
 mod tests {
     use super::*;
-    
+
+
+    #[test]
+    fn relief_annotated_string_passes_through() {
+        // The (relief) custom value type: an annotated string prop must
+        // survive kdl_to_json as a plain JSON string at its pointer.
+        let content = "style {\n    surface {\n        desktop gap_width=(i64)16 line_relief=(relief)\"w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000\"\n    }\n}\n";
+        let val = parse_kdl_to_json(content);
+        assert_eq!(
+            val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
+            Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
+        );
+    }
 
     #[test]
     fn test_nested_parsing() {
diff --git a/src/layout.rs b/src/layout.rs
index 2a0b6fe..8287c3a 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1153,6 +1153,18 @@ fn parse_relief_profile_spec(spec: Option<&str>) -> Option<(Vec<(f32, f32)>, boo
     spec.filter(|s| *s != RELIEF_PROFILE_IDENTITY_SPEC).and_then(crate::widget::parse_ramp_spec)
 }
 
+/// Install (or clear back to analytic) the WALL profile from a ramp spec —
+/// the entry point for a `(relief)` config value's profile
+/// ([`crate::relief_spec::ReliefSpec`]): an app whose feature carries its
+/// own material installs it process-wide here. Same identity/unparseable
+/// filtering as the config path above.
+pub fn install_wall_profile_spec(spec: Option<&str>) {
+    match parse_relief_profile_spec(spec) {
+        Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
+        None => clear_bevel_profile(),
+    }
+}
+
 fn mod_rest(rest: &str) -> &str {
     let rest = rest.trim();
     if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
diff --git a/src/lib.rs b/src/lib.rs
index 552100e..04f4a8e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,6 +3,7 @@ pub mod widget;
 pub mod config;
 pub mod input;
 pub mod layout;
+pub mod relief_spec;
 pub mod wayland;
 pub mod protocol;
 pub mod engine;
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
new file mode 100644
index 0000000..68a980f
--- /dev/null
+++ b/src/relief_spec.rs
@@ -0,0 +1,113 @@
+//! The `(relief)` config value type: one relief material folded into a
+//! single string, so any config key can carry its own material and
+//! `cce-relief --key <dotted.key>` can edit it in place.
+//!
+//! Format: whitespace-separated `name=value` pairs, e.g.
+//!
+//! ```text
+//! w=6.0 d=0.350 k=0.500,0.500,0.500 p=0.000:0.000,0.032:0.001,...,1.000:1.000
+//! ```
+//!
+//! - `w` — wall/roll width in logical px (required)
+//! - `d` — light depth across the wall (`bevel_depth`; optional)
+//! - `k` — the editor's Shoulder/Base/Bias knob triple, a ride-along seed
+//!   so `cce-relief` reopens where it was left (optional)
+//! - `p` — the wall's height curve as the same ramp spec
+//!   `style.surface.relief.profile` carries (optional; absent or the
+//!   identity sentinel = the analytic profile)
+//!
+//! No value contains whitespace (ramp specs are `;`/`:`/`,`-delimited), so
+//! parsing is a plain split. Unknown pairs are skipped, not errors —
+//! forward compatibility for future fields.
+
+/// One parsed `(relief)` value. See the module doc for the string format.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ReliefSpec {
+    /// Wall/roll width in logical px.
+    pub width: f32,
+    /// Light depth (`bevel_depth`); `None` = keep the process's material.
+    pub depth: Option<f32>,
+    /// Shoulder/Base/Bias editor knobs behind `profile` — seed only, the
+    /// renderer never reads them.
+    pub knobs: Option<(f32, f32, f32)>,
+    /// Wall profile ramp spec; `None` = the analytic profile. May carry the
+    /// identity sentinel verbatim — installers filter it like the config
+    /// loader does.
+    pub profile: Option<String>,
+}
+
+impl ReliefSpec {
+    /// Parse a `(relief)` value. `None` when `w=` is absent or malformed —
+    /// a spec without a width says nothing drawable.
+    pub fn parse(s: &str) -> Option<Self> {
+        let mut width = None;
+        let mut depth = None;
+        let mut knobs = None;
+        let mut profile = None;
+        for tok in s.split_whitespace() {
+            let Some((k, v)) = tok.split_once('=') else { continue };
+            match k {
+                "w" => width = v.parse::<f32>().ok().filter(|w| w.is_finite() && *w >= 0.0),
+                "d" => depth = v.parse::<f32>().ok().filter(|d| d.is_finite()),
+                "k" => {
+                    let mut it = v.splitn(3, ',').map(|p| p.parse::<f32>().ok());
+                    if let (Some(Some(a)), Some(Some(b)), Some(Some(c))) =
+                        (it.next(), it.next(), it.next())
+                    {
+                        knobs = Some((a, b, c));
+                    }
+                }
+                "p" => profile = Some(v.to_string()),
+                _ => {}
+            }
+        }
+        Some(Self { width: width?, depth, knobs, profile })
+    }
+
+    /// The string `parse` reads back — what `cce-relief --key` saves.
+    pub fn serialize(&self) -> String {
+        let mut out = format!("w={:.2}", self.width);
+        if let Some(d) = self.depth {
+            out.push_str(&format!(" d={d:.3}"));
+        }
+        if let Some((a, b, c)) = self.knobs {
+            out.push_str(&format!(" k={a:.3},{b:.3},{c:.3}"));
+        }
+        if let Some(p) = &self.profile {
+            out.push_str(&format!(" p={p}"));
+        }
+        out
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_full() {
+        let spec = ReliefSpec {
+            width: 6.0,
+            depth: Some(0.35),
+            knobs: Some((0.8, 0.2, 0.5)),
+            profile: Some("0.000:0.000,0.500:0.700,1.000:1.000".to_string()),
+        };
+        assert_eq!(ReliefSpec::parse(&spec.serialize()), Some(spec));
+    }
+
+    #[test]
+    fn width_only_and_unknown_pairs_skip() {
+        let spec = ReliefSpec::parse("w=4 future=stuff junk").unwrap();
+        assert_eq!(spec.width, 4.0);
+        assert_eq!(spec.depth, None);
+        assert_eq!(spec.knobs, None);
+        assert_eq!(spec.profile, None);
+    }
+
+    #[test]
+    fn missing_or_bad_width_rejects() {
+        assert_eq!(ReliefSpec::parse("d=0.3"), None);
+        assert_eq!(ReliefSpec::parse("w=-1"), None);
+        assert_eq!(ReliefSpec::parse(""), None);
+    }
+}