GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/scene/material.rs (33.5K)
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 /// The tint alone, composited at its alpha. Not a sample of the backdrop.
67 Opaque,
68 /// Frosted glass: the backdrop blurred, luminance-compressed toward the
69 /// tint's key, tinted at the tint's alpha; the rim refracts.
70 Frosted {
71 /// How hard the blurred backdrop's luminance is pulled toward the
72 /// tint's key — the legibility control. 0..1.
73 /// `style.surface.plate.backdrop_compression` today.
74 compression: f32,
75 /// How far the plate's roll bends what it samples — the objecthood
76 /// control. 0..1. `style.surface.plate.refraction` today.
77 refraction: f32,
78 /// Blur radius — the kernel's sigma — in logical px.
79 /// [`Frost::DEFAULT_RADIUS`] is the kernel every frosted plate had;
80 /// 0 is a CLEAR plate: one clean sample, tinted.
81 radius: f32,
82 },
83 }
84
85 impl Frost {
86 /// The kernel every frosted plate had, as a sigma in logical px.
87 ///
88 /// Before the recipe was per plate, `resolve_blur` sampled a 7×7 kernel
89 /// at a fixed 5.5 PHYSICAL px stride (sigma two taps = 11 physical px)
90 /// — half the blur on a scale-2 panel that it was on a scale-1 one, and
91 /// the panel every frosted surface was tuned on is scale 2. A material
92 /// cannot know the scale, so the default is stated in logical px at the
93 /// value that reproduces the panel exactly: 5.5 logical = 11 physical at
94 /// scale 2. A scale-1 display now gets the same logical blur instead of
95 /// twice it.
96 pub const DEFAULT_RADIUS: f32 = 5.5;
97
98 /// Fixed-point width of `compression` and `refraction` inside one push
99 /// float: `c·4095·4096 + r·4095` is an integer below 2²⁴, exact in f32.
100 /// Mirrors the shader's `FROST_PACK_MAX` / `FROST_PACK_BASE`.
101 pub const PACK_MAX: f32 = 4095.0;
102 pub const PACK_BASE: f32 = 4096.0;
103
104 /// The recipe as the plate branch reads it: `[p_host.z, p_host.w]` —
105 /// compression and refraction packed in `z`, the blur radius in `w` as
106 /// the kernel sigma in PHYSICAL px (the shader samples the backdrop in
107 /// physical px). `Opaque` packs to zeros: nothing reads them, and a
108 /// plate that was never frosted pushes the bytes it always did.
109 pub fn pack(&self, scale: f32) -> [f32; 2] {
110 match *self {
111 Frost::Opaque => [0.0, 0.0],
112 Frost::Frosted { compression, refraction, radius } => {
113 let q = |v: f32| (v.clamp(0.0, 1.0) * Self::PACK_MAX).round();
114 [q(compression) * Self::PACK_BASE + q(refraction), radius.max(0.0) * scale]
115 }
116 }
117 }
118
119 /// The Rust twin of the shader's unpack: `(compression, refraction)`
120 /// from a packed `z`.
121 pub fn unpack(z: f32) -> (f32, f32) {
122 let hi = (z / Self::PACK_BASE).floor();
123 (hi / Self::PACK_MAX, (z - hi * Self::PACK_BASE) / Self::PACK_MAX)
124 }
125
126 /// The DE's frost recipe: the pane rung's, when its bound material is
127 /// frosted (`plate material="glass"`), else the default material's
128 /// plate-rung keys (`style.surface.plate.backdrop_compression` /
129 /// `refraction` / `radius`). What `from_fill` and `popover` frost with.
130 pub fn from_style() -> Self {
131 if let Some(f @ Frost::Frosted { .. }) = Material::bound(PlateRung::Pane).map(|m| m.frost) {
132 return f;
133 }
134 Frost::Frosted {
135 compression: crate::color::plate_backdrop_compression(),
136 refraction: crate::color::plate_refraction(),
137 radius: crate::color::plate_frost_radius(),
138 }
139 }
140
141 /// [`Frost::from_style`] when `on`, else [`Frost::Opaque`] — the shape of
142 /// every `blur: bool` the toolkit carries today.
143 pub fn from_flag(on: bool) -> Self {
144 if on { Self::from_style() } else { Frost::Opaque }
145 }
146
147 pub fn is_frosted(&self) -> bool {
148 matches!(self, Frost::Frosted { .. })
149 }
150 }
151
152 /// The three rungs of the plate ladder a material can be bound to in
153 /// config (`docs/rfc-material.md` § 5): `plate { root material="…" }`,
154 /// `plate material="…"` and `control material="…"`.
155 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
156 pub enum PlateRung {
157 Root,
158 Pane,
159 Control,
160 }
161
162 /// A named material as config spells it — every field optional, resolved
163 /// against the rung's legacy material by [`MaterialDef::resolve`]:
164 ///
165 /// ```kdl
166 /// style { surface { material {
167 /// glass {
168 /// color (rgba)"#05050840"
169 /// frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
170 /// finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
171 /// }
172 /// } } }
173 /// ```
174 ///
175 /// A node with no `frost` child is opaque — not "frosted at zero", none. A
176 /// missing `color` keeps the rung's tint; a missing `finish` key keeps the
177 /// DE's. `light` is the finish strength in the units `style.surface.relief.
178 /// depth` uses (0.15 = the default strength of 1).
179 #[derive(Clone, Debug, Default, PartialEq)]
180 pub struct MaterialDef {
181 pub tint: Option<[f32; 4]>,
182 pub frost: Option<FrostDef>,
183 pub light: Option<f32>,
184 pub spec: Option<f32>,
185 pub shininess: Option<f32>,
186 pub curvature: Option<f32>,
187 }
188
189 /// The `frost` child of a material node: present means frosted, each knob
190 /// defaulting (0, 0, [`Frost::DEFAULT_RADIUS`]).
191 #[derive(Clone, Copy, Debug, Default, PartialEq)]
192 pub struct FrostDef {
193 pub compression: Option<f32>,
194 pub refraction: Option<f32>,
195 pub radius: Option<f32>,
196 }
197
198 impl MaterialDef {
199 /// The material this definition names, over `base` — the rung's legacy
200 /// material, which supplies everything the node leaves unsaid.
201 pub fn resolve(&self, base: Material) -> Material {
202 let frost = match self.frost {
203 Some(f) => Frost::Frosted {
204 compression: f.compression.unwrap_or(0.0).clamp(0.0, 1.0),
205 refraction: f.refraction.unwrap_or(0.0).clamp(0.0, 1.0),
206 radius: f.radius.unwrap_or(Frost::DEFAULT_RADIUS).max(0.0),
207 },
208 None => Frost::Opaque,
209 };
210 let mut finish = base.finish;
211 if let Some(l) = self.light {
212 finish.strength = l / 0.15;
213 }
214 if let Some(v) = self.spec {
215 finish.spec = v.max(0.0);
216 }
217 if let Some(v) = self.shininess {
218 finish.shininess = v.max(1.0);
219 }
220 if let Some(v) = self.curvature {
221 finish.curvature = v.max(0.0);
222 }
223 Material { tint: self.tint.unwrap_or(base.tint), frost, finish }
224 }
225 }
226
227 /// Which frost regime a plate is under: a root plate's frost is the
228 /// compositor's blur-behind (its fill stays positive-alpha whatever its
229 /// material says), a nested plate's is the in-app pass (the negative-alpha
230 /// sentinel). `PlateSpec::role` derives it from the window-corner flags; a
231 /// control plate is always nested.
232 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
233 pub enum PlateRole {
234 Root,
235 Nested,
236 }
237
238 /// What a surface is made of. ~14 floats, copied freely.
239 #[derive(Clone, Copy, Debug, PartialEq)]
240 pub struct Material {
241 /// Linear RGBA. Alpha is opacity and is non-negative here — the
242 /// blur-behind sentinel is an encoding detail of [`Material::fill`],
243 /// never state.
244 pub tint: [f32; 4],
245 pub frost: Frost,
246 pub finish: Finish,
247 }
248
249 impl Material {
250 /// An opaque material of `tint` under the DE's finish.
251 pub fn opaque(tint: [f32; 4]) -> Self {
252 Self { tint, frost: Frost::Opaque, finish: Finish::from_style() }
253 }
254
255 pub fn with_tint(mut self, tint: [f32; 4]) -> Self {
256 self.tint = tint;
257 self
258 }
259
260 pub fn with_frost(mut self, frost: Frost) -> Self {
261 self.frost = frost;
262 self
263 }
264
265 pub fn with_finish(mut self, finish: Finish) -> Self {
266 self.finish = finish;
267 self
268 }
269
270 // ---- the rung defaults -------------------------------------------------
271
272 /// The root rung: `plate { root material="…" }` when bound, else the
273 /// window's background, `style.surface.plate.root.color`
274 /// (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
275 /// `Frost::Opaque` on the client side by construction: a root plate's
276 /// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
277 /// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
278 /// a `Frosted` here anyway.
279 pub fn root() -> Self {
280 Self::bound(PlateRung::Root).unwrap_or_else(Self::root_legacy)
281 }
282
283 /// The pane rung: `plate material="…"` when bound, else the params
284 /// plate's tint (`style.surface.param.color`) at the global plate
285 /// opacity, frosted when `style.surface.plate.blur` says so — exactly
286 /// what `color::param_plate_fill` resolved before this type existed (it
287 /// now resolves through here).
288 pub fn pane() -> Self {
289 Self::bound(PlateRung::Pane).unwrap_or_else(Self::pane_legacy)
290 }
291
292 /// The control rung: `control material="…"` when bound, else the button
293 /// fill, never frosted. Control faces under the relief stances are laid
294 /// through a stroke the sentinel cannot reach (see `PlateStance::Flat`);
295 /// frost at this rung is `Flat` only and takes the pane's material
296 /// verbatim ([`Material::flat_control`]).
297 pub fn control() -> Self {
298 Self::bound(PlateRung::Control).unwrap_or_else(Self::control_legacy)
299 }
300
301 /// The rung's material from the legacy keys alone — what every config
302 /// without a `material=` binding resolves to, and the base a bound
303 /// material's unset fields fall back to.
304 pub fn legacy(rung: PlateRung) -> Self {
305 match rung {
306 PlateRung::Root => Self::root_legacy(),
307 PlateRung::Pane => Self::pane_legacy(),
308 PlateRung::Control => Self::control_legacy(),
309 }
310 }
311
312 fn root_legacy() -> Self {
313 Self::opaque(crate::color::page_low_color())
314 }
315
316 fn pane_legacy() -> Self {
317 let mut tint = crate::color::param_bg_color();
318 tint[3] *= crate::layout::plate_opacity();
319 let frost = if crate::color::plate_blur() {
320 Frost::Frosted {
321 compression: crate::color::plate_backdrop_compression(),
322 refraction: crate::color::plate_refraction(),
323 radius: crate::color::plate_frost_radius(),
324 }
325 } else {
326 Frost::Opaque
327 };
328 Self { tint, frost, finish: Finish::from_style() }
329 }
330
331 fn control_legacy() -> Self {
332 Self::opaque(crate::color::button_background_color())
333 }
334
335 /// The material `rung` is bound to in config, resolved over the rung's
336 /// legacy material — `None` when the rung is unbound. A binding to a
337 /// name no `material` node defines is reported once and treated as
338 /// unbound, so a typo degrades to today's look rather than to nothing.
339 pub fn bound(rung: PlateRung) -> Option<Self> {
340 let name = crate::color::material_binding(rung)?;
341 match crate::color::named_material(&name) {
342 Some(def) => Some(def.resolve(Self::legacy(rung))),
343 None => {
344 log::warn!("{rung:?} plate rung is bound to material \"{name}\", which no material node defines");
345 None
346 }
347 }
348 }
349
350 /// A named material from config, resolved over the pane rung's legacy
351 /// material — for an app that wants a material by name for its own
352 /// surfaces. `None` when no node defines it.
353 pub fn named(name: &str) -> Option<Self> {
354 crate::color::named_material(name).map(|def| def.resolve(Self::pane_legacy()))
355 }
356
357 /// The legacy bridge: a fill as the renderer consumed it before this
358 /// type existed. A negative alpha is the frost sentinel — `Frosted` at
359 /// the DE recipe, tint alpha `|a|`; otherwise `Opaque` with the colour
360 /// as is. `from_fill(c).fill(Nested) == c` for every `c` a caller could
361 /// hand the old API. For a call site that holds an encoded colour; a
362 /// site that knows what it means says `Material::opaque` / `with_frost`.
363 pub fn from_fill(encoded: [f32; 4]) -> Self {
364 let a = encoded[3];
365 let m = Self::opaque([encoded[0], encoded[1], encoded[2], a.abs()]);
366 if a < 0.0 { m.with_frost(Frost::from_style()) } else { m }
367 }
368
369 /// The legacy bridge for a FACE slot: a transparent fill is no face at
370 /// all (`None` — the surface below shows through), anything else is
371 /// [`Material::from_fill`] of it. The `|alpha| > 0.001` test every face
372 /// slot applied, stated once.
373 pub fn face(encoded: [f32; 4]) -> Option<Self> {
374 (encoded[3].abs() > 0.001).then(|| Self::from_fill(encoded))
375 }
376
377 /// This material as a plate in `role` carries it: a root plate's frost
378 /// is the COMPOSITOR's, so under [`PlateRole::Root`] the client-side
379 /// material is opaque — the prim a `PlateSpec` emits carries this, and
380 /// the tessellator encodes every prim as nested.
381 pub fn for_role(&self, role: PlateRole) -> Self {
382 match role {
383 PlateRole::Root => Material { frost: Frost::Opaque, ..*self },
384 PlateRole::Nested => *self,
385 }
386 }
387
388 /// The popover material: a menu, a context menu, a dropdown's open
389 /// surface. `base`'s colour at `style.surface.menu.opacity`
390 /// (`color::menu_opacity`) — not the colour's own alpha, since a page
391 /// colour is typically opaque and would resolve the frost to a solid
392 /// tint — and frosted at the DE recipe, so a menu shows the content
393 /// beneath it blurred and tinted rather than covering it, with the
394 /// recipe's compression replaced by the menu's own
395 /// (`style.surface.menu.compression`, `color::menu_compression`): a menu
396 /// is read over whatever it opened above, so it holds its key harder
397 /// than a pane does.
398 pub fn popover(base: [f32; 4]) -> Self {
399 let mut frost = Frost::from_style();
400 if let Frost::Frosted { compression, .. } = &mut frost {
401 *compression = crate::color::menu_compression();
402 }
403 Self::opaque([base[0], base[1], base[2], crate::color::menu_opacity()]).with_frost(frost)
404 }
405
406 // ---- derived materials -------------------------------------------------
407
408 /// The well floor cut into this plate: the same material with the tint
409 /// darkened by `WELL_FLOOR`'s strength (`WELL_FLOOR_LIFTED`'s when
410 /// `lifted`, the hover cue). Frost and finish carried through — a well in
411 /// glass is deeper glass (RFC § 11 (3)). `PaintCtx::well_floor` draws
412 /// this for a FROSTED host; an opaque host's floor stays the darkening
413 /// overlay, which is this exactly at plate alpha 1 and the honest
414 /// darkening at any other alpha (a darkened fill at the plate's own
415 /// alpha would barely darken a translucent plate).
416 pub fn floor(&self, lifted: bool) -> Material {
417 let overlay = if lifted { crate::color::WELL_FLOOR_LIFTED } else { crate::color::WELL_FLOOR };
418 let keep = 1.0 - overlay[3];
419 let t = self.tint;
420 Material { tint: [t[0] * keep, t[1] * keep, t[2] * keep, t[3]], ..*self }
421 }
422
423 /// A `Flat`-stance control on this pane: the material verbatim. Exists so
424 /// the call site says what it means.
425 pub fn flat_control(&self) -> Material {
426 *self
427 }
428
429 /// A control face from a configured fill, under the rule
430 /// the control rung states: an opaque one is the face
431 /// (alpha forced to 1 — a translucent face would blend into the relief's
432 /// shading and read as a second material); a transparent one is `None`,
433 /// the surface below showing as the face (edges only).
434 pub fn control_face(raw: [f32; 4]) -> Option<Material> {
435 (raw[3] > 0.001).then(|| Self::opaque([raw[0], raw[1], raw[2], 1.0]))
436 }
437
438 // ---- the one encoding function ----------------------------------------
439
440 /// The vertex colour the renderer consumes for a plate of this material
441 /// in `role` — see [`Material::fill_tint`].
442 pub fn fill(&self, role: PlateRole) -> [f32; 4] {
443 Self::fill_tint(self.tint, self.frost, role)
444 }
445
446 /// THE place a negative alpha is written. For `tint` under `frost` in
447 /// `role`:
448 /// - [`PlateRole::Root`] → alpha positive whatever `frost` says. A root
449 /// plate's frost is the compositor's blur-behind, never the in-app pass.
450 /// - nested + [`Frost::Frosted`] → the in-app frost pass's negative-alpha
451 /// sentinel, `-|alpha|`.
452 /// - nested + [`Frost::Opaque`] → the tint as is.
453 ///
454 /// Static so a caller holding a tint and a flag (today's `PlateSpec`)
455 /// encodes through the same rule without resolving a finish it does not
456 /// need.
457 pub fn fill_tint(tint: [f32; 4], frost: Frost, role: PlateRole) -> [f32; 4] {
458 let mut c = tint;
459 match role {
460 PlateRole::Root => c[3] = c[3].abs(),
461 PlateRole::Nested if frost.is_frosted() => c[3] = -c[3].abs(),
462 PlateRole::Nested => {}
463 }
464 c
465 }
466 }
467
468 #[cfg(test)]
469 mod tests {
470 use super::*;
471
472 fn frosted() -> Frost {
473 Frost::Frosted { compression: 0.6, refraction: 0.3, radius: Frost::DEFAULT_RADIUS }
474 }
475
476 /// The encoding rule, stated once: root stays positive whatever the
477 /// frost, nested frost is the sentinel, nested opaque passes through.
478 #[test]
479 fn fill_encodes_by_role() {
480 let tint = [0.1, 0.2, 0.3, 0.8];
481 assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Root)[3], 0.8, "root frost is the compositor's");
482 assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Root)[3], 0.8);
483 assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Nested)[3], -0.8, "nested frost = sentinel");
484 assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Nested), tint, "no frost, no encoding");
485 // A caller that hands a negative alpha in is normalised, not doubled.
486 assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], frosted(), PlateRole::Nested)[3], -0.5);
487 assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], Frost::Opaque, PlateRole::Root)[3], 0.5);
488 let m = Material::opaque(tint).with_frost(frosted());
489 assert_eq!(m.fill(PlateRole::Nested), Material::fill_tint(tint, frosted(), PlateRole::Nested));
490 }
491
492 /// The pane rung is `param_plate_fill`'s old arithmetic exactly: the
493 /// tint at plate opacity, negated under plate blur.
494 #[test]
495 fn pane_resolves_like_param_plate_fill_did() {
496 let _lock = crate::color::test_color_state_lock();
497 let old = |blur: bool| {
498 let mut c = crate::color::param_bg_color();
499 c[3] *= crate::layout::plate_opacity();
500 if blur {
501 c[3] = -c[3].abs();
502 }
503 c
504 };
505 for blur in [false, true] {
506 crate::color::set_plate_blur(blur);
507 assert_eq!(Material::pane().fill(PlateRole::Nested), old(blur), "blur={blur}");
508 assert_eq!(crate::color::param_plate_fill(), old(blur), "blur={blur}");
509 assert_eq!(Material::pane().frost.is_frosted(), blur);
510 }
511 crate::color::set_plate_blur(false);
512 }
513
514 /// The finish defaults are the literals the shader shipped with, and the
515 /// push-constant layout is unchanged.
516 #[test]
517 fn finish_defaults_and_layout() {
518 let f = Finish::from_style();
519 assert_eq!(f.spec, 0.4);
520 assert_eq!(f.shininess, 24.0);
521 assert_eq!(f.curvature, 0.2);
522 assert_eq!(f.to_array(), [f.strength, f.spec, f.shininess, f.curvature]);
523 crate::color::set_finish_spec(0.9);
524 assert_eq!(Finish::from_style().spec, 0.9);
525 crate::color::set_finish_spec(0.4);
526 }
527
528 /// The frost recipe reads the two plate-rung keys and carries the default
529 /// kernel; the flag form is today's `blur: bool`.
530 #[test]
531 fn frost_from_style_and_flag() {
532 let _lock = crate::color::test_color_state_lock();
533 crate::color::set_plate_backdrop_compression(0.6);
534 crate::color::set_plate_refraction(0.3);
535 assert_eq!(Frost::from_style(), frosted());
536 assert_eq!(Frost::from_flag(false), Frost::Opaque);
537 assert!(Frost::from_flag(true).is_frosted());
538 crate::color::set_plate_backdrop_compression(0.0);
539 crate::color::set_plate_refraction(0.0);
540 }
541
542 /// A floor darkens the tint by the overlay's strength and keeps
543 /// everything else: alpha, frost, finish.
544 #[test]
545 fn floor_darkens_and_carries_the_frost() {
546 let m = Material::opaque([0.5, 0.5, 0.5, 0.7]).with_frost(frosted());
547 let f = m.floor(false);
548 let keep = 1.0 - crate::color::WELL_FLOOR[3];
549 assert!((f.tint[0] - 0.5 * keep).abs() < 1e-6);
550 assert_eq!(f.tint[3], 0.7);
551 assert_eq!(f.frost, m.frost);
552 assert_eq!(f.finish, m.finish);
553 assert!(m.floor(true).tint[0] > f.tint[0], "lifted rises toward the plate");
554 assert_eq!(m.flat_control(), m);
555 }
556
557 /// The legacy bridge round-trips every fill the old API accepted, and
558 /// the role resolution agrees with the encoding function.
559 #[test]
560 fn from_fill_round_trips_and_for_role_matches_fill() {
561 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]] {
562 let m = Material::from_fill(c);
563 assert_eq!(m.fill(PlateRole::Nested), c, "{c:?}");
564 assert_eq!(m.frost.is_frosted(), c[3] < 0.0);
565 assert!(m.tint[3] >= 0.0, "tint alpha is never negative");
566 for role in [PlateRole::Root, PlateRole::Nested] {
567 assert_eq!(m.for_role(role).fill(PlateRole::Nested), m.fill(role), "{c:?} {role:?}");
568 }
569 }
570 assert_eq!(Material::from_fill([0.0, 0.0, 0.0, -0.5]).frost, Frost::from_style());
571 assert!(Material::face([0.3, 0.3, 0.3, 0.0]).is_none(), "transparent = no face");
572 assert!(Material::face([0.3, 0.3, 0.3, -0.5]).is_some_and(|m| m.frost.is_frosted()));
573 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]));
574 }
575
576 /// The pack is exact on its own grid, monotone, and never mixes the two
577 /// halves; the shader's literals are the ones the Rust twin uses.
578 #[test]
579 fn frost_pack_round_trips() {
580 for i in [0u32, 1, 2, 613, 614, 2047, 2048, 4094, 4095] {
581 for j in [0u32, 1, 819, 4095] {
582 let (c, r) = (i as f32 / Frost::PACK_MAX, j as f32 / Frost::PACK_MAX);
583 let f = Frost::Frosted { compression: c, refraction: r, radius: 5.5 };
584 let [z, w] = f.pack(2.0);
585 let (c2, r2) = Frost::unpack(z);
586 assert!((c2 - c).abs() < 1e-6 && (r2 - r).abs() < 1e-6, "{i},{j}: {c},{r} -> {c2},{r2}");
587 assert_eq!(w, 11.0);
588 assert!(z < (1u32 << 24) as f32, "packed value must stay an exact f32 integer");
589 }
590 }
591 // 0.6 / 0.3 (the designer's recipe) survive to better than a 1/255 step.
592 let (c, r) = Frost::unpack(Frost::Frosted { compression: 0.6, refraction: 0.3, radius: 0.0 }.pack(1.0)[0]);
593 assert!((c - 0.6).abs() < 1.0 / 510.0 && (r - 0.3).abs() < 1.0 / 510.0);
594 assert_eq!(Frost::Opaque.pack(2.0), [0.0, 0.0]);
595 assert_eq!(Frost::Frosted { compression: 0.0, refraction: 0.0, radius: 0.0 }.pack(2.0), [0.0, 0.0]);
596
597 let wgsl = include_str!("../vk/shader2d.wgsl");
598 let lit = |name: &str| -> f32 {
599 let rest = wgsl.split(&format!("const {name}: f32 = ")).nth(1).unwrap_or_else(|| panic!("{name} missing"));
600 rest.split(';').next().unwrap().trim().parse().unwrap()
601 };
602 assert_eq!(lit("FROST_PACK_MAX"), Frost::PACK_MAX);
603 assert_eq!(lit("FROST_PACK_BASE"), Frost::PACK_BASE);
604 // The droplet's and the raw-vertex fallback's stride is the panel's
605 // default kernel in physical px: DEFAULT_RADIUS × scale 2 / 2.
606 assert_eq!(lit("LEGACY_STRIDE"), Frost::DEFAULT_RADIUS * 2.0 / 2.0);
607 }
608
609 const DESIGNER_LEGACY: &str = r##"
610 style {
611 surface {
612 param color=(rgba)"#05050840"
613 plate backdrop_compression=(f64)0.6 refraction=(f64)0.3 bevel_width=(f64)12.0 blur=(bool)true {
614 root corner_radius=(i64)24
615 }
616 relief depth=(f64)0.08
617 }
618 }
619 "##;
620
621 const DESIGNER_NAMED: &str = r##"
622 style {
623 surface {
624 material {
625 glass {
626 color (rgba)"#05050840"
627 frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
628 }
629 }
630 plate material="glass" bevel_width=(f64)12.0 {
631 root corner_radius=(i64)24
632 }
633 relief depth=(f64)0.08
634 }
635 }
636 "##;
637
638 /// The designer's frosted pane spelled with the legacy keys and as a
639 /// named material bound to the pane rung resolve to the SAME material —
640 /// the step-4 exit test: same Material, same bytes (steps 2–3).
641 #[test]
642 fn named_material_round_trips_the_legacy_spelling() {
643 let _lock = crate::color::test_color_state_lock();
644 crate::layout::lazy_init_style_registry();
645 let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
646 crate::layout::set_plate_opacity(1.0);
647 crate::color::reload_colors(DESIGNER_LEGACY);
648 assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
649 let legacy = Material::pane();
650 assert!(legacy.frost.is_frosted());
651 assert!((legacy.tint[3] - 0x40 as f32 / 255.0).abs() < 1e-6, "{:?}", legacy.tint);
652
653 crate::color::reload_colors(DESIGNER_NAMED);
654 assert_eq!(crate::color::material_binding(PlateRung::Pane).as_deref(), Some("glass"));
655 let named = Material::pane();
656 assert_eq!(named.tint, legacy.tint);
657 assert_eq!(named.finish, legacy.finish);
658 match (named.frost, legacy.frost) {
659 (Frost::Frosted { compression: c1, refraction: r1, radius: d1 }, Frost::Frosted { compression: c2, refraction: r2, radius: d2 }) => {
660 assert!((c1 - c2).abs() < 1e-6 && (r1 - r2).abs() < 1e-6 && d1 == d2, "{:?} vs {:?}", named.frost, legacy.frost);
661 }
662 other => panic!("{other:?}"),
663 }
664 // The DE recipe follows the bound pane.
665 assert_eq!(Frost::from_style(), named.frost);
666 assert_eq!(Material::named("glass"), Some(named));
667 assert_eq!(Material::named("nope"), None);
668 // The other rungs are unbound and unchanged.
669 assert_eq!(Material::root(), Material::legacy(PlateRung::Root));
670 assert_eq!(Material::control(), Material::legacy(PlateRung::Control));
671 assert_eq!(crate::color::material_names(), vec!["glass".to_string()]);
672 // Bindings and nodes are replaced wholesale by every load, so an
673 // empty document unbinds every rung for the tests that follow.
674 crate::color::reload_colors("");
675 assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
676 }
677
678 /// A binding wins over the legacy keys, a node without `frost` is opaque
679 /// whatever `plate blur` says, unset fields fall back to the rung, and a
680 /// binding to an undefined name degrades to the legacy material.
681 #[test]
682 fn binding_semantics() {
683 let _lock = crate::color::test_color_state_lock();
684 crate::layout::lazy_init_style_registry();
685 let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
686 crate::color::reload_colors(r##"
687 style {
688 surface {
689 material {
690 plastic {
691 finish spec=(f64)0.25 shininess=(f64)12.0
692 }
693 matte {
694 color (rgba)"#20202080"
695 finish light=(f64)0.3
696 }
697 }
698 plate blur=(bool)true material="plastic"
699 relief depth=(f64)0.15 spec=(f64)0.5 shininess=(f64)20.0 curvature=(f64)0.1
700 }
701 control material="ghost"
702 }
703 "##);
704 let pane = Material::pane();
705 assert_eq!(pane.frost, Frost::Opaque, "no frost child = opaque, blur flag or not");
706 assert_eq!(pane.tint, Material::legacy(PlateRung::Pane).tint, "no color = the rung's tint");
707 assert_eq!(pane.finish.spec, 0.25);
708 assert_eq!(pane.finish.shininess, 12.0);
709 assert_eq!(pane.finish.curvature, 0.1, "unset finish keys take the DE's (relief curvature)");
710 assert_eq!(Finish::from_style().spec, 0.5, "style.surface.relief.spec is the DE finish");
711 assert_eq!(Material::named("matte").map(|m| m.finish.strength), Some(0.3 / 0.15));
712 assert_eq!(Material::control(), Material::legacy(PlateRung::Control), "unknown name = unbound");
713 assert!(Frost::from_style().is_frosted(), "an opaque bound pane leaves the DE recipe to the keys");
714 crate::color::set_finish_spec(0.4);
715 crate::color::set_finish_shininess(24.0);
716 crate::color::set_finish_curvature(0.2);
717 crate::color::reload_colors("");
718 }
719
720 /// A popover is the base colour at menu opacity, frosted — the bytes the
721 /// three menu sites used to write by negating an alpha.
722 #[test]
723 fn popover_is_the_menu_recipe() {
724 let _lock = crate::color::test_color_state_lock();
725 let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
726 let mut old = [0.1, 0.2, 0.3, 1.0];
727 old[3] = -crate::color::menu_opacity();
728 assert_eq!(m.fill(PlateRole::Nested), old);
729 assert!(m.frost.is_frosted());
730 }
731
732 /// A popover's frost compresses at the menu key, not the DE recipe's:
733 /// the two are independent dials.
734 #[test]
735 fn popover_compresses_at_the_menu_key() {
736 let _lock = crate::color::test_color_state_lock();
737 crate::color::set_plate_backdrop_compression(0.1);
738 crate::color::set_menu_compression(0.7);
739 let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
740 let Frost::Frosted { compression, .. } = m.frost else { panic!("popover is frosted") };
741 assert!((compression - 0.7).abs() < 1e-6, "popover compression {compression}");
742 let Frost::Frosted { compression: pane, .. } = Frost::from_style() else { panic!("recipe is frosted") };
743 assert!((pane - 0.1).abs() < 1e-6, "recipe compression {pane}");
744 crate::color::set_plate_backdrop_compression(0.0);
745 crate::color::set_menu_compression(0.6);
746 }
747
748 /// The control-face rule: opaque or nothing.
749 #[test]
750 fn control_face_is_opaque_or_none() {
751 let face = Material::control_face([0.2, 0.3, 0.4, 0.5]).expect("a fill is a face");
752 assert_eq!(face.tint, [0.2, 0.3, 0.4, 1.0]);
753 assert_eq!(face.frost, Frost::Opaque);
754 assert!(Material::control_face([0.2, 0.3, 0.4, 0.0]).is_none());
755 assert_eq!(Material::control().frost, Frost::Opaque);
756 assert_eq!(Material::root().frost, Frost::Opaque);
757 }
758 }