GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor: the relief is two shapes — relief { wall … ; edge … }
The six geometry keys under style.surface.relief were flat, with the
wall unnamed (height, profile, profile_knobs) and the edge prefixed
(edge_height, edge_profile, edge_knobs), which read as one shape with an
"edge" variant. A carve's wall and a plate's perimeter roll are two
shapes shaded two ways; each is a node now, carrying the same three
keys: height (a length), profile (its ramp spec) and knobs (cce-relief's
sliders). width and depth stay on the relief node, shared by both.
The registry keys did not move, so nothing downstream of the registry
knows. The flat spellings survive as aliases in the flatten table;
`prefer_relief_wall_edge` drops a flat one whenever its node spelling is
present, so a file carrying both is decided by the node rather than by
line order. `wall` and `edge` join PROP_NODES so their keys write as
properties, and `config::remove_kdl_in_memory` / `remove_config_value`
are new: cce-relief's Save writes the node spellings and removes the
flat ones, migrating a file the first time it is saved. Its reader takes
the node spelling first and the flat one after, the same precedence.
Tests: relief_wall_and_edge_nodes_flatten_to_the_legacy_registry_keys,
the_node_spelling_of_a_relief_key_wins_over_the_flat_one, and
relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 49 +++++++++---
src/bin/cce-relief.rs | 51 +++++++++----
src/color.rs | 2 +-
src/config.rs | 111 ++++++++++++++++++++++++++-
src/layout.rs | 154 +++++++++++++++++++++++++++++++++-----
src/relief_spec.rs | 2 +-
src/widget/input/bevel_preview.rs | 2 +-
7 files changed, 323 insertions(+), 48 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7fbe98c..659f07b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -670,21 +670,52 @@ adds the bridge to real lengths, in two parts:
beside `scale::set_scale_factor` (`units::set_metric`); apps read
`units::metric()`, `units::mm(v)`, or `Len::to_px()`.
-**Relief has a real depth axis now.** `style.surface.relief.width` (the wall's
-run) and the new **`height`** (a carve's drop) and **`edge_height`** (the plate
-roll's rise) are all lengths — `height=(mm)0.3` is honest geometry, resolved
-through the metric. Unset, a carve drops `relief_shade::RECESS_DEPTH` (0.6) of
-its wall (saturating at the DE roll width) and the roll is a quarter-round of
-radius width — the look every config had. `style.surface.relief.depth` is NOT a
-length: it is the light strength (`bevel_depth` → `Finish.strength`), and
-**`light`** is its honest alias. `layout::carve_depth_px` states the drop rule
+**The relief is two shapes, and the config says which (2026-09-28).** A
+**wall** is a carve's side — a recess, boss, ridge or trough cut into a
+surface: buttons, wells, text boxes, the rows of a params pane — shaded as a
+translucent light-and-shadow overlay on whatever is under it. An **edge** is
+a plate's perimeter roll, the face curving down to its silhouette, shaded as a
+multiply on the plate's own fill plus a specular crest. Each is a node under
+`style.surface.relief` with the same three keys:
+
+```kdl
+relief depth=0.15 width=9.3 {
+ wall height=(mm)0.3 profile="smooth;…" knobs=(bevel)"0.5,0.5,0.5"
+ edge height=4.0 profile="smooth;…" knobs=(bevel)"0.5,0.5,0.5"
+}
+```
+
+`width` (the run of both, one number — see the roll-width note below) and
+`depth` (the light; `light` is its honest alias — NOT a length, it is
+`bevel_depth` → `Finish.strength`) stay on the node itself, since both
+shapes share them. `height` is a length — the wall's drop, the edge's rise —
+and `height=(mm)0.3` is honest geometry resolved through the metric; unset, a
+carve drops `relief_shade::RECESS_DEPTH` (0.6) of its wall (saturating at the
+DE roll width) and the roll is a quarter-round of radius width, the look every
+config had. `profile` is the curve as a ramp spec (absent or the identity
+sentinel = the analytic curve: smoothstep for a wall, the superellipse
+quadrant for an edge), and `knobs` is cce-relief's slider triple behind it,
+which only the editor reads. The registry keys never moved — `bevel_height`
+/ `roll_height`, `bevel_profile_spec` / `roll_profile_spec`,
+`bevel_profile_knobs` / `roll_profile_knobs` — so nothing downstream of the
+registry knows. Until 2026-09-28 the six were flat on the node with the wall
+UNNAMED (`height`, `profile`, `profile_knobs`) and the edge prefixed
+(`edge_height`, `edge_profile`, `edge_knobs`), which read as one shape with an
+"edge" variant rather than two shapes; those spellings survive as aliases in
+`layout.rs`'s flatten table, and `prefer_relief_wall_edge` drops a flat one
+whenever its node spelling is present, so a file carrying both is decided by
+the node and not by line order (`the_node_spelling_of_a_relief_key_wins_over_the_flat_one`).
+`cce-relief`'s Save writes the node spellings and REMOVES the flat ones
+(`config::remove_config_value`), so a file migrates the first time it is
+saved; `wall` and `edge` are `PROP_NODES` members so their keys land as
+properties. `layout::carve_depth_px` states the drop rule
once for the tessellator's CSG features and, through `WindowInfo.relief_meta`,
the shader's free carves; `carve_depth_ratio` / `roll_height_ratio` feed the
shading twin (`Finish.carve_depth` / `roll_height`). A `(relief)` value
carries the drop as `h=` (a length: `h=0.5mm`, or bare px) beside `w=` and
`d=` (light; `l=` reads as an alias). `cce-relief`'s Height knob is the editor:
its section's depth numbers read in mm when the metric is real, and Save
-writes `height` as a `(mm)` length then, px otherwise.
+writes `wall.height` as a `(mm)` length then, px otherwise.
**There is one roll width.** `style.surface.relief.width` is the run of every
roll and wall: the root plate's perimeter (`PlateSpec::window`), a `PlateSpec`
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 61b4085..16872a9 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -24,8 +24,8 @@
//! **Height** (the wall's drop, logical px; 0 = follow the width at the
//! analytic ratio). Height is the fabrication axis: the section's depth
//! numbers read in millimetres whenever the display metric is real
-//! (`cce_ui::units`), and Save writes `style.surface.relief.height` as a
-//! `(mm)` length then, px otherwise.
+//! (`cce_ui::units`), and Save writes `style.surface.relief.wall.height` as
+//! a `(mm)` length then, px otherwise.
//!
//! Every edit applies live to this process (the
//! popup's own plate, wells, and buttons ARE the preview) and logs the
@@ -1303,23 +1303,33 @@ impl BevelPopup {
let len = self.height_len(h);
cce_ui::config::write_config_value_typed(
&p,
- "style.surface.relief.height",
+ "style.surface.relief.wall.height",
&cce_ui::units::fmt_num(len.value),
"style",
Some(len.unit.suffix()),
)
} else {
- w("style.surface.relief.height", "0")
+ w("style.surface.relief.wall.height", "0")
};
let material_ok = self.save_material(&p);
+ // The two shapes are nodes — `relief { wall … ; edge … }` — and a
+ // save MIGRATES: the flat spellings this editor wrote until
+ // 2026-09-28 (`height`, `profile`, `profile_knobs` for the wall,
+ // `edge_*` for the edge) come off the file, or the node spelling
+ // would shadow a stale line forever. `remove_config_value` is true
+ // for a key that is not there, so a clean file costs nothing.
let ok = height_ok
& material_ok
& w("style.surface.relief.depth", &depth)
& w("style.surface.relief.width", &width)
- & w("style.surface.relief.profile", &self.wall.last_spec)
- & w("style.surface.relief.edge_profile", &self.edge.last_spec)
- & wb("style.surface.relief.profile_knobs", &knob_str(&self.wall))
- & wb("style.surface.relief.edge_knobs", &knob_str(&self.edge));
+ & w("style.surface.relief.wall.profile", &self.wall.last_spec)
+ & w("style.surface.relief.edge.profile", &self.edge.last_spec)
+ & wb("style.surface.relief.wall.knobs", &knob_str(&self.wall))
+ & wb("style.surface.relief.edge.knobs", &knob_str(&self.edge));
+ let migrated = ["height", "edge_height", "profile", "edge_profile", "profile_knobs", "edge_knobs"]
+ .iter()
+ .all(|k| cce_ui::config::remove_config_value(&p, &format!("style.surface.relief.{k}")));
+ let ok = ok & migrated;
self.status = if ok {
println!("saved {p}");
"Saved — apps pick the material up on start.".to_string()
@@ -1378,16 +1388,25 @@ impl Application for BevelPopup {
.flatten()
.map(|c| cce_ui::config::parse_kdl_to_json(&c));
let target_relief = target_json.as_ref().and_then(|v| v.pointer("/style/surface/relief").cloned());
- let rel_str = |k: &str| {
- target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_str().map(String::from))
- };
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)
};
+ // A key of one of the relief's two SHAPES: `relief { wall k=… }` first,
+ // then the flat legacy spelling a file saved before 2026-09-28 still
+ // carries (`height` / `profile` / `profile_knobs` for the wall,
+ // `edge_*` for the edge) — the same precedence the registry load
+ // applies through `layout::prefer_relief_wall_edge`.
+ let rel_shape = |node: &str, k: &str, legacy: &str| {
+ let r = target_relief.as_ref()?;
+ r.get(node).and_then(|n| n.get(k)).or_else(|| r.get(legacy)).cloned()
+ };
+ let rel_shape_str = |node: &str, k: &str, legacy: &str| {
+ rel_shape(node, k, legacy).and_then(|v| v.as_str().map(String::from))
+ };
// A length key: a bare number is logical px, a `(mm)`-annotated one
// arrives as the string "0.3mm" and resolves through the metric.
- let rel_len = |k: &str| {
- target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| {
+ let rel_shape_len = |node: &str, k: &str, legacy: &str| {
+ rel_shape(node, k, legacy).and_then(|v| {
v.as_f64()
.map(|f| f as f32)
.or_else(|| v.as_str().and_then(cce_ui::units::Len::parse).map(|l| l.to_px()))
@@ -1424,9 +1443,9 @@ impl Application for BevelPopup {
key_spec
.as_ref()
.and_then(|s| s.knobs)
- .or_else(|| rel_str("profile_knobs").as_deref().and_then(parse_knobs))
+ .or_else(|| rel_shape_str("wall", "knobs", "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")
+ rel_shape_str("edge", "knobs", "edge_knobs")
.as_deref()
.and_then(parse_knobs)
.or_else(|| reg.get_string("roll_profile_knobs").as_deref().and_then(parse_knobs)),
@@ -1443,7 +1462,7 @@ impl Application for BevelPopup {
.as_ref()
.and_then(|s| s.height)
.map(|l| l.to_px())
- .or_else(|| rel_len("height"))
+ .or_else(|| rel_shape_len("wall", "height", "height"))
.or_else(cce_ui::layout::bevel_height)
.unwrap_or(0.0);
let width = key_spec
diff --git a/src/color.rs b/src/color.rs
index 6bb747a..ac58098 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1853,7 +1853,7 @@ pub fn set_plate_border_thickness(t: f32) {
///
/// **This is `style.surface.relief.width`** — the one roll width, the same
/// number the root plate rolls over, every control wall runs, and
-/// `relief.edge_height` is a rise against. Until 2026-09-28 it was a second
+/// `relief.edge.height` is a rise against. Until 2026-09-28 it was a second
/// width of its own (`style.surface.plate.bevel_width`, default 6 against the
/// relief's 9.3), so a `PlateSpec` pane plate and an `append_widget_plate`
/// pane plate rolled over different widths in one window, the designer's
diff --git a/src/config.rs b/src/config.rs
index 2dd97e1..5d86d2c 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -315,9 +315,60 @@ const PROP_NODES: &[&str] = &[
"gestures", "key_bindings", "pointer_bind", "gesture_bind",
"button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
"status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
- "menubar", "statusbar", "node", "relief", "frost", "finish"
+ "menubar", "statusbar", "node", "relief", "frost", "finish",
+ // The relief's two shapes (`relief { wall height=… profile=… knobs=… ; edge … }`).
+ "wall", "edge"
];
+fn get_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
+ if path.is_empty() {
+ return None;
+ }
+ let idx = doc.nodes().iter().position(|n| n.name().value() == path[0])?;
+ let node = &mut doc.nodes_mut()[idx];
+ if path.len() == 1 {
+ Some(node)
+ } else {
+ get_node_mut(node.children_mut().as_mut()?, &path[1..])
+ }
+}
+
+/// Remove `key` from the document — a property off its `PROP_NODES` node, or
+/// the whole node otherwise — creating nothing on the way. `true` when
+/// something was removed; a key that is not there is `false`, so a writer
+/// can tell "migrated" from "was already clean".
+pub fn remove_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str) -> bool {
+ let parts: Vec<&str> = key.split('.').collect();
+ let Some(leaf) = parts.last().copied() else {
+ return false;
+ };
+ // A PROP_NODES parent holds its keys as properties — but it may also
+ // hold child NODES (`relief { wall … }`), so a leaf that is not among
+ // the properties is looked for among the children before giving up.
+ let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
+ if is_property {
+ let Some(node) = get_node_mut(doc, &parts[..parts.len() - 1]) else {
+ return false;
+ };
+ let before = node.entries().len();
+ node.entries_mut().retain(|e| e.name().map(|n| n.value()) != Some(leaf));
+ if node.entries().len() != before {
+ return true;
+ }
+ }
+ let parent: &mut kdl::KdlDocument = if parts.len() == 1 {
+ doc
+ } else {
+ match get_node_mut(doc, &parts[..parts.len() - 1]).and_then(|n| n.children_mut().as_mut()) {
+ Some(children) => children,
+ None => return false,
+ }
+ };
+ let before = parent.nodes().len();
+ parent.nodes_mut().retain(|n| n.name().value() != leaf);
+ parent.nodes().len() != before
+}
+
fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
if path.is_empty() {
return None;
@@ -735,6 +786,22 @@ pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &
write_config_value_typed(path, key, value, default_section, None)
}
+/// Remove `key` from the config file at `path` ([`remove_kdl_in_memory`]),
+/// writing only when something was removed. `true` when the key is gone —
+/// absent to begin with, or removed and written — so a writer migrating a
+/// legacy spelling can fold it into its own success.
+pub fn remove_config_value(path: &str, key: &str) -> bool {
+ let content = fs::read_to_string(path).unwrap_or_default();
+ let mut doc = match content.parse::<kdl::KdlDocument>() {
+ Ok(d) => d,
+ Err(_) => return true,
+ };
+ if remove_kdl_in_memory(&mut doc, key) {
+ return safe_write(path, &doc.to_string());
+ }
+ true
+}
+
/// [`write_config_value`] with an explicit type annotation — see
/// [`update_kdl_in_memory_typed`].
pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
@@ -924,6 +991,48 @@ mod tests {
assert_eq!(prop_val.as_f64().unwrap(), 0.75);
}
+ /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
+ /// child nodes of the existing `relief` node — the node keeps its own
+ /// `depth=` / `width=` properties and grows one child block — and the
+ /// reload path reads them back at the nested paths the registry maps.
+ /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
+ /// which is how cce-relief's Save migrates a file in place.
+ #[test]
+ fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
+ let content = "style {\n surface {\n relief depth=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\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.wall.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.knobs", "0.4,0.5,0.6", "style", Some("bevel")));
+ // Migrate: the flat spellings come off, and a spelling that is not
+ // there reports nothing removed.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
+ let out = doc.to_string();
+ assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
+ assert!(out.contains("depth=(f64)0.15"), "the node keeps its properties: {out}");
+ assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
+ assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
+
+ let val = parse_kdl_to_json(&out);
+ let relief = val.pointer("/style/surface/relief").unwrap();
+ assert_eq!(relief.get("depth").and_then(|v| v.as_f64()), Some(0.15));
+ assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
+ assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
+ assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
+ assert_eq!(relief.pointer("/edge/knobs").and_then(|v| v.as_str()), Some("0.4,0.5,0.6"));
+ assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
+ // A whole node comes off too.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
+ let val = parse_kdl_to_json(&doc.to_string());
+ assert!(val.pointer("/style/surface/relief/edge").is_none());
+ assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
+ }
+
#[test]
fn relief_keys_write_as_properties_and_round_trip() {
// `relief` is a PROP_NODES member: style.surface.relief.* must land as
diff --git a/src/layout.rs b/src/layout.rs
index 463bb17..5c102a2 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -247,18 +247,33 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
// the honest spelling, `depth` the one every config has.
"style.surface.relief.depth" | "style.surface.relief.light" | "window_manager.bevel_depth" => "bevel_depth",
"style.surface.relief.width" | "window_manager.bevel_width" => "bevel_width",
- // The geometric heights, both lengths (unit-aware): a carve's
- // drop and the plate roll's rise. Unset = follow the width.
- "style.surface.relief.height" => "bevel_height",
- "style.surface.relief.edge_height" => "roll_height",
- // Ramp-spec strings for the custom wall/roll profiles
- // (written by cce-relief, installed by reload_config).
- "style.surface.relief.profile" => "bevel_profile_spec",
- "style.surface.relief.edge_profile" => "roll_profile_spec",
- // cce-relief's slider positions behind those specs
- // ("shoulder,base,bias" — only the editor reads these).
- "style.surface.relief.profile_knobs" => "bevel_profile_knobs",
- "style.surface.relief.edge_knobs" => "roll_profile_knobs",
+ // The two SHAPES of the relief, each a node under it
+ // (2026-09-28): `wall` is a carve's wall — a recess, boss,
+ // ridge or trough cut into a surface — and `edge` is the
+ // plate's perimeter roll. Each carries `height` (a length:
+ // the carve's drop, the roll's rise; unset = follow the
+ // width), `profile` (the ramp spec of its curve, written by
+ // cce-relief, installed by reload_config) and `knobs`
+ // (cce-relief's "shoulder,base,bias" slider positions behind
+ // that spec — only the editor reads them). The registry keys
+ // keep their old names. The flat spellings on the second
+ // line of each pair are the pre-rename aliases
+ // (`height` / `profile` / `profile_knobs` were the wall's,
+ // `edge_*` the edge's); `prefer_relief_wall_edge` drops a
+ // flat one whenever its node spelling is present, so a config
+ // carrying both is decided by the node, not by line order.
+ "style.surface.relief.wall.height"
+ | "style.surface.relief.height" => "bevel_height",
+ "style.surface.relief.edge.height"
+ | "style.surface.relief.edge_height" => "roll_height",
+ "style.surface.relief.wall.profile"
+ | "style.surface.relief.profile" => "bevel_profile_spec",
+ "style.surface.relief.edge.profile"
+ | "style.surface.relief.edge_profile" => "roll_profile_spec",
+ "style.surface.relief.wall.knobs"
+ | "style.surface.relief.profile_knobs" => "bevel_profile_knobs",
+ "style.surface.relief.edge.knobs"
+ | "style.surface.relief.edge_knobs" => "roll_profile_knobs",
"style.container.section.depth" => "section_depth",
"style.surface.param.backdrop_compression" => "param_compression",
"style.surface.param.label_layout" => "param_label_layout",
@@ -380,10 +395,38 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
}
}
+/// The `wall` / `edge` node spellings of the relief geometry win over the
+/// flat legacy ones. Both flatten to ONE registry key
+/// (`relief.wall.height` and `relief.height` are both `bevel_height`), and
+/// `reload_config` loads the flat lines in the order the JSON hands them
+/// out, so a config carrying both spellings — a file cce-relief has saved
+/// once under the new names while an older line survives, or a per-app
+/// override written in the other spelling — would otherwise be decided by
+/// which line came first. Run on the parsed config before it is flattened.
+pub(crate) fn prefer_relief_wall_edge(val: &mut serde_json::Value) {
+ const PAIRS: &[(&str, &str, &str)] = &[
+ ("height", "wall", "height"),
+ ("profile", "wall", "profile"),
+ ("profile_knobs", "wall", "knobs"),
+ ("edge_height", "edge", "height"),
+ ("edge_profile", "edge", "profile"),
+ ("edge_knobs", "edge", "knobs"),
+ ];
+ let Some(relief) = val.pointer_mut("/style/surface/relief").and_then(|v| v.as_object_mut()) else {
+ return;
+ };
+ for (flat, node, key) in PAIRS {
+ if relief.get(*node).and_then(|n| n.get(*key)).is_some() {
+ relief.remove(*flat);
+ }
+ }
+}
+
fn read_config() -> Option<String> {
let path = crate::config::get_config_path();
if let Ok(content) = std::fs::read_to_string(&path) {
- let val = crate::config::parse_kdl_to_json(&content);
+ let mut val = crate::config::parse_kdl_to_json(&content);
+ prefer_relief_wall_edge(&mut val);
let mut flat_props = String::new();
flatten_json_to_flat_props(&val, "", &mut flat_props);
return Some(flat_props);
@@ -394,7 +437,8 @@ fn read_config() -> Option<String> {
pub fn read_config_value(target_key: &str) -> Option<String> {
let path = crate::config::get_config_path();
if let Ok(content) = std::fs::read_to_string(&path) {
- let val = crate::config::parse_kdl_to_json(&content);
+ let mut val = crate::config::parse_kdl_to_json(&content);
+ prefer_relief_wall_edge(&mut val);
let mut flat_props = String::new();
flatten_json_to_flat_props(&val, "", &mut flat_props);
for line in flat_props.lines() {
@@ -1345,9 +1389,9 @@ pub fn reload_config() {
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.
+/// (`style.surface.relief.wall.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();
@@ -1837,7 +1881,7 @@ pub fn bevel_width() -> f32 {
}
/// A carve's geometric drop when the material pins one
-/// (`style.surface.relief.height`, a length — `(mm)0.3` resolves through
+/// (`style.surface.relief.wall.height`, a length — `(mm)0.3` resolves through
/// the display metric), in logical px. `None` = follow the wall width at the
/// analytic ratio ([`crate::scene::relief_shade::RECESS_DEPTH`]), the look
/// every config had before heights existed. A configured 0 reads as unset,
@@ -1851,7 +1895,7 @@ pub fn bevel_height() -> Option<f32> {
.filter(|h| h.is_finite() && *h > 0.0)
}
-/// The plate roll's rise when pinned (`style.surface.relief.edge_height`, a
+/// The plate roll's rise when pinned (`style.surface.relief.edge.height`, a
/// length), logical px. `None` = a quarter-round of radius `bevel_width`.
pub fn roll_height() -> Option<f32> {
lazy_init_style_registry();
@@ -6914,6 +6958,78 @@ mod tests {
assert!(padding >= 0.0);
}
+ /// The relief's two shapes are nodes — `relief { wall … ; edge … }` —
+ /// and each of their keys flattens to the registry key the flat legacy
+ /// spelling always did, so nothing downstream of the registry moved.
+ #[test]
+ fn relief_wall_and_edge_nodes_flatten_to_the_legacy_registry_keys() {
+ let val: serde_json::Value = serde_json::json!({
+ "style": { "surface": { "relief": {
+ "width": 9.3,
+ "wall": { "height": "0.3mm", "profile": "smooth;0:0,1:1", "knobs": "0.5,0.5,0.5" },
+ "edge": { "height": 4.0, "profile": "smooth;0:0,1:0.9", "knobs": "0.4,0.5,0.6" }
+ } } }
+ });
+ let mut flat = String::new();
+ flatten_json_to_flat_props(&val, "", &mut flat);
+ // A flat line is `key = value`, strings quoted — what reload_config parses.
+ let value = |k: &str| {
+ flat.lines()
+ .find(|l| l.starts_with(&format!("{k} = ")))
+ .map(|l| l[k.len() + 3..].trim_matches('"').to_string())
+ };
+ assert_eq!(value("bevel_height").as_deref(), Some("0.3mm"), "{flat}");
+ assert_eq!(value("roll_height").as_deref(), Some("4"), "{flat}");
+ assert_eq!(value("bevel_profile_spec").as_deref(), Some("smooth;0:0,1:1"), "{flat}");
+ assert_eq!(value("roll_profile_spec").as_deref(), Some("smooth;0:0,1:0.9"), "{flat}");
+ assert_eq!(value("bevel_profile_knobs").as_deref(), Some("0.5,0.5,0.5"), "{flat}");
+ assert_eq!(value("roll_profile_knobs").as_deref(), Some("0.4,0.5,0.6"), "{flat}");
+ // And the flat legacy spellings still land on the same keys.
+ let old: serde_json::Value = serde_json::json!({
+ "style": { "surface": { "relief": {
+ "height": 2.0, "edge_height": 3.0, "profile": "a", "edge_profile": "b",
+ "profile_knobs": "1,1,1", "edge_knobs": "2,2,2"
+ } } }
+ });
+ let mut flat = String::new();
+ flatten_json_to_flat_props(&old, "", &mut flat);
+ for k in ["bevel_height", "roll_height", "bevel_profile_spec", "roll_profile_spec", "bevel_profile_knobs", "roll_profile_knobs"] {
+ assert!(flat.lines().any(|l| l.starts_with(&format!("{k} = "))), "{k} missing: {flat}");
+ }
+ }
+
+ /// A config carrying both spellings of one relief key is decided by the
+ /// node spelling, whichever line the file wrote first — the flat pass
+ /// loads in JSON order, and `prefer_relief_wall_edge` is what makes the
+ /// order irrelevant. Keys with no node spelling are left alone.
+ #[test]
+ fn the_node_spelling_of_a_relief_key_wins_over_the_flat_one() {
+ let mut val: serde_json::Value = serde_json::json!({
+ "style": { "surface": { "relief": {
+ "depth": 0.15,
+ "height": 2.0, "wall": { "height": 5.0 },
+ "edge_profile": "old", "edge": { "profile": "new" },
+ "profile_knobs": "0.1,0.1,0.1"
+ } } }
+ });
+ prefer_relief_wall_edge(&mut val);
+ let relief = val.pointer("/style/surface/relief").unwrap();
+ assert!(relief.get("height").is_none(), "the flat wall height yields: {relief}");
+ assert!(relief.get("edge_profile").is_none(), "the flat edge profile yields: {relief}");
+ assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_f64()), Some(5.0));
+ assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some("new"));
+ assert_eq!(relief.get("profile_knobs").and_then(|v| v.as_str()), Some("0.1,0.1,0.1"), "no node spelling: kept");
+ assert_eq!(relief.get("depth").and_then(|v| v.as_f64()), Some(0.15));
+ let mut flat = String::new();
+ flatten_json_to_flat_props(&val, "", &mut flat);
+ assert_eq!(flat.lines().filter(|l| l.starts_with("bevel_height = ")).count(), 1, "{flat}");
+ assert!(flat.contains("bevel_height = 5"), "{flat}");
+ // A config with no relief block at all is untouched.
+ let mut none: serde_json::Value = serde_json::json!({ "style": {} });
+ prefer_relief_wall_edge(&mut none);
+ assert_eq!(none, serde_json::json!({ "style": {} }));
+ }
+
/// The control rung's key flattens to `control_corner_radius`, beside a
/// widget's own override — the config shape `control corner_radius=8 { button corner_radius=10 }`.
#[test]
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
index 8afe3b4..28c299c 100644
--- a/src/relief_spec.rs
+++ b/src/relief_spec.rs
@@ -18,7 +18,7 @@
//! - `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
+//! `style.surface.relief.wall.profile` carries (optional; absent or the
//! identity sentinel = the analytic profile)
//!
//! No value contains whitespace (ramp specs are `;`/`:`/`,`-delimited), so
diff --git a/src/widget/input/bevel_preview.rs b/src/widget/input/bevel_preview.rs
index b08688f..8472e23 100644
--- a/src/widget/input/bevel_preview.rs
+++ b/src/widget/input/bevel_preview.rs
@@ -28,7 +28,7 @@ pub fn bevel_ease(shoulder: f32, base: f32, bias: f32, v: f32) -> f32 {
}
/// Parse a "shoulder,base,bias" knob triple (the `(bevel)` value format, and
-/// what cce-relief persists as `profile_knobs` / `edge_knobs`).
+/// what cce-relief persists as `relief.wall.knobs` / `relief.edge.knobs`).
pub fn parse_bevel_knobs(s: &str) -> Option<(f32, f32, f32)> {
let mut it = s.split(',').map(|p| p.trim().parse::<f32>());
match (it.next(), it.next(), it.next()) {