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 }