GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Named materials in config, and cce-relief edits the finish and the frost (RFC material, step 4)
style.surface.material { <name> { color; frost …; finish … } } defines
materials; plate material=, plate { root material= } and
style.control.material bind them per rung. MaterialDef::resolve lays a
node over the rung's legacy material — unset fields fall back, no frost
child means opaque, a binding wins over the legacy keys, and a binding
to an undefined name warns and degrades to the legacy material.
Material::named hands an app any defined material. Nodes and bindings
are replaced wholesale on every load, so a reload forgets what config
no longer says. Frost::from_style follows the bound pane's recipe.
The DE finish's three fixed terms get config keys — relief.spec /
shininess / curvature — and the default frost's blur sigma is
plate.radius. The writer treats frost and finish as property nodes.
cce-relief grows two columns, Finish (Specular / Shininess / Curvature)
and Frost (Compression / Refraction / Blur radius), seeded from the pane
rung's effective material, applied live, and saved into the bound
material's node when the pane is bound, else into the DE keys — Save
never restructures a config that has no materials.
Tests: the designer's #05050840 / 0.6 / 0.3 spelled with the legacy
keys and as a bound resolve to the same Material; binding
semantics; the writer's key shape. No live config or backup carries a
material node or binding, so every one resolves as before.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 11 ++
docs/rfc-material.md | 53 +++++++---
src/bin/cce-relief.rs | 184 +++++++++++++++++++++++++++++++-
src/color.rs | 121 +++++++++++++++++++++
src/config.rs | 27 ++++-
src/scene/material.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++---
src/scene/mod.rs | 2 +-
7 files changed, 645 insertions(+), 36 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c77063c..781173d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -381,6 +381,17 @@ What this buys, and where the code is heading:
vertex from outside the display list falls to the no-recipe branch.
`examples/frost_pair.rs` is the visual test: three recipes in one window, run in a
shadow, measured in the RFC's step-3 note.
+- **Named materials in config** (RFC step 4): `style.surface.material { <name> { color;
+ frost …; finish … } }` and a binding per rung — `plate material="…"`, `plate { root
+ material="…" }`, `style.control.material` — resolved by `MaterialDef::resolve` over
+ the rung's legacy material (unset fields fall back; no `frost` child = opaque; a
+ binding wins over the legacy keys; an undefined name warns and degrades to legacy).
+ The DE finish's three fixed terms are `style.surface.relief.spec / shininess /
+ curvature`; the default frost's blur sigma is `style.surface.plate.radius`. cce-relief
+ edits them (Finish and Frost columns) and writes into the bound material's node or the
+ DE keys — never restructuring an unbound config. KDL trap when writing fixtures: two
+ nodes on one line need a `;`, and `a { b }` on one line is a parse error the loader
+ swallows into an empty document.
- **`style.surface.plate.refraction` (0..1, default 0) is the rim, and it buys
no legibility.** It is the answer to the other half of the question — not
"can I read this" but "is this an object". The roll is a real surface with a
diff --git a/docs/rfc-material.md b/docs/rfc-material.md
index 32e2793..b9186d3 100644
--- a/docs/rfc-material.md
+++ b/docs/rfc-material.md
@@ -238,36 +238,44 @@ sites, all of the form `color: …, blur: …`. Each becomes `material: Material
```kdl
style {
surface {
- material "glass" {
- color (rgba)"#05050840"
- frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
- finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+ material {
+ glass {
+ color (rgba)"#05050840"
+ frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
+ finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+ }
+ plastic {
+ color (rgba)"#26263380"
+ finish light=(f64)0.15 spec=(f64)0.25 shininess=(f64)12.0
+ }
}
- material "plastic" {
- color (rgba)"#26263380"
- finish light=(f64)0.15 spec=(f64)0.25 shininess=(f64)12.0
- }
- plate {
+ plate material="glass" { // the pane rung
root material="glass"
- material "glass" // the pane rung
}
- control material="plastic"
}
+ control material="plastic"
}
```
+(As built: the materials are CHILDREN of one `material` node, named by node name, not
+`material "glass"` with a string argument — the config converter keys objects by node
+name and a node's argument would be lost; and `control` is `style.control`, the node the
+control rung's other keys already live under.)
+
A `material` node with no `frost` child is `Opaque`. Missing `finish` keys take the rung
-default. A rung with no `material=` binding reads its material from the legacy keys below —
-which is how every existing config keeps its look.
+default; a missing `color` keeps the rung's tint. A rung with no `material=` binding reads
+its material from the legacy keys below — which is how every existing config keeps its
+look. `Material::named(name)` hands an app any defined material for its own surfaces.
### 5.2 Aliases: every existing key survives
| Existing key | Resolves into |
|---|---|
-| `style.surface.plate.color` / `.blur` / `.backdrop_compression` / `.refraction` | the pane rung's default material |
+| `style.surface.plate.color` / `.blur` / `.backdrop_compression` / `.refraction` / `.radius` | the pane rung's default material (`radius`, new: the default frost's blur sigma) |
| `style.surface.param.color`, `style.surface.plate.opacity` | the pane rung's tint |
| `style.surface.plate.root.color` / `.blur` | the root rung's default material |
| `style.surface.relief.depth` / `.light` | every rung's `finish.strength` (and the free carves') |
+| `style.surface.relief.spec` / `.shininess` / `.curvature` | the DE finish's other three terms (new; were literals) — every rung's, and the carves' |
| `style.surface.relief.width` / `.height` / `.edge_height` | unchanged: geometry, not material |
| `style.surface.control.fill` (and the per-widget fills) | the control rung's tint |
@@ -466,7 +474,22 @@ the scale-2 panel and the other two plates differ only by their own recipes.
batch count before/after on cce-designer's default view (expect +N for the flat frosted
quads, N small).
-**Step 4 — config and editor.**
+**Step 4 — config and editor.** *DONE 2026-09-20.* The named-material nodes and the three
+rung bindings (§ 5.1, in the child-node shape), `MaterialDef::resolve` over the rung's
+legacy material, `Material::named`, the DE finish keys `relief.spec / shininess /
+curvature` and the default frost's `plate.radius`; live reload replaces nodes and bindings
+wholesale. cce-relief grew two columns — Finish (Specular / Shininess / Curvature) and
+Frost (Compression / Refraction / Blur radius) — seeded from the pane rung's effective
+material, applied live, and saved into the bound material's node when the pane is bound,
+else into the DE keys: **Save never restructures a config that has no materials**; the
+named form is opted into by writing the binding. In `--key` mode the material sliders
+are not part of a `(relief)` value and are not written. *Exit:* no live config or backup
+carries a `material` node or binding (grep), so all resolve as before by construction;
+`named_material_round_trips_the_legacy_spelling` pins the designer's `#05050840` / 0.6 /
+0.3 as a bound `glass` to the SAME `Material` as the legacy keys (same Material, same
+bytes — steps 2–3), and `material_keys_write_as_frost_and_finish_props` pins the
+writer's shape. Not exercised: a Save click in the shadow (the utility window is taller
+than the headless output).
- `material "<name>"` nodes; per-rung `material=` bindings; the alias table (§ 5.2).
- `cce-relief` grows a Finish section (spec / shininess / curvature) and a Frost section,
and Save writes a named material.
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index d50b049..875fc35 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -73,6 +73,16 @@ const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
const DEPTH_RANGE: (f32, f32) = (0.0, 0.6);
const WIDTH_RANGE: (f32, f32) = (2.0, 24.0);
const HEIGHT_RANGE: (f32, f32) = (0.0, 24.0);
+/// The finish's three fixed terms and the frost recipe — the material
+/// sections (RFC material step 4). Specular strength, shininess exponent,
+/// curvature/AO strength; luminance compression, rim refraction, blur sigma
+/// in logical px.
+const SPEC_RANGE: (f32, f32) = (0.0, 1.5);
+const SHINE_RANGE: (f32, f32) = (1.0, 64.0);
+const CURV_RANGE: (f32, f32) = (0.0, 1.0);
+const COMP_RANGE: (f32, f32) = (0.0, 1.0);
+const REFR_RANGE: (f32, f32) = (0.0, 1.0);
+const RADIUS_RANGE: (f32, f32) = (0.0, 20.0);
/// Sample count for the spec written to config — enough that the 32-slot
/// renderer LUT sees the curve, few enough that the config line stays sane.
@@ -114,7 +124,7 @@ fn content_height(width: f32) -> f32 {
let button_h = 26.0;
let status_h = HEADER_FONT_SIZE + 4.0;
let fixed = knob_h + gap
- + 6.0 * (knob_h + gap)
+ + 9.0 * (knob_h + gap)
+ button_h + 8.0 + status_h + 2.0 * gap;
let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
+ 2.0 * CUT_MARGIN
@@ -455,6 +465,18 @@ struct BevelPopup {
depth_slider: Adapted<Slider>,
width_slider: Adapted<Slider>,
height_slider: Adapted<Slider>,
+ /// The Finish column: what the surface does under light beyond its
+ /// strength (`Light` above) — `scene::material::Finish`'s spec /
+ /// shininess / curvature. Applied live to the DE finish, or to the pane
+ /// rung's bound material when config binds one.
+ spec_slider: Adapted<Slider>,
+ shine_slider: Adapted<Slider>,
+ curv_slider: Adapted<Slider>,
+ /// The Frost column: the pane material's recipe — compression,
+ /// refraction, blur radius (`scene::material::Frost`). Same live target.
+ comp_slider: Adapted<Slider>,
+ refr_slider: Adapted<Slider>,
+ radius_slider: Adapted<Slider>,
save_button: Adapted<Button>,
/// Cancel = discard-and-close: edits are live only in THIS process, so
/// with nothing persisted, closing IS the discard (same as Escape).
@@ -997,7 +1019,7 @@ impl BevelPopup {
self.active_shape().curve()
}
- fn root_ids(&self) -> [WidgetId; 13] {
+ fn root_ids(&self) -> [WidgetId; 19] {
[
self.profile_dropdown.id(),
self.edge_dropdown.id(),
@@ -1010,12 +1032,18 @@ impl BevelPopup {
self.depth_slider.id(),
self.width_slider.id(),
self.height_slider.id(),
+ self.spec_slider.id(),
+ self.shine_slider.id(),
+ self.curv_slider.id(),
+ self.comp_slider.id(),
+ self.refr_slider.id(),
+ self.radius_slider.id(),
self.save_button.id(),
self.cancel_button.id(),
]
}
- fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 13] {
+ fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 19] {
[
self.profile_dropdown.as_ptr_mut(),
self.edge_dropdown.as_ptr_mut(),
@@ -1028,11 +1056,96 @@ impl BevelPopup {
self.depth_slider.as_ptr_mut(),
self.width_slider.as_ptr_mut(),
self.height_slider.as_ptr_mut(),
+ self.spec_slider.as_ptr_mut(),
+ self.shine_slider.as_ptr_mut(),
+ self.curv_slider.as_ptr_mut(),
+ self.comp_slider.as_ptr_mut(),
+ self.refr_slider.as_ptr_mut(),
+ self.radius_slider.as_ptr_mut(),
self.save_button.as_ptr_mut(),
self.cancel_button.as_ptr_mut(),
]
}
+ /// The pane rung's bound material name, when config binds one — the
+ /// target the material sliders edit and Save writes; `None` = the DE
+ /// keys (`style.surface.relief.*` for the finish, `style.surface.plate.*`
+ /// for the frost).
+ fn bound_material() -> Option<String> {
+ cce_ui::color::material_binding(cce_ui::scene::PlateRung::Pane)
+ }
+
+ /// Push the six material sliders into the live style: the bound
+ /// material's node when there is one (so the panes made of it follow),
+ /// else the DE keys every unbound rung reads.
+ fn apply_material_live(&self) {
+ use cce_ui::scene::{FrostDef, MaterialDef};
+ let spec = self.spec_slider.inner().get_scaled_value();
+ let shine = self.shine_slider.inner().get_scaled_value();
+ let curv = self.curv_slider.inner().get_scaled_value();
+ let comp = self.comp_slider.inner().get_scaled_value();
+ let refr = self.refr_slider.inner().get_scaled_value();
+ let radius = self.radius_slider.inner().get_scaled_value();
+ match Self::bound_material() {
+ Some(name) => {
+ let mut def: MaterialDef = cce_ui::color::named_material(&name).unwrap_or_default();
+ def.spec = Some(spec);
+ def.shininess = Some(shine);
+ def.curvature = Some(curv);
+ // Only a frosted material has a recipe to edit; an opaque
+ // node stays opaque (the sliders read as "when frosted").
+ if def.frost.is_some() {
+ def.frost = Some(FrostDef { compression: Some(comp), refraction: Some(refr), radius: Some(radius) });
+ }
+ cce_ui::color::set_named_material(&name, Some(def));
+ }
+ None => {
+ cce_ui::color::set_finish_spec(spec);
+ cce_ui::color::set_finish_shininess(shine);
+ cce_ui::color::set_finish_curvature(curv);
+ cce_ui::color::set_plate_backdrop_compression(comp);
+ cce_ui::color::set_plate_refraction(refr);
+ cce_ui::color::set_plate_frost_radius(radius);
+ }
+ }
+ }
+
+ /// Persist the material sliders: into the bound material's node
+ /// (`style.surface.material.<name>.finish` / `.frost`) when the pane rung
+ /// is bound, else the DE keys. Never restructures a config that has no
+ /// materials — the named form is opted into by writing the binding.
+ fn save_material(&self, p: &str) -> bool {
+ let f = |v: f32| format!("{v:.3}");
+ let w = |key: &str, value: &str| cce_ui::config::write_config_value(p, key, value, "style");
+ let spec = f(self.spec_slider.inner().get_scaled_value());
+ let shine = f(self.shine_slider.inner().get_scaled_value());
+ let curv = f(self.curv_slider.inner().get_scaled_value());
+ let comp = f(self.comp_slider.inner().get_scaled_value());
+ let refr = f(self.refr_slider.inner().get_scaled_value());
+ let radius = f(self.radius_slider.inner().get_scaled_value());
+ match Self::bound_material() {
+ Some(name) => {
+ let m = format!("style.surface.material.{name}");
+ let frosted = cce_ui::color::named_material(&name).is_some_and(|d| d.frost.is_some());
+ w(&format!("{m}.finish.spec"), &spec)
+ & w(&format!("{m}.finish.shininess"), &shine)
+ & w(&format!("{m}.finish.curvature"), &curv)
+ & (!frosted
+ || (w(&format!("{m}.frost.backdrop_compression"), &comp)
+ & w(&format!("{m}.frost.refraction"), &refr)
+ & w(&format!("{m}.frost.radius"), &radius)))
+ }
+ None => {
+ w("style.surface.relief.spec", &spec)
+ & w("style.surface.relief.shininess", &shine)
+ & w("style.surface.relief.curvature", &curv)
+ & w("style.surface.plate.backdrop_compression", &comp)
+ & w("style.surface.plate.refraction", &refr)
+ & w("style.surface.plate.radius", &radius)
+ }
+ }
+ }
+
/// `take_*` plumbing after any routed dispatch — state-gated, so it does
/// not matter which propagate call consumed the event.
fn drain_widget_changes(&mut self) {
@@ -1088,6 +1201,25 @@ impl BevelPopup {
println!("height {v:.2}px = {:.3}mm ({})", v * m.mm_per_px(), m.source.as_str());
self.needs_rebuild = true;
}
+ let material_moved = self.spec_slider.take_change()
+ | self.shine_slider.take_change()
+ | self.curv_slider.take_change()
+ | self.comp_slider.take_change()
+ | self.refr_slider.take_change()
+ | self.radius_slider.take_change();
+ if material_moved {
+ self.apply_material_live();
+ println!(
+ "material spec {:.3} shininess {:.1} curvature {:.3} | compression {:.3} refraction {:.3} radius {:.1}",
+ self.spec_slider.inner().get_scaled_value(),
+ self.shine_slider.inner().get_scaled_value(),
+ self.curv_slider.inner().get_scaled_value(),
+ self.comp_slider.inner().get_scaled_value(),
+ self.refr_slider.inner().get_scaled_value(),
+ self.radius_slider.inner().get_scaled_value(),
+ );
+ self.needs_rebuild = true;
+ }
if self.save_button.take_click() {
self.save_to_config();
self.needs_rebuild = true;
@@ -1162,7 +1294,9 @@ impl BevelPopup {
} else {
w("style.surface.relief.height", "0")
};
+ let material_ok = self.save_material(&p);
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)
@@ -1321,6 +1455,27 @@ impl Application for BevelPopup {
let (dmin, dmax) = DEPTH_RANGE;
let (wmin, wmax) = WIDTH_RANGE;
let (hmin, hmax) = HEIGHT_RANGE;
+ // The material sliders seed from the pane rung's effective material
+ // — the bound node when config binds one, else the DE keys — so they
+ // open on what the panes actually wear.
+ let pane = cce_ui::scene::Material::pane();
+ let (comp0, refr0, radius0) = match pane.frost {
+ cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
+ cce_ui::scene::Frost::Opaque => match cce_ui::scene::Frost::from_style() {
+ cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
+ cce_ui::scene::Frost::Opaque => (0.0, 0.0, cce_ui::scene::Frost::DEFAULT_RADIUS),
+ },
+ };
+ let norm = |v: f32, (lo, hi): (f32, f32)| ((v - lo) / (hi - lo)).clamp(0.0, 1.0);
+ let material_slider = |label: &str, v: f32, range: (f32, f32), decimals: usize| {
+ Slider::new()
+ .with_label(label)
+ .with_range(range.0, range.1)
+ .with_value(norm(v, range))
+ .with_readout(true)
+ .with_decimals(decimals)
+ .with_scroll(true)
+ };
// The persisted per-app plate opacity, falling back to the DE look.
// New path first, then the pre-rename one, so an existing opacity
// setting keeps working without a migration step.
@@ -1369,6 +1524,12 @@ impl Application for BevelPopup {
.with_readout(true)
.with_decimals(1)
.with_scroll(true),
+ spec_slider: material_slider("Specular", pane.finish.spec, SPEC_RANGE, 2),
+ shine_slider: material_slider("Shininess", pane.finish.shininess, SHINE_RANGE, 0),
+ curv_slider: material_slider("Curvature", pane.finish.curvature, CURV_RANGE, 2),
+ comp_slider: material_slider("Compression", comp0, COMP_RANGE, 2),
+ refr_slider: material_slider("Refraction", refr0, REFR_RANGE, 2),
+ radius_slider: material_slider("Blur radius", radius0, RADIUS_RANGE, 1),
save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
exit_requested: false,
@@ -1527,6 +1688,17 @@ impl Application for BevelPopup {
y += knob_h + gap;
self.height_slider.set_rect(x, y, w, knob_h);
y += knob_h + gap;
+ // The material columns: Finish left, Frost right, three rows.
+ let half = ((w - gap) * 0.5).max(60.0);
+ for (l, r) in [
+ (&mut self.spec_slider, &mut self.comp_slider),
+ (&mut self.shine_slider, &mut self.refr_slider),
+ (&mut self.curv_slider, &mut self.radius_slider),
+ ] {
+ l.set_rect(x, y, half, knob_h);
+ r.set_rect(x + half + gap, y, half, knob_h);
+ y += knob_h + gap;
+ }
self.save_button.set_rect(x, y, 96.0, button_h);
self.cancel_button.set_rect(x + 96.0 + 12.0, y, 96.0, button_h);
y += button_h + 8.0;
@@ -1590,6 +1762,12 @@ impl Application for BevelPopup {
&self.depth_slider,
&self.width_slider,
&self.height_slider,
+ &self.spec_slider,
+ &self.shine_slider,
+ &self.curv_slider,
+ &self.comp_slider,
+ &self.refr_slider,
+ &self.radius_slider,
] {
cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
}
diff --git a/src/color.rs b/src/color.rs
index 805d9a7..5919133 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -659,6 +659,65 @@ fn parse_and_set_colors(content: &str) {
} else if let Some(blur_val) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_f64()) {
if let Ok(mut lock) = PLATE_BLUR.write() { *lock = blur_val > 0.001; }
}
+ if let Some(r) = val.pointer("/style/surface/plate/radius").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = PLATE_FROST_RADIUS.write() { *lock = (r as f32).max(0.0); }
+ }
+
+ // The DE finish beyond its strength (`relief.depth` / `light`, which
+ // lives in the style registry): the three terms that were literals.
+ let f = |k: &str| val.pointer(&format!("/style/surface/relief/{k}")).and_then(|v| v.as_f64()).map(|v| v as f32);
+ if let Some(v) = f("spec") {
+ if let Ok(mut lock) = FINISH_SPEC.write() { *lock = v.max(0.0); }
+ }
+ if let Some(v) = f("shininess") {
+ if let Ok(mut lock) = FINISH_SHININESS.write() { *lock = v.max(1.0); }
+ }
+ if let Some(v) = f("curvature") {
+ if let Ok(mut lock) = FINISH_CURVATURE.write() { *lock = v.max(0.0); }
+ }
+
+ // Named materials and the rung bindings (RFC material § 5), replaced
+ // wholesale so a reload forgets what config no longer says.
+ {
+ use crate::scene::material::{FrostDef, MaterialDef};
+ let num = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_f64()).map(|v| v as f32);
+ let mut map: Vec<(String, MaterialDef)> = Vec::new();
+ if let Some(obj) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
+ for (name, node) in obj {
+ let Some(node) = node.as_object() else { continue };
+ let frost = node.get("frost").map(|fr| match fr.as_object() {
+ Some(fo) => FrostDef {
+ compression: num(fo.get("backdrop_compression").or(fo.get("compression"))),
+ refraction: num(fo.get("refraction")),
+ radius: num(fo.get("radius")),
+ },
+ // A bare `frost` node (no knobs) is frosted at the defaults.
+ None => FrostDef::default(),
+ });
+ let fin = node.get("finish").and_then(|v| v.as_object());
+ let fk = |k: &str| fin.and_then(|fo| num(fo.get(k)));
+ map.push((
+ name.clone(),
+ MaterialDef {
+ tint: node.get("color").and_then(|v| v.as_str()).and_then(parse_hex),
+ frost,
+ light: fk("light").or(fk("depth")),
+ spec: fk("spec"),
+ shininess: fk("shininess"),
+ curvature: fk("curvature"),
+ },
+ ));
+ }
+ }
+ if let Ok(mut lock) = NAMED_MATERIALS.write() { *lock = map; }
+ let bind = |p: &str| val.pointer(p).and_then(|v| v.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty());
+ let bindings = [
+ bind("/style/surface/plate/root/material"),
+ bind("/style/surface/plate/material"),
+ bind("/style/control/material"),
+ ];
+ if let Ok(mut lock) = MATERIAL_BINDINGS.write() { *lock = bindings; }
+ }
}
fn load_colors_once() {
@@ -1753,6 +1812,68 @@ static FINISH_SPEC: RwLock<f32> = RwLock::new(0.4);
static FINISH_SHININESS: RwLock<f32> = RwLock::new(24.0);
static FINISH_CURVATURE: RwLock<f32> = RwLock::new(0.2);
+/// The default material's blur radius (`style.surface.plate.radius`, the
+/// kernel sigma in logical px) — [`crate::scene::Frost::DEFAULT_RADIUS`]
+/// unless config says otherwise.
+static PLATE_FROST_RADIUS: RwLock<f32> = RwLock::new(crate::scene::material::Frost::DEFAULT_RADIUS);
+
+pub fn plate_frost_radius() -> f32 {
+ load_colors_once();
+ style_read(&PLATE_FROST_RADIUS)
+}
+pub fn set_plate_frost_radius(r: f32) {
+ style_write(&PLATE_FROST_RADIUS, r.max(0.0));
+}
+
+/// The named materials config defines (`style.surface.material { <name> {…} }`)
+/// and the rung bindings (`plate material=`, `plate { root material= }`,
+/// `control material=`) — see `docs/rfc-material.md` § 5. Replaced wholesale
+/// on every config load, so a node or binding removed from config is gone
+/// after a reload.
+static NAMED_MATERIALS: RwLock<Vec<(String, crate::scene::material::MaterialDef)>> = RwLock::new(Vec::new());
+static MATERIAL_BINDINGS: RwLock<[Option<String>; 3]> = RwLock::new([None, None, None]);
+
+fn rung_index(rung: crate::scene::material::PlateRung) -> usize {
+ match rung {
+ crate::scene::material::PlateRung::Root => 0,
+ crate::scene::material::PlateRung::Pane => 1,
+ crate::scene::material::PlateRung::Control => 2,
+ }
+}
+
+pub fn named_material(name: &str) -> Option<crate::scene::material::MaterialDef> {
+ load_colors_once();
+ style_read(&NAMED_MATERIALS).iter().find(|(n, _)| n == name).map(|(_, d)| d.clone())
+}
+
+/// Every defined material's name, sorted.
+pub fn material_names() -> Vec<String> {
+ load_colors_once();
+ let mut v: Vec<String> = style_read(&NAMED_MATERIALS).into_iter().map(|(n, _)| n).collect();
+ v.sort();
+ v
+}
+
+pub fn set_named_material(name: &str, def: Option<crate::scene::material::MaterialDef>) {
+ let mut list = style_read(&NAMED_MATERIALS);
+ list.retain(|(n, _)| n != name);
+ if let Some(d) = def {
+ list.push((name.to_string(), d));
+ }
+ style_write(&NAMED_MATERIALS, list);
+}
+
+pub fn material_binding(rung: crate::scene::material::PlateRung) -> Option<String> {
+ load_colors_once();
+ style_read(&MATERIAL_BINDINGS)[rung_index(rung)].clone()
+}
+
+pub fn set_material_binding(rung: crate::scene::material::PlateRung, name: Option<String>) {
+ let mut b = style_read(&MATERIAL_BINDINGS);
+ b[rung_index(rung)] = name;
+ style_write(&MATERIAL_BINDINGS, b);
+}
+
pub fn finish_spec() -> f32 {
style_read(&FINISH_SPEC)
}
diff --git a/src/config.rs b/src/config.rs
index 155344b..2dd97e1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -315,7 +315,7 @@ 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"
+ "menubar", "statusbar", "node", "relief", "frost", "finish"
];
fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
@@ -812,6 +812,31 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
#[cfg(test)]
mod tests {
+ /// A material node's frost and finish are written as PROPERTIES of a
+ /// `frost` / `finish` child (RFC material § 5), created on demand under
+ /// `style.surface.material.<name>`, and read back through the same
+ /// pointer the loader uses.
+ #[test]
+ fn material_keys_write_as_frost_and_finish_props() {
+ use super::{parse_kdl_to_json, update_kdl_in_memory};
+ let mut doc = kdl::KdlDocument::new();
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.backdrop_compression", "0.6", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
+ let text = doc.to_string();
+ let val = parse_kdl_to_json(&text);
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/backdrop_compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
+ assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
+ assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
+ assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
+ // One `frost` node with two props, not two `frost` nodes.
+ assert_eq!(text.matches("frost").count(), 1, "{text}");
+ assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
+ }
+
#[test]
fn unit_annotations_become_len_strings() {
let v = parse_kdl_to_json("style {\n relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n ruler (in)0.5\n}\n");
diff --git a/src/scene/material.rs b/src/scene/material.rs
index b3ca1b0..ffd0ce3 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -123,13 +123,18 @@ impl Frost {
(hi / Self::PACK_MAX, (z - hi * Self::PACK_BASE) / Self::PACK_MAX)
}
- /// The DE's frost, from the plate-rung keys every frosted surface reads
- /// today (the window-wide recipe, until step 3 makes it per plate).
+ /// The DE's frost recipe: the pane rung's, when its bound material is
+ /// frosted (`plate material="glass"`), else the default material's
+ /// plate-rung keys (`style.surface.plate.backdrop_compression` /
+ /// `refraction` / `radius`). What `from_fill` and `popover` frost with.
pub fn from_style() -> Self {
+ if let Some(f @ Frost::Frosted { .. }) = Material::bound(PlateRung::Pane).map(|m| m.frost) {
+ return f;
+ }
Frost::Frosted {
compression: crate::color::plate_backdrop_compression(),
refraction: crate::color::plate_refraction(),
- radius: Self::DEFAULT_RADIUS,
+ radius: crate::color::plate_frost_radius(),
}
}
@@ -144,6 +149,81 @@ impl Frost {
}
}
+/// The three rungs of the plate ladder a material can be bound to in
+/// config (`docs/rfc-material.md` § 5): `plate { root material="…" }`,
+/// `plate material="…"` and `control material="…"`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum PlateRung {
+ Root,
+ Pane,
+ Control,
+}
+
+/// A named material as config spells it — every field optional, resolved
+/// against the rung's legacy material by [`MaterialDef::resolve`]:
+///
+/// ```kdl
+/// style { surface { material {
+/// glass {
+/// color (rgba)"#05050840"
+/// frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
+/// finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
+/// }
+/// } } }
+/// ```
+///
+/// A node with no `frost` child is opaque — not "frosted at zero", none. A
+/// missing `color` keeps the rung's tint; a missing `finish` key keeps the
+/// DE's. `light` is the finish strength in the units `style.surface.relief.
+/// depth` uses (0.15 = the default strength of 1).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MaterialDef {
+ pub tint: Option<[f32; 4]>,
+ pub frost: Option<FrostDef>,
+ pub light: Option<f32>,
+ pub spec: Option<f32>,
+ pub shininess: Option<f32>,
+ pub curvature: Option<f32>,
+}
+
+/// The `frost` child of a material node: present means frosted, each knob
+/// defaulting (0, 0, [`Frost::DEFAULT_RADIUS`]).
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct FrostDef {
+ pub compression: Option<f32>,
+ pub refraction: Option<f32>,
+ pub radius: Option<f32>,
+}
+
+impl MaterialDef {
+ /// The material this definition names, over `base` — the rung's legacy
+ /// material, which supplies everything the node leaves unsaid.
+ pub fn resolve(&self, base: Material) -> Material {
+ let frost = match self.frost {
+ Some(f) => Frost::Frosted {
+ compression: f.compression.unwrap_or(0.0).clamp(0.0, 1.0),
+ refraction: f.refraction.unwrap_or(0.0).clamp(0.0, 1.0),
+ radius: f.radius.unwrap_or(Frost::DEFAULT_RADIUS).max(0.0),
+ },
+ None => Frost::Opaque,
+ };
+ let mut finish = base.finish;
+ if let Some(l) = self.light {
+ finish.strength = l / 0.15;
+ }
+ if let Some(v) = self.spec {
+ finish.spec = v.max(0.0);
+ }
+ if let Some(v) = self.shininess {
+ finish.shininess = v.max(1.0);
+ }
+ if let Some(v) = self.curvature {
+ finish.curvature = v.max(0.0);
+ }
+ Material { tint: self.tint.unwrap_or(base.tint), frost, finish }
+ }
+}
+
/// Which frost regime a plate is under: a root plate's frost is the
/// compositor's blur-behind (its fill stays positive-alpha whatever its
/// material says), a nested plate's is the in-app pass (the negative-alpha
@@ -189,34 +269,91 @@ impl Material {
// ---- the rung defaults -------------------------------------------------
- /// The root rung: the window's background, `style.surface.plate.root.
- /// color` (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
+ /// The root rung: `plate { root material="…" }` when bound, else the
+ /// window's background, `style.surface.plate.root.color`
+ /// (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
/// `Frost::Opaque` on the client side by construction: a root plate's
/// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
/// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
/// a `Frosted` here anyway.
pub fn root() -> Self {
- Self::opaque(crate::color::page_low_color())
+ Self::bound(PlateRung::Root).unwrap_or_else(Self::root_legacy)
}
- /// The pane rung: the params plate's tint (`style.surface.param.color`)
- /// at the global plate opacity, frosted when `style.surface.plate.blur`
- /// says so — exactly what `color::param_plate_fill` resolved before this
- /// type existed (it now resolves through here).
+ /// The pane rung: `plate material="…"` when bound, else the params
+ /// plate's tint (`style.surface.param.color`) at the global plate
+ /// opacity, frosted when `style.surface.plate.blur` says so — exactly
+ /// what `color::param_plate_fill` resolved before this type existed (it
+ /// now resolves through here).
pub fn pane() -> Self {
+ Self::bound(PlateRung::Pane).unwrap_or_else(Self::pane_legacy)
+ }
+
+ /// The control rung: `control material="…"` when bound, else the button
+ /// fill, never frosted. Control faces under the relief stances are laid
+ /// through a stroke the sentinel cannot reach (see `PlateStance::Flat`);
+ /// frost at this rung is `Flat` only and takes the pane's material
+ /// verbatim ([`Material::flat_control`]).
+ pub fn control() -> Self {
+ Self::bound(PlateRung::Control).unwrap_or_else(Self::control_legacy)
+ }
+
+ /// The rung's material from the legacy keys alone — what every config
+ /// without a `material=` binding resolves to, and the base a bound
+ /// material's unset fields fall back to.
+ pub fn legacy(rung: PlateRung) -> Self {
+ match rung {
+ PlateRung::Root => Self::root_legacy(),
+ PlateRung::Pane => Self::pane_legacy(),
+ PlateRung::Control => Self::control_legacy(),
+ }
+ }
+
+ fn root_legacy() -> Self {
+ Self::opaque(crate::color::page_low_color())
+ }
+
+ fn pane_legacy() -> Self {
let mut tint = crate::color::param_bg_color();
tint[3] *= crate::layout::plate_opacity();
- Self { tint, frost: Frost::from_flag(crate::color::plate_blur()), finish: Finish::from_style() }
+ let frost = if crate::color::plate_blur() {
+ Frost::Frosted {
+ compression: crate::color::plate_backdrop_compression(),
+ refraction: crate::color::plate_refraction(),
+ radius: crate::color::plate_frost_radius(),
+ }
+ } else {
+ Frost::Opaque
+ };
+ Self { tint, frost, finish: Finish::from_style() }
}
- /// The control rung: the button fill, never frosted. Control faces under
- /// the relief stances are laid through a stroke the sentinel cannot reach
- /// (see `PlateStance::Flat`); frost at this rung is `Flat` only and takes
- /// the pane's material verbatim ([`Material::flat_control`]).
- pub fn control() -> Self {
+ fn control_legacy() -> Self {
Self::opaque(crate::color::button_background_color())
}
+ /// The material `rung` is bound to in config, resolved over the rung's
+ /// legacy material — `None` when the rung is unbound. A binding to a
+ /// name no `material` node defines is reported once and treated as
+ /// unbound, so a typo degrades to today's look rather than to nothing.
+ pub fn bound(rung: PlateRung) -> Option<Self> {
+ let name = crate::color::material_binding(rung)?;
+ match crate::color::named_material(&name) {
+ Some(def) => Some(def.resolve(Self::legacy(rung))),
+ None => {
+ log::warn!("{rung:?} plate rung is bound to material \"{name}\", which no material node defines");
+ None
+ }
+ }
+ }
+
+ /// A named material from config, resolved over the pane rung's legacy
+ /// material — for an app that wants a material by name for its own
+ /// surfaces. `None` when no node defines it.
+ pub fn named(name: &str) -> Option<Self> {
+ crate::color::named_material(name).map(|def| def.resolve(Self::pane_legacy()))
+ }
+
/// The legacy bridge: a fill as the renderer consumed it before this
/// type existed. A negative alpha is the frost sentinel — `Frosted` at
/// the DE recipe, tint alpha `|a|`; otherwise `Opaque` with the colour
@@ -348,6 +485,7 @@ mod tests {
/// tint at plate opacity, negated under plate blur.
#[test]
fn pane_resolves_like_param_plate_fill_did() {
+ let _lock = crate::color::test_color_state_lock();
let old = |blur: bool| {
let mut c = crate::color::param_bg_color();
c[3] *= crate::layout::plate_opacity();
@@ -383,6 +521,7 @@ mod tests {
/// kernel; the flag form is today's `blur: bool`.
#[test]
fn frost_from_style_and_flag() {
+ let _lock = crate::color::test_color_state_lock();
crate::color::set_plate_backdrop_compression(0.6);
crate::color::set_plate_refraction(0.3);
assert_eq!(Frost::from_style(), frosted());
@@ -459,10 +598,122 @@ mod tests {
assert_eq!(lit("LEGACY_STRIDE"), Frost::DEFAULT_RADIUS * 2.0 / 2.0);
}
+ const DESIGNER_LEGACY: &str = r##"
+ style {
+ surface {
+ param color=(rgba)"#05050840"
+ plate backdrop_compression=(f64)0.6 refraction=(f64)0.3 bevel_width=(f64)12.0 blur=(bool)true {
+ root corner_radius=(i64)24
+ }
+ relief depth=(f64)0.08
+ }
+ }
+ "##;
+
+ const DESIGNER_NAMED: &str = r##"
+ style {
+ surface {
+ material {
+ glass {
+ color (rgba)"#05050840"
+ frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
+ }
+ }
+ plate material="glass" bevel_width=(f64)12.0 {
+ root corner_radius=(i64)24
+ }
+ relief depth=(f64)0.08
+ }
+ }
+ "##;
+
+ /// The designer's frosted pane spelled with the legacy keys and as a
+ /// named material bound to the pane rung resolve to the SAME material —
+ /// the step-4 exit test: same Material, same bytes (steps 2–3).
+ #[test]
+ fn named_material_round_trips_the_legacy_spelling() {
+ let _lock = crate::color::test_color_state_lock();
+ crate::layout::lazy_init_style_registry();
+ let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
+ crate::layout::set_plate_opacity(1.0);
+ crate::color::reload_colors(DESIGNER_LEGACY);
+ assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
+ let legacy = Material::pane();
+ assert!(legacy.frost.is_frosted());
+ assert!((legacy.tint[3] - 0x40 as f32 / 255.0).abs() < 1e-6, "{:?}", legacy.tint);
+
+ crate::color::reload_colors(DESIGNER_NAMED);
+ assert_eq!(crate::color::material_binding(PlateRung::Pane).as_deref(), Some("glass"));
+ let named = Material::pane();
+ assert_eq!(named.tint, legacy.tint);
+ assert_eq!(named.finish, legacy.finish);
+ match (named.frost, legacy.frost) {
+ (Frost::Frosted { compression: c1, refraction: r1, radius: d1 }, Frost::Frosted { compression: c2, refraction: r2, radius: d2 }) => {
+ assert!((c1 - c2).abs() < 1e-6 && (r1 - r2).abs() < 1e-6 && d1 == d2, "{:?} vs {:?}", named.frost, legacy.frost);
+ }
+ other => panic!("{other:?}"),
+ }
+ // The DE recipe follows the bound pane.
+ assert_eq!(Frost::from_style(), named.frost);
+ assert_eq!(Material::named("glass"), Some(named));
+ assert_eq!(Material::named("nope"), None);
+ // The other rungs are unbound and unchanged.
+ assert_eq!(Material::root(), Material::legacy(PlateRung::Root));
+ assert_eq!(Material::control(), Material::legacy(PlateRung::Control));
+ assert_eq!(crate::color::material_names(), vec!["glass".to_string()]);
+ // Bindings and nodes are replaced wholesale by every load, so an
+ // empty document unbinds every rung for the tests that follow.
+ crate::color::reload_colors("");
+ assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
+ }
+
+ /// A binding wins over the legacy keys, a node without `frost` is opaque
+ /// whatever `plate blur` says, unset fields fall back to the rung, and a
+ /// binding to an undefined name degrades to the legacy material.
+ #[test]
+ fn binding_semantics() {
+ let _lock = crate::color::test_color_state_lock();
+ crate::layout::lazy_init_style_registry();
+ let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
+ crate::color::reload_colors(r##"
+ style {
+ surface {
+ material {
+ plastic {
+ finish spec=(f64)0.25 shininess=(f64)12.0
+ }
+ matte {
+ color (rgba)"#20202080"
+ finish light=(f64)0.3
+ }
+ }
+ plate blur=(bool)true material="plastic"
+ relief depth=(f64)0.15 spec=(f64)0.5 shininess=(f64)20.0 curvature=(f64)0.1
+ }
+ control material="ghost"
+ }
+ "##);
+ let pane = Material::pane();
+ assert_eq!(pane.frost, Frost::Opaque, "no frost child = opaque, blur flag or not");
+ assert_eq!(pane.tint, Material::legacy(PlateRung::Pane).tint, "no color = the rung's tint");
+ assert_eq!(pane.finish.spec, 0.25);
+ assert_eq!(pane.finish.shininess, 12.0);
+ assert_eq!(pane.finish.curvature, 0.1, "unset finish keys take the DE's (relief curvature)");
+ assert_eq!(Finish::from_style().spec, 0.5, "style.surface.relief.spec is the DE finish");
+ assert_eq!(Material::named("matte").map(|m| m.finish.strength), Some(0.3 / 0.15));
+ assert_eq!(Material::control(), Material::legacy(PlateRung::Control), "unknown name = unbound");
+ assert!(Frost::from_style().is_frosted(), "an opaque bound pane leaves the DE recipe to the keys");
+ crate::color::set_finish_spec(0.4);
+ crate::color::set_finish_shininess(24.0);
+ crate::color::set_finish_curvature(0.2);
+ crate::color::reload_colors("");
+ }
+
/// A popover is the base colour at menu opacity, frosted — the bytes the
/// three menu sites used to write by negating an alpha.
#[test]
fn popover_is_the_menu_recipe() {
+ let _lock = crate::color::test_color_state_lock();
let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
let mut old = [0.1, 0.2, 0.3, 1.0];
old[3] = -crate::color::menu_opacity();
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 370f470..6fed0b8 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -16,5 +16,5 @@ pub mod relief_shade;
pub mod tree;
pub use arena::{Arena, Node, NodeId};
-pub use material::{Finish, Frost, Material, PlateRole};
+pub use material::{Finish, Frost, FrostDef, Material, MaterialDef, PlateRole, PlateRung};
pub use tree::WidgetTree;