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

src/scene/material.rs (40.8K)

  1 //! Material — what a surface is made of (`docs/rfc-material.md`).
  2 //!
  3 //! A [`Material`] is a surface's **tint**, its **frost** (whether and how it
  4 //! shows what is behind it) and its **finish** (how it answers the DE's
  5 //! light), carried BY VALUE on the plate made of it. Step 1 of the RFC: the
  6 //! type exists, the sentinel encoding lives in exactly one function
  7 //! ([`Material::fill_tint`]), and the rung defaults resolve from the same style
  8 //! getters the rungs read today — so nothing on screen moves. Step 2 threads
  9 //! it through `PlateSpec`, `ControlPlate` and the prims.
 10 //!
 11 //! What is deliberately NOT here: the light (the scene's, `relief_shade::
 12 //! light_vector`), the roll width (geometry, per prim as `depth`) and the
 13 //! carve/roll profiles (per-window uniforms). See the RFC's non-goals.
 14 
 15 /// The plastic finish: how a surface answers light. `[shading strength,
 16 /// specular strength, shininess, curvature/AO strength]` as carried in
 17 /// `PlatePush.material`, plus the two depth ratios the shader reads from
 18 /// `WindowInfo`. Formerly `relief_shade::Material`; that module re-exports it
 19 /// under the old name until step 2 (RFC § 11 (4)).
 20 #[derive(Clone, Copy, Debug, PartialEq)]
 21 pub struct Finish {
 22     pub strength: f32,
 23     pub spec: f32,
 24     pub shininess: f32,
 25     pub curvature: f32,
 26     /// A carve's drop over its run (`layout::carve_depth_ratio`): the
 27     /// geometry the slopes are scaled by. `relief_shade::RECESS_DEPTH` unless
 28     /// a height is pinned. Not in `to_array` — the shader reads it from
 29     /// `WindowInfo`.
 30     pub carve_depth: f32,
 31     /// The plate roll's rise over its run (`layout::roll_height_ratio`):
 32     /// 1 for the quarter-round.
 33     pub roll_height: f32,
 34 }
 35 
 36 impl Finish {
 37     /// The DE's finish, strength tracking `bevel_depth` against the default,
 38     /// the other three from `color::finish_spec` / `finish_shininess` /
 39     /// `finish_curvature` (defaults: the literals the shader shipped with).
 40     /// This is the ONE definition — the renderer's push constants come from
 41     /// here too.
 42     pub fn from_style() -> Self {
 43         Self {
 44             strength: crate::layout::bevel_depth() / 0.15,
 45             spec: crate::color::finish_spec(),
 46             shininess: crate::color::finish_shininess(),
 47             curvature: crate::color::finish_curvature(),
 48             carve_depth: crate::layout::carve_depth_ratio(),
 49             roll_height: crate::layout::roll_height_ratio(),
 50         }
 51     }
 52 
 53     pub fn to_array(self) -> [f32; 4] {
 54         [self.strength, self.spec, self.shininess, self.curvature]
 55     }
 56 }
 57 
 58 /// Whether and how a surface shows what is behind it.
 59 ///
 60 /// An enum, not two floats and a bool: an opaque plate has no compression and
 61 /// no refraction — not zero of each, none — and making the recipe unreachable
 62 /// when the plate is not frosted is what keeps [`Material::fill`] to one
 63 /// question.
 64 #[derive(Clone, Copy, Debug, PartialEq)]
 65 pub enum Frost {
 66     /// No frost: the tint alone, composited at its ALPHA — a translucent
 67     /// tint is still translucent, and what shows through is sharp. This
 68     /// says nothing about coverage; it says the plate never samples its
 69     /// backdrop. (Named `Opaque` until 2026-09-28, which read as "solid"
 70     /// and was not.)
 71     Unfrosted,
 72     /// Frosted glass: the backdrop blurred, luminance-compressed toward the
 73     /// tint's key, tinted at the tint's alpha; the rim refracts.
 74     Frosted {
 75         /// How hard the blurred backdrop's luminance is pulled toward the
 76         /// tint's key — the legibility control. 0..1.
 77         /// `style.surface.plate.backdrop_compression` today.
 78         compression: f32,
 79         /// How far the plate's roll bends what it samples — the objecthood
 80         /// control. 0..1. `style.surface.plate.refraction` today.
 81         refraction: f32,
 82         /// Blur radius — the kernel's sigma — in logical px.
 83         /// [`Frost::DEFAULT_RADIUS`] is the kernel every frosted plate had;
 84         /// 0 is a CLEAR plate: one clean sample, tinted.
 85         radius: f32,
 86     },
 87 }
 88 
 89 impl Frost {
 90     /// The kernel every frosted plate had, as a sigma in logical px.
 91     ///
 92     /// Before the recipe was per plate, `resolve_blur` sampled a 7×7 kernel
 93     /// at a fixed 5.5 PHYSICAL px stride (sigma two taps = 11 physical px)
 94     /// — half the blur on a scale-2 panel that it was on a scale-1 one, and
 95     /// the panel every frosted surface was tuned on is scale 2. A material
 96     /// cannot know the scale, so the default is stated in logical px at the
 97     /// value that reproduces the panel exactly: 5.5 logical = 11 physical at
 98     /// scale 2. A scale-1 display now gets the same logical blur instead of
 99     /// twice it.
100     pub const DEFAULT_RADIUS: f32 = 5.5;
101 
102     /// Fixed-point width of `compression` and `refraction` inside one push
103     /// float: `c·4095·4096 + r·4095` is an integer below 2²⁴, exact in f32.
104     /// Mirrors the shader's `FROST_PACK_MAX` / `FROST_PACK_BASE`.
105     pub const PACK_MAX: f32 = 4095.0;
106     pub const PACK_BASE: f32 = 4096.0;
107 
108     /// The recipe as the plate branch reads it: `[p_host.z, p_host.w]` —
109     /// compression and refraction packed in `z`, the blur radius in `w` as
110     /// the kernel sigma in PHYSICAL px (the shader samples the backdrop in
111     /// physical px). `Unfrosted` packs to zeros: nothing reads them, and a
112     /// plate that was never frosted pushes the bytes it always did.
113     pub fn pack(&self, scale: f32) -> [f32; 2] {
114         match *self {
115             Frost::Unfrosted => [0.0, 0.0],
116             Frost::Frosted { compression, refraction, radius } => {
117                 let q = |v: f32| (v.clamp(0.0, 1.0) * Self::PACK_MAX).round();
118                 [q(compression) * Self::PACK_BASE + q(refraction), radius.max(0.0) * scale]
119             }
120         }
121     }
122 
123     /// The Rust twin of the shader's unpack: `(compression, refraction)`
124     /// from a packed `z`.
125     pub fn unpack(z: f32) -> (f32, f32) {
126         let hi = (z / Self::PACK_BASE).floor();
127         (hi / Self::PACK_MAX, (z - hi * Self::PACK_BASE) / Self::PACK_MAX)
128     }
129 
130     /// The DE's frost recipe: the pane rung's, when its bound material is
131     /// frosted (`plate material="glass"`), else the default material's
132     /// plate-rung keys (`style.surface.plate.backdrop_compression` /
133     /// `refraction` / `radius`). What `from_fill` and `popover` frost with.
134     pub fn from_style() -> Self {
135         if let Some(f @ Frost::Frosted { .. }) = Material::bound(PlateRung::Pane).map(|m| m.frost) {
136             return f;
137         }
138         Frost::Frosted {
139             compression: crate::color::plate_backdrop_compression(),
140             refraction: crate::color::plate_refraction(),
141             radius: crate::color::plate_frost_radius(),
142         }
143     }
144 
145     /// [`Frost::from_style`] when `on`, else [`Frost::Unfrosted`] — the shape of
146     /// every `blur: bool` the toolkit carries today.
147     pub fn from_flag(on: bool) -> Self {
148         if on { Self::from_style() } else { Frost::Unfrosted }
149     }
150 
151     pub fn is_frosted(&self) -> bool {
152         matches!(self, Frost::Frosted { .. })
153     }
154 }
155 
156 /// The three rungs of the plate ladder a material can be bound to in
157 /// config (`docs/rfc-material.md` § 5): `plate { root material="…" }`,
158 /// `plate material="…"` and `control material="…"`.
159 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
160 pub enum PlateRung {
161     Root,
162     Pane,
163     Control,
164 }
165 
166 /// A named material as config spells it — every field optional, resolved
167 /// against the rung's legacy material by [`MaterialDef::resolve`]:
168 ///
169 /// ```kdl
170 /// style { surface { material {
171 ///     glass {
172 ///         color (rgba)"#05050840"
173 ///         frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
174 ///         finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
175 ///     }
176 /// } } }
177 /// ```
178 ///
179 /// A node with no `frost` child is opaque — not "frosted at zero", none. A
180 /// missing `color` keeps the rung's tint; a missing `finish` key keeps the
181 /// DE's. `light` is the finish strength in the units `style.surface.relief.
182 /// depth` uses (0.15 = the default strength of 1).
183 #[derive(Clone, Debug, Default, PartialEq)]
184 pub struct MaterialDef {
185     pub tint: Option<[f32; 4]>,
186     pub frost: Option<FrostDef>,
187     pub light: Option<f32>,
188     pub spec: Option<f32>,
189     pub shininess: Option<f32>,
190     pub curvature: Option<f32>,
191 }
192 
193 /// The `frost` child of a material node: present means frosted, each knob
194 /// defaulting (0, 0, [`Frost::DEFAULT_RADIUS`]).
195 #[derive(Clone, Copy, Debug, Default, PartialEq)]
196 pub struct FrostDef {
197     pub compression: Option<f32>,
198     pub refraction: Option<f32>,
199     pub radius: Option<f32>,
200 }
201 
202 impl MaterialDef {
203     /// The material this definition names, over `base` — the rung's legacy
204     /// material, which supplies everything the node leaves unsaid.
205     pub fn resolve(&self, base: Material) -> Material {
206         let frost = match self.frost {
207             Some(f) => Frost::Frosted {
208                 compression: f.compression.unwrap_or(0.0).clamp(0.0, 1.0),
209                 refraction: f.refraction.unwrap_or(0.0).clamp(0.0, 1.0),
210                 radius: f.radius.unwrap_or(Frost::DEFAULT_RADIUS).max(0.0),
211             },
212             None => Frost::Unfrosted,
213         };
214         let mut finish = base.finish;
215         if let Some(l) = self.light {
216             finish.strength = l / 0.15;
217         }
218         if let Some(v) = self.spec {
219             finish.spec = v.max(0.0);
220         }
221         if let Some(v) = self.shininess {
222             finish.shininess = v.max(1.0);
223         }
224         if let Some(v) = self.curvature {
225             finish.curvature = v.max(0.0);
226         }
227         Material { tint: self.tint.unwrap_or(base.tint), frost, finish }
228     }
229 }
230 
231 /// Which frost regime a plate is under: a root plate's frost is the
232 /// compositor's blur-behind (its fill stays positive-alpha whatever its
233 /// material says), a nested plate's is the in-app pass (the negative-alpha
234 /// sentinel). `PlateSpec::role` derives it from the window-corner flags; a
235 /// control plate is always nested.
236 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
237 pub enum PlateRole {
238     Root,
239     Nested,
240 }
241 
242 /// What a surface is made of. ~14 floats, copied freely.
243 #[derive(Clone, Copy, Debug, PartialEq)]
244 pub struct Material {
245     /// Linear RGBA. Alpha is opacity and is non-negative here — the
246     /// blur-behind sentinel is an encoding detail of [`Material::fill`],
247     /// never state.
248     pub tint: [f32; 4],
249     pub frost: Frost,
250     pub finish: Finish,
251 }
252 
253 impl Material {
254     /// An opaque material of `tint` under the DE's finish.
255     pub fn opaque(tint: [f32; 4]) -> Self {
256         Self { tint, frost: Frost::Unfrosted, finish: Finish::from_style() }
257     }
258 
259     pub fn with_tint(mut self, tint: [f32; 4]) -> Self {
260         self.tint = tint;
261         self
262     }
263 
264     pub fn with_frost(mut self, frost: Frost) -> Self {
265         self.frost = frost;
266         self
267     }
268 
269     pub fn with_finish(mut self, finish: Finish) -> Self {
270         self.finish = finish;
271         self
272     }
273 
274     // ---- the rung defaults -------------------------------------------------
275 
276     /// The root rung: `plate { root material="…" }` when bound, else the
277     /// window's background, `style.surface.plate.root.color`
278     /// (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
279     /// `Frost::Unfrosted` on the client side by construction: a root plate's
280     /// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
281     /// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
282     /// a `Frosted` here anyway.
283     pub fn root() -> Self {
284         Self::bound(PlateRung::Root).unwrap_or_else(Self::root_legacy)
285     }
286 
287     /// The pane rung: `plate material="…"` when bound, else the params
288     /// plate's tint (`style.surface.param.color`) at the global plate
289     /// opacity, frosted when `style.surface.plate.blur` says so — exactly
290     /// what `color::param_plate_fill` resolved before this type existed (it
291     /// now resolves through here).
292     pub fn pane() -> Self {
293         Self::bound(PlateRung::Pane).unwrap_or_else(Self::pane_legacy)
294     }
295 
296     /// The control rung: `control material="…"` when bound, else the button
297     /// fill, never frosted. Control faces under the relief stances are laid
298     /// through a stroke the sentinel cannot reach (see `PlateStance::Flat`);
299     /// frost at this rung is `Flat` only and takes the pane's material
300     /// verbatim ([`Material::flat_control`]).
301     pub fn control() -> Self {
302         Self::bound(PlateRung::Control).unwrap_or_else(Self::control_legacy)
303     }
304 
305     /// The rung's material from the legacy keys alone — what every config
306     /// without a `material=` binding resolves to, and the base a bound
307     /// material's unset fields fall back to.
308     pub fn legacy(rung: PlateRung) -> Self {
309         match rung {
310             PlateRung::Root => Self::root_legacy(),
311             PlateRung::Pane => Self::pane_legacy(),
312             PlateRung::Control => Self::control_legacy(),
313         }
314     }
315 
316     fn root_legacy() -> Self {
317         Self::opaque(crate::color::page_low_color())
318     }
319 
320     fn pane_legacy() -> Self {
321         let mut tint = crate::color::param_bg_color();
322         // `style.surface.plate.pane.color` is the whole tint, alpha
323         // included; the legacy `param.color` is still multiplied by the
324         // top-level `plate_opacity` line, as it always was.
325         if !crate::color::pane_color_is_whole() {
326             tint[3] *= crate::layout::plate_opacity();
327         }
328         let frost = if crate::color::plate_blur() {
329             Frost::Frosted {
330                 compression: crate::color::plate_backdrop_compression(),
331                 refraction: crate::color::plate_refraction(),
332                 radius: crate::color::plate_frost_radius(),
333             }
334         } else {
335             Frost::Unfrosted
336         };
337         Self { tint, frost, finish: Finish::from_style() }
338     }
339 
340     fn control_legacy() -> Self {
341         Self::opaque(crate::color::button_background_color())
342     }
343 
344     /// The material `rung` is bound to in config, resolved over the rung's
345     /// legacy material — `None` when the rung is unbound. A binding to a
346     /// name no `material` node defines is reported once and treated as
347     /// unbound, so a typo degrades to today's look rather than to nothing.
348     pub fn bound(rung: PlateRung) -> Option<Self> {
349         let name = crate::color::material_binding(rung)?;
350         match crate::color::named_material(&name) {
351             Some(def) => Some(def.resolve(Self::legacy(rung))),
352             None => {
353                 log::warn!("{rung:?} plate rung is bound to material \"{name}\", which no material node defines");
354                 None
355             }
356         }
357     }
358 
359     /// A named material from config, resolved over the pane rung's legacy
360     /// material — for an app that wants a material by name for its own
361     /// surfaces. `None` when no node defines it.
362     pub fn named(name: &str) -> Option<Self> {
363         crate::color::named_material(name).map(|def| def.resolve(Self::pane_legacy()))
364     }
365 
366     /// The legacy bridge: a fill as the renderer consumed it before this
367     /// type existed. A negative alpha is the frost sentinel — `Frosted` at
368     /// the DE recipe, tint alpha `|a|`; otherwise `Unfrosted` with the colour
369     /// as is. `from_fill(c).fill(Nested) == c` for every `c` a caller could
370     /// hand the old API. For a call site that holds an encoded colour; a
371     /// site that knows what it means says `Material::opaque` / `with_frost`.
372     pub fn from_fill(encoded: [f32; 4]) -> Self {
373         let a = encoded[3];
374         let m = Self::opaque([encoded[0], encoded[1], encoded[2], a.abs()]);
375         if a < 0.0 { m.with_frost(Frost::from_style()) } else { m }
376     }
377 
378     /// The legacy bridge for a FACE slot: a transparent fill is no face at
379     /// all (`None` — the surface below shows through), anything else is
380     /// [`Material::from_fill`] of it. The `|alpha| > 0.001` test every face
381     /// slot applied, stated once.
382     pub fn face(encoded: [f32; 4]) -> Option<Self> {
383         (encoded[3].abs() > 0.001).then(|| Self::from_fill(encoded))
384     }
385 
386     /// This material as a plate in `role` carries it: a root plate's frost
387     /// is the COMPOSITOR's, so under [`PlateRole::Root`] the client-side
388     /// material is opaque — the prim a `PlateSpec` emits carries this, and
389     /// the tessellator encodes every prim as nested.
390     pub fn for_role(&self, role: PlateRole) -> Self {
391         match role {
392             PlateRole::Root => Material { frost: Frost::Unfrosted, ..*self },
393             PlateRole::Nested => *self,
394         }
395     }
396 
397     /// The popover material: a menu, a context menu, a dropdown's open
398     /// surface. `base`'s colour at `style.surface.menu.opacity`
399     /// (`color::menu_opacity`) — not the colour's own alpha, since a page
400     /// colour is typically opaque and would resolve the frost to a solid
401     /// tint — and frosted at the DE recipe, so a menu shows the content
402     /// beneath it blurred and tinted rather than covering it, with the
403     /// recipe's compression replaced by the menu's own
404     /// (`style.surface.menu.compression`, `color::menu_compression`): a menu
405     /// is read over whatever it opened above, so it holds its key harder
406     /// than a pane does.
407     pub fn popover(base: [f32; 4]) -> Self {
408         let mut frost = Frost::from_style();
409         if let Frost::Frosted { compression, .. } = &mut frost {
410             *compression = crate::color::menu_compression();
411         }
412         Self::opaque([base[0], base[1], base[2], crate::color::menu_opacity()]).with_frost(frost)
413     }
414 
415     /// THE menu material: [`Material::popover`] of `color::menu_color` —
416     /// `style.surface.menu.color`, else the root plate colour. What a
417     /// context menu's plate is made of, and what any other surface that
418     /// should read as one (a command palette, a modal list) asks for, so
419     /// the `style.surface.menu` block is the one place both are configured.
420     pub fn menu() -> Self {
421         Self::popover(crate::color::menu_color())
422     }
423 
424     // ---- derived materials -------------------------------------------------
425 
426     /// The well floor cut into this plate: the same material with the tint
427     /// darkened by `WELL_FLOOR`'s strength (`WELL_FLOOR_LIFTED`'s when
428     /// `lifted`, the hover cue). Frost and finish carried through — a well in
429     /// glass is deeper glass (RFC § 11 (3)). `PaintCtx::well_floor` draws
430     /// this for a FROSTED host; an opaque host's floor stays the darkening
431     /// overlay, which is this exactly at plate alpha 1 and the honest
432     /// darkening at any other alpha (a darkened fill at the plate's own
433     /// alpha would barely darken a translucent plate).
434     pub fn floor(&self, lifted: bool) -> Material {
435         let overlay = if lifted { crate::color::WELL_FLOOR_LIFTED } else { crate::color::WELL_FLOOR };
436         let keep = 1.0 - overlay[3];
437         let t = self.tint;
438         Material { tint: [t[0] * keep, t[1] * keep, t[2] * keep, t[3]], ..*self }
439     }
440 
441     /// A `Flat`-stance control on this pane: the material verbatim. Exists so
442     /// the call site says what it means.
443     pub fn flat_control(&self) -> Material {
444         *self
445     }
446 
447     /// A control face from a configured fill, under the rule
448     /// the control rung states: an opaque one is the face
449     /// (alpha forced to 1 — a translucent face would blend into the relief's
450     /// shading and read as a second material); a transparent one is `None`,
451     /// the surface below showing as the face (edges only).
452     pub fn control_face(raw: [f32; 4]) -> Option<Material> {
453         (raw[3] > 0.001).then(|| Self::opaque([raw[0], raw[1], raw[2], 1.0]))
454     }
455 
456     // ---- the one encoding function ----------------------------------------
457 
458     /// The vertex colour the renderer consumes for a plate of this material
459     /// in `role` — see [`Material::fill_tint`].
460     pub fn fill(&self, role: PlateRole) -> [f32; 4] {
461         Self::fill_tint(self.tint, self.frost, role)
462     }
463 
464     /// THE place a negative alpha is written. For `tint` under `frost` in
465     /// `role`:
466     /// - [`PlateRole::Root`] → alpha positive whatever `frost` says. A root
467     ///   plate's frost is the compositor's blur-behind, never the in-app pass.
468     /// - nested + [`Frost::Frosted`] → the in-app frost pass's negative-alpha
469     ///   sentinel, `-|alpha|`.
470     /// - nested + [`Frost::Unfrosted`] → the tint as is.
471     ///
472     /// Static so a caller holding a tint and a flag (today's `PlateSpec`)
473     /// encodes through the same rule without resolving a finish it does not
474     /// need.
475     pub fn fill_tint(tint: [f32; 4], frost: Frost, role: PlateRole) -> [f32; 4] {
476         let mut c = tint;
477         match role {
478             PlateRole::Root => c[3] = c[3].abs(),
479             PlateRole::Nested if frost.is_frosted() => c[3] = -c[3].abs(),
480             PlateRole::Nested => {}
481         }
482         c
483     }
484 }
485 
486 #[cfg(test)]
487 mod tests {
488     use super::*;
489 
490     fn frosted() -> Frost {
491         Frost::Frosted { compression: 0.6, refraction: 0.3, radius: Frost::DEFAULT_RADIUS }
492     }
493 
494     /// The encoding rule, stated once: root stays positive whatever the
495     /// frost, nested frost is the sentinel, nested opaque passes through.
496     #[test]
497     fn fill_encodes_by_role() {
498         let tint = [0.1, 0.2, 0.3, 0.8];
499         assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Root)[3], 0.8, "root frost is the compositor's");
500         assert_eq!(Material::fill_tint(tint, Frost::Unfrosted, PlateRole::Root)[3], 0.8);
501         assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Nested)[3], -0.8, "nested frost = sentinel");
502         assert_eq!(Material::fill_tint(tint, Frost::Unfrosted, PlateRole::Nested), tint, "no frost, no encoding");
503         // A caller that hands a negative alpha in is normalised, not doubled.
504         assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], frosted(), PlateRole::Nested)[3], -0.5);
505         assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], Frost::Unfrosted, PlateRole::Root)[3], 0.5);
506         let m = Material::opaque(tint).with_frost(frosted());
507         assert_eq!(m.fill(PlateRole::Nested), Material::fill_tint(tint, frosted(), PlateRole::Nested));
508     }
509 
510     /// The pane rung is `param_plate_fill`'s old arithmetic exactly: the
511     /// tint at plate opacity, negated under plate blur.
512     #[test]
513     fn pane_resolves_like_param_plate_fill_did() {
514         let _lock = crate::color::test_color_state_lock();
515         let old = |blur: bool| {
516             let mut c = crate::color::param_bg_color();
517             c[3] *= crate::layout::plate_opacity();
518             if blur {
519                 c[3] = -c[3].abs();
520             }
521             c
522         };
523         for blur in [false, true] {
524             crate::color::set_plate_blur(blur);
525             assert_eq!(Material::pane().fill(PlateRole::Nested), old(blur), "blur={blur}");
526             assert_eq!(crate::color::param_plate_fill(), old(blur), "blur={blur}");
527             assert_eq!(Material::pane().frost.is_frosted(), blur);
528         }
529         crate::color::set_plate_blur(false);
530     }
531 
532     /// The finish defaults are the literals the shader shipped with, the DE
533     /// finish reads the three getters, and the push-constant layout is
534     /// unchanged.
535     ///
536     /// Read through `from_style` unpinned, the defaults were whatever the
537     /// process-wide values were: the machine's config, or what a test that
538     /// reloads the knobs had left there — `binding_semantics` left spec 0.5
539     /// until 2026-10-05, so this failed whenever it ran after that one. The
540     /// defaults are checked as the statics' initial values and the getters
541     /// pinned on this thread.
542     #[test]
543     fn finish_defaults_and_layout() {
544         use crate::color::{FINISH_CURVATURE_DEFAULT, FINISH_SHININESS_DEFAULT, FINISH_SPEC_DEFAULT};
545         assert_eq!((FINISH_SPEC_DEFAULT, FINISH_SHININESS_DEFAULT, FINISH_CURVATURE_DEFAULT), (0.4, 24.0, 0.2));
546         crate::color::set_finish_spec(0.9);
547         crate::color::set_finish_shininess(30.0);
548         crate::color::set_finish_curvature(0.3);
549         let f = Finish::from_style();
550         assert_eq!((f.spec, f.shininess, f.curvature), (0.9, 30.0, 0.3));
551         assert_eq!(f.to_array(), [f.strength, f.spec, f.shininess, f.curvature]);
552     }
553 
554     /// The frost recipe reads the two plate-rung keys and carries the default
555     /// kernel; the flag form is today's `blur: bool`.
556     ///
557     /// All THREE knobs are pinned on this thread, the radius included: the
558     /// setters write a per-thread overlay, but a `reload_colors` writes the
559     /// process-wide globals, and an absent frost knob keeps its last value
560     /// by design — so a neighbour's reload of `frost radius=3.0` outlived
561     /// its closing empty reload, and this test read 3.0 for the default
562     /// whenever that neighbour ran first (one run in eight, 2026-09-28).
563     #[test]
564     fn frost_from_style_and_flag() {
565         let _lock = crate::color::test_color_state_lock();
566         crate::color::set_plate_backdrop_compression(0.6);
567         crate::color::set_plate_refraction(0.3);
568         crate::color::set_plate_frost_radius(Frost::DEFAULT_RADIUS);
569         assert_eq!(Frost::from_style(), frosted());
570         assert_eq!(Frost::from_flag(false), Frost::Unfrosted);
571         assert!(Frost::from_flag(true).is_frosted());
572         crate::color::set_plate_backdrop_compression(0.0);
573         crate::color::set_plate_refraction(0.0);
574     }
575 
576     /// A floor darkens the tint by the overlay's strength and keeps
577     /// everything else: alpha, frost, finish.
578     #[test]
579     fn floor_darkens_and_carries_the_frost() {
580         let m = Material::opaque([0.5, 0.5, 0.5, 0.7]).with_frost(frosted());
581         let f = m.floor(false);
582         let keep = 1.0 - crate::color::WELL_FLOOR[3];
583         assert!((f.tint[0] - 0.5 * keep).abs() < 1e-6);
584         assert_eq!(f.tint[3], 0.7);
585         assert_eq!(f.frost, m.frost);
586         assert_eq!(f.finish, m.finish);
587         assert!(m.floor(true).tint[0] > f.tint[0], "lifted rises toward the plate");
588         assert_eq!(m.flat_control(), m);
589     }
590 
591     /// The legacy bridge round-trips every fill the old API accepted, and
592     /// the role resolution agrees with the encoding function.
593     #[test]
594     fn from_fill_round_trips_and_for_role_matches_fill() {
595         for c in [[0.1, 0.2, 0.3, 0.8], [0.1, 0.2, 0.3, -0.8], [0.0; 4], [0.5, 0.5, 0.5, 1.0]] {
596             let m = Material::from_fill(c);
597             assert_eq!(m.fill(PlateRole::Nested), c, "{c:?}");
598             assert_eq!(m.frost.is_frosted(), c[3] < 0.0);
599             assert!(m.tint[3] >= 0.0, "tint alpha is never negative");
600             for role in [PlateRole::Root, PlateRole::Nested] {
601                 assert_eq!(m.for_role(role).fill(PlateRole::Nested), m.fill(role), "{c:?} {role:?}");
602             }
603         }
604         assert_eq!(Material::from_fill([0.0, 0.0, 0.0, -0.5]).frost, Frost::from_style());
605         assert!(Material::face([0.3, 0.3, 0.3, 0.0]).is_none(), "transparent = no face");
606         assert!(Material::face([0.3, 0.3, 0.3, -0.5]).is_some_and(|m| m.frost.is_frosted()));
607         assert_eq!(Material::face([0.3, 0.3, 0.3, 0.7]).map(|m| m.tint), Some([0.3, 0.3, 0.3, 0.7]));
608     }
609 
610     /// The pack is exact on its own grid, monotone, and never mixes the two
611     /// halves; the shader's literals are the ones the Rust twin uses.
612     #[test]
613     fn frost_pack_round_trips() {
614         for i in [0u32, 1, 2, 613, 614, 2047, 2048, 4094, 4095] {
615             for j in [0u32, 1, 819, 4095] {
616                 let (c, r) = (i as f32 / Frost::PACK_MAX, j as f32 / Frost::PACK_MAX);
617                 let f = Frost::Frosted { compression: c, refraction: r, radius: 5.5 };
618                 let [z, w] = f.pack(2.0);
619                 let (c2, r2) = Frost::unpack(z);
620                 assert!((c2 - c).abs() < 1e-6 && (r2 - r).abs() < 1e-6, "{i},{j}: {c},{r} -> {c2},{r2}");
621                 assert_eq!(w, 11.0);
622                 assert!(z < (1u32 << 24) as f32, "packed value must stay an exact f32 integer");
623             }
624         }
625         // 0.6 / 0.3 (the designer's recipe) survive to better than a 1/255 step.
626         let (c, r) = Frost::unpack(Frost::Frosted { compression: 0.6, refraction: 0.3, radius: 0.0 }.pack(1.0)[0]);
627         assert!((c - 0.6).abs() < 1.0 / 510.0 && (r - 0.3).abs() < 1.0 / 510.0);
628         assert_eq!(Frost::Unfrosted.pack(2.0), [0.0, 0.0]);
629         assert_eq!(Frost::Frosted { compression: 0.0, refraction: 0.0, radius: 0.0 }.pack(2.0), [0.0, 0.0]);
630 
631         let wgsl = include_str!("../draw/shader2d.wgsl");
632         let lit = |name: &str| -> f32 {
633             let rest = wgsl.split(&format!("const {name}: f32 = ")).nth(1).unwrap_or_else(|| panic!("{name} missing"));
634             rest.split(';').next().unwrap().trim().parse().unwrap()
635         };
636         assert_eq!(lit("FROST_PACK_MAX"), Frost::PACK_MAX);
637         assert_eq!(lit("FROST_PACK_BASE"), Frost::PACK_BASE);
638         // The droplet's and the raw-vertex fallback's stride is the panel's
639         // default kernel in physical px: DEFAULT_RADIUS × scale 2 / 2.
640         assert_eq!(lit("LEGACY_STRIDE"), Frost::DEFAULT_RADIUS * 2.0 / 2.0);
641     }
642 
643     const DESIGNER_LEGACY: &str = r##"
644         style {
645             surface {
646                 param color=(rgba)"#05050840"
647                 plate {
648                     frost compression=(f64)0.6 refraction=(f64)0.3
649                     root corner_radius=(i64)24
650                 }
651                 relief light=(f64)0.08
652             }
653         }
654     "##;
655 
656     const DESIGNER_NAMED: &str = r##"
657         style {
658             surface {
659                 material {
660                     glass {
661                         color (rgba)"#05050840"
662                         frost compression=(f64)0.6 refraction=(f64)0.3
663                     }
664                 }
665                 plate material="glass" {
666                     root corner_radius=(i64)24
667                 }
668                 relief light=(f64)0.08
669             }
670         }
671     "##;
672 
673     /// The designer's frosted pane spelled with the legacy keys and as a
674     /// named material bound to the pane rung resolve to the SAME material —
675     /// the step-4 exit test: same Material, same bytes (steps 2–3).
676     #[test]
677     fn named_material_round_trips_the_legacy_spelling() {
678         let _lock = crate::color::test_color_state_lock();
679         crate::layout::lazy_init_style_registry();
680         let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
681         crate::layout::set_plate_opacity(1.0);
682         crate::color::reload_colors(DESIGNER_LEGACY);
683         assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
684         let legacy = Material::pane();
685         assert!(legacy.frost.is_frosted());
686         assert!((legacy.tint[3] - 0x40 as f32 / 255.0).abs() < 1e-6, "{:?}", legacy.tint);
687 
688         crate::color::reload_colors(DESIGNER_NAMED);
689         assert_eq!(crate::color::material_binding(PlateRung::Pane).as_deref(), Some("glass"));
690         let named = Material::pane();
691         assert_eq!(named.tint, legacy.tint);
692         assert_eq!(named.finish, legacy.finish);
693         match (named.frost, legacy.frost) {
694             (Frost::Frosted { compression: c1, refraction: r1, radius: d1 }, Frost::Frosted { compression: c2, refraction: r2, radius: d2 }) => {
695                 assert!((c1 - c2).abs() < 1e-6 && (r1 - r2).abs() < 1e-6 && d1 == d2, "{:?} vs {:?}", named.frost, legacy.frost);
696             }
697             other => panic!("{other:?}"),
698         }
699         // The DE recipe follows the bound pane.
700         assert_eq!(Frost::from_style(), named.frost);
701         assert_eq!(Material::named("glass"), Some(named));
702         assert_eq!(Material::named("nope"), None);
703         // The other rungs are unbound and unchanged.
704         assert_eq!(Material::root(), Material::legacy(PlateRung::Root));
705         assert_eq!(Material::control(), Material::legacy(PlateRung::Control));
706         assert_eq!(crate::color::material_names(), vec!["glass".to_string()]);
707         // Bindings and nodes are replaced wholesale by every load, so an
708         // empty document unbinds every rung for the tests that follow.
709         crate::color::reload_colors("");
710         assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
711     }
712 
713     /// The default material's frost is ONE block — `plate { frost radius=…
714     /// compression=… refraction=… }`, the shape a named material's `frost`
715     /// child already had: a bare `frost` is frosted at the defaults, `frost
716     /// (bool)false` is not. The four scattered keys it replaced (`blur`,
717     /// `radius`, `backdrop_compression`, `refraction`) are RETIRED, not
718     /// aliases: a config carrying one is reported by path
719     /// (`color::retired_surface_keys`) and the key does nothing — `blur=true`
720     /// alone is sharp, a flat `radius` moves nothing, and the old knob name
721     /// inside the block is ignored too.
722     #[test]
723     fn the_frost_block_is_the_only_spelling_of_the_default_recipe() {
724         let _lock = crate::color::test_color_state_lock();
725         crate::layout::lazy_init_style_registry();
726         let _ = crate::color::plate_blur();
727         let doc = |plate: &str| format!("style {{\n surface {{\n plate {{\n {plate}\n }}\n }}\n}}\n");
728         let load = |plate: &str| {
729             crate::color::reload_colors(&doc(plate));
730             (crate::color::plate_blur(), Frost::from_style())
731         };
732         let retired = |plate: &str| crate::color::retired_surface_keys(&crate::config::parse_kdl_to_json(&doc(plate)));
733         let frosted = |c: f32, r: f32, rad: f32| Frost::Frosted { compression: c, refraction: r, radius: rad };
734         assert_eq!(load("frost radius=(f64)3.0 compression=(f64)0.4 refraction=(f64)0.1"), (true, frosted(0.4, 0.1, 3.0)));
735         assert!(retired("frost radius=(f64)3.0 compression=(f64)0.4 refraction=(f64)0.1").is_empty());
736         assert_eq!(load("frost backdrop_compression=(f64)0.2"), (true, frosted(0.4, 0.1, 3.0)), "the old knob name inside the block is ignored; unset knobs keep their last value");
737         assert_eq!(retired("frost backdrop_compression=(f64)0.2"), vec!["style.surface.plate.frost.backdrop_compression"]);
738         assert!(load("frost").0, "a bare `frost` is frosted");
739         assert!(!load("frost (bool)false").0);
740         // `from_style` is the RECIPE, `plate_blur` the switch: the retired
741         // keys flip neither — the switch stays off and the radius stays 3.
742         assert_eq!(load("blur (bool)true\n radius (f64)2.0"), (false, frosted(0.4, 0.1, 3.0)), "the retired spelling frosts nothing and moves nothing");
743         assert_eq!(retired("blur (bool)true\n radius (f64)2.0"), vec!["style.surface.plate.blur", "style.surface.plate.radius"]);
744         assert_eq!(load("blur (bool)false\n frost compression=(f64)0.7"), (true, frosted(0.7, 0.1, 3.0)), "the block is read, the retired key is not");
745         // Leave the globals as they were found. A reload writes them for
746         // the whole process, and an unset knob KEEPS its value (asserted
747         // above), so the empty reload alone left radius 3 / compression 0.7
748         // / refraction 0.1 behind for every test after this one.
749         crate::color::reload_colors(&doc(&format!(
750             "frost radius=(f64){} compression=(f64)0.0 refraction=(f64)0.0",
751             Frost::DEFAULT_RADIUS
752         )));
753         crate::color::reload_colors(&doc("frost (bool)false"));
754         crate::color::reload_colors("");
755         assert_eq!(Frost::from_style(), frosted(0.0, 0.0, Frost::DEFAULT_RADIUS), "the globals are back at their defaults");
756         assert!(!crate::color::plate_blur());
757     }
758 
759     /// `style.surface.plate.pane.color` is the pane tint WHOLE — its alpha is
760     /// the tint strength — where the legacy `param.color` is still multiplied
761     /// by the top-level `plate_opacity` line.
762     #[test]
763     fn the_pane_colour_spelling_is_the_whole_tint() {
764         let _lock = crate::color::test_color_state_lock();
765         crate::layout::lazy_init_style_registry();
766         let _ = crate::color::plate_blur();
767         let was = crate::layout::plate_opacity();
768         crate::layout::set_plate_opacity(0.5);
769         crate::color::reload_colors("style {\n surface {\n param color=(rgba)\"#10101880\"\n }\n}\n");
770         assert!(!crate::color::pane_color_is_whole());
771         let a = Material::pane().tint[3];
772         assert!((a - 0.25).abs() < 1e-2, "legacy: alpha 0.5 x plate_opacity 0.5, got {a}");
773         crate::color::reload_colors("style {\n surface {\n plate {\n pane color=(rgba)\"#10101880\"\n }\n }\n}\n");
774         assert!(crate::color::pane_color_is_whole());
775         let a = Material::pane().tint[3];
776         assert!((a - 0.5).abs() < 1e-2, "spelled whole: alpha 0.5 as written, got {a}");
777         crate::layout::set_plate_opacity(was);
778         crate::color::reload_colors("");
779     }
780 
781     /// A binding wins over the legacy keys, a node without `frost` is
782     /// unfrosted whatever `plate blur` says, unset fields fall back to the
783     /// rung, and a binding to an undefined name degrades to the legacy
784     /// material.
785     #[test]
786     fn binding_semantics() {
787         let _lock = crate::color::test_color_state_lock();
788         crate::layout::lazy_init_style_registry();
789         let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
790         // The DE finish this reload changes is process-wide, and an absent
791         // knob keeps its value through the empty reload below: put back
792         // what was there. (`set_finish_*` cannot — under `cfg(test)` a
793         // setter writes this thread's overlay, not the globals.)
794         let finish_before = Finish::from_style();
795         crate::color::reload_colors(r##"
796             style {
797                 surface {
798                     material {
799                         plastic {
800                             finish spec=(f64)0.25 shininess=(f64)12.0
801                         }
802                         matte {
803                             color (rgba)"#20202080"
804                             finish light=(f64)0.3
805                         }
806                     }
807                     plate blur=(bool)true material="plastic"
808                     relief light=(f64)0.15 spec=(f64)0.5 shininess=(f64)20.0 curvature=(f64)0.1
809                 }
810                 control material="ghost"
811             }
812         "##);
813         let pane = Material::pane();
814         assert_eq!(pane.frost, Frost::Unfrosted, "no frost child = unfrosted, blur flag or not");
815         assert_eq!(pane.tint, Material::legacy(PlateRung::Pane).tint, "no color = the rung's tint");
816         assert_eq!(pane.finish.spec, 0.25);
817         assert_eq!(pane.finish.shininess, 12.0);
818         assert_eq!(pane.finish.curvature, 0.1, "unset finish keys take the DE's (relief curvature)");
819         assert_eq!(Finish::from_style().spec, 0.5, "style.surface.relief.spec is the DE finish");
820         assert_eq!(Material::named("matte").map(|m| m.finish.strength), Some(0.3 / 0.15));
821         assert_eq!(Material::control(), Material::legacy(PlateRung::Control), "unknown name = unbound");
822         assert!(Frost::from_style().is_frosted(), "an opaque bound pane leaves the DE recipe to the keys");
823         // Multi-line: `a { b }` on one line does not parse, and the loader
824         // reads that as an empty document.
825         crate::color::reload_colors(&format!(
826             "style {{\n surface {{\n relief spec=(f64){:?} shininess=(f64){:?} curvature=(f64){:?}\n }}\n}}\n",
827             finish_before.spec, finish_before.shininess, finish_before.curvature,
828         ));
829         crate::color::reload_colors("");
830         assert_eq!(Finish::from_style(), finish_before, "the DE finish is back for the tests after this one");
831     }
832 
833     /// A popover is the base colour at menu opacity, frosted — the bytes the
834     /// three menu sites used to write by negating an alpha.
835     #[test]
836     fn popover_is_the_menu_recipe() {
837         let _lock = crate::color::test_color_state_lock();
838         let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
839         let mut old = [0.1, 0.2, 0.3, 1.0];
840         old[3] = -crate::color::menu_opacity();
841         assert_eq!(m.fill(PlateRole::Nested), old);
842         assert!(m.frost.is_frosted());
843     }
844 
845     /// A popover's frost compresses at the menu key, not the DE recipe's:
846     /// the two are independent dials.
847     #[test]
848     fn popover_compresses_at_the_menu_key() {
849         let _lock = crate::color::test_color_state_lock();
850         crate::color::set_plate_backdrop_compression(0.1);
851         crate::color::set_menu_compression(0.7);
852         let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
853         let Frost::Frosted { compression, .. } = m.frost else { panic!("popover is frosted") };
854         assert!((compression - 0.7).abs() < 1e-6, "popover compression {compression}");
855         let Frost::Frosted { compression: pane, .. } = Frost::from_style() else { panic!("recipe is frosted") };
856         assert!((pane - 0.1).abs() < 1e-6, "recipe compression {pane}");
857         crate::color::set_plate_backdrop_compression(0.0);
858         crate::color::set_menu_compression(0.6);
859     }
860 
861     /// The control-face rule: opaque or nothing.
862     #[test]
863     fn control_face_is_opaque_or_none() {
864         let face = Material::control_face([0.2, 0.3, 0.4, 0.5]).expect("a fill is a face");
865         assert_eq!(face.tint, [0.2, 0.3, 0.4, 1.0]);
866         assert_eq!(face.frost, Frost::Unfrosted);
867         assert!(Material::control_face([0.2, 0.3, 0.4, 0.0]).is_none());
868         assert_eq!(Material::control().frost, Frost::Unfrosted);
869         assert_eq!(Material::root().frost, Frost::Unfrosted);
870     }
871 }