GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
+ }
+}