Skip to main content

ferroui_base/media/
acrylic_brush.rs

1//! `AcrylicBrush` of WinUI (`Microsoft.UI.Xaml.Media.AcrylicBrush`,
2//! `controls/dev/Materials/Acrylic/AcrylicBrush.cpp` of microsoft-ui-xaml):
3//! the in-app acrylic material, a blurred backdrop with a luminosity and a
4//! tint, and noise (winui-materials.md).
5
6use crate::media::{
7    BrushImpl, Color, MaterialPolicy, XamlCompositionBrushBase, XamlCompositionBrushBaseImpl,
8};
9use crate::numerics::Vector2;
10use crate::reactive::IDisposable;
11use crate::rendering::composition::effects::{
12    BlendEffect, BlendEffectMode, BorderEffect, CanvasComposite, CanvasEdgeBehavior, ColorSourceEffect,
13    CompositeEffect, CompositionEffectFactory, CompositionEffectSourceParameter, CrossFadeEffect, EffectBorderMode,
14    GaussianBlurEffect, GraphicsEffect, GraphicsEffectSource, OpacityEffect,
15};
16use crate::rendering::composition::{
17    CompositionBatchTypes, CompositionBrush, CompositionScopedBatch, Compositor, ICompositionObjectAnimations,
18};
19use crate::{
20    ferro_class, ferro_impl_classes, ferro_property, instantiate, FerroObjectImpl, FerroObjectImplExt, FerroProperty,
21    FerroPropertyChangedEventArgs, Ref, StyledProperty, StyledPropertyOptions,
22};
23use std::cell::RefCell;
24use std::rc::{Rc, Weak};
25use std::time::Duration;
26
27/// `sc_blurRadius`: the blur amount of the backdrop.
28pub const ACRYLIC_BLUR_AMOUNT: f32 = 30.0;
29/// `sc_noiseOpacity`: the opacity of the noise.
30pub const ACRYLIC_NOISE_OPACITY: f32 = 0.02;
31/// The duration of the cross-fade between the material and its fallback
32/// colour.
33pub const ACRYLIC_FALLBACK_CROSS_FADE: Duration = Duration::from_millis(167);
34
35const TINT_COLOR_COLOR: &str = "TintColor.Color";
36const LUMINOSITY_COLOR_COLOR: &str = "LuminosityColor.Color";
37const FALLBACK_COLOR_COLOR: &str = "FallbackColor.Color";
38const OPAQUE_FALLBACK_COLOR_COLOR: &str = "OpaqueFallbackColor.Color";
39const FADE_IN_OUT_WEIGHT: &str = "FadeInOut.Weight";
40
41// --- the colour math of WinUI --------------------------------------------------------
42
43/// A colour in hue (degrees), saturation and value (0 to 1).
44#[derive(Clone, Copy, Debug, PartialEq)]
45struct Hsv {
46    h: f64,
47    s: f64,
48    v: f64,
49}
50
51fn rgb_to_hsv(r: f64, g: f64, b: f64) -> Hsv {
52    let max = r.max(g).max(b);
53    let min = r.min(g).min(b);
54    let delta = max - min;
55    let h = if delta <= 0.0 {
56        0.0
57    } else if max == r {
58        60.0 * (((g - b) / delta).rem_euclid(6.0))
59    } else if max == g {
60        60.0 * ((b - r) / delta + 2.0)
61    } else {
62        60.0 * ((r - g) / delta + 4.0)
63    };
64    let s = if max <= 0.0 { 0.0 } else { delta / max };
65    Hsv { h, s, v: max }
66}
67
68fn hsv_to_rgb(hsv: Hsv) -> (f64, f64, f64) {
69    let c = hsv.v * hsv.s;
70    let h = hsv.h.rem_euclid(360.0) / 60.0;
71    let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
72    let (r, g, b) = match h as u32 {
73        0 => (c, x, 0.0),
74        1 => (x, c, 0.0),
75        2 => (0.0, c, x),
76        3 => (0.0, x, c),
77        4 => (x, 0.0, c),
78        _ => (c, 0.0, x),
79    };
80    let m = hsv.v - c;
81    (r + m, g + m, b + m)
82}
83
84fn channels(color: Color) -> (f64, f64, f64) {
85    (color.r as f64 / 255.0, color.g as f64 / 255.0, color.b as f64 / 255.0)
86}
87
88fn channel(value: f64) -> u8 {
89    (value * 255.0).round().clamp(0.0, 255.0) as u8
90}
91
92/// `GetTintOpacityModifier`: how much of the tint opacity a colour keeps.
93/// Pure white keeps 45 %, pure black 85 %, grey 90 %; the suppression grows
94/// linearly with the distance of the value from 50 % and shrinks as the
95/// saturation grows.
96pub fn tint_opacity_modifier(tint_color: Color) -> f64 {
97    const MID_POINT: f64 = 0.50;
98    const WHITE_MAX_OPACITY: f64 = 0.45;
99    const MID_POINT_MAX_OPACITY: f64 = 0.90;
100    const BLACK_MAX_OPACITY: f64 = 0.85;
101
102    let (r, g, b) = channels(tint_color);
103    let hsv = rgb_to_hsv(r, g, b);
104    let mut opacity_modifier = MID_POINT_MAX_OPACITY;
105    if hsv.v != MID_POINT {
106        let mut lowest_max_opacity = MID_POINT_MAX_OPACITY;
107        let mut max_deviation = MID_POINT;
108        if hsv.v > MID_POINT {
109            lowest_max_opacity = WHITE_MAX_OPACITY;
110            max_deviation = 1.0 - max_deviation;
111        } else if hsv.v < MID_POINT {
112            lowest_max_opacity = BLACK_MAX_OPACITY;
113        }
114        let mut max_opacity_suppression = MID_POINT_MAX_OPACITY - lowest_max_opacity;
115        let deviation = (hsv.v - MID_POINT).abs();
116        let normalized_deviation = deviation / max_deviation;
117        if hsv.s > 0.0 {
118            max_opacity_suppression *= (1.0 - hsv.s * 2.0).max(0.0);
119        }
120        let opacity_suppression = max_opacity_suppression * normalized_deviation;
121        opacity_modifier = MID_POINT_MAX_OPACITY - opacity_suppression;
122    }
123    opacity_modifier
124}
125
126/// `GetLuminosityColor`: the colour of the luminosity blend for a tint
127/// colour whose alpha is the tint opacity the user set. With a luminosity
128/// opacity it is the tint colour at that opacity; without one, the tint
129/// colour with its value clamped to 12.5 %..96.5 %, at an opacity mapped
130/// from the tint opacity onto 15 %..103 % (at most 100 %).
131pub fn luminosity_color(tint_color: Color, luminosity_opacity: Option<f64>) -> Color {
132    let (r, g, b) = channels(tint_color);
133    if let Some(opacity) = luminosity_opacity {
134        return Color::from_argb(channel(opacity.clamp(0.0, 1.0)), tint_color.r, tint_color.g, tint_color.b);
135    }
136    const MIN_HSV_V: f64 = 0.125;
137    const MAX_HSV_V: f64 = 0.965;
138    let hsv = rgb_to_hsv(r, g, b);
139    let (lr, lg, lb) = hsv_to_rgb(Hsv { v: hsv.v.clamp(MIN_HSV_V, MAX_HSV_V), ..hsv });
140    const MIN_LUMINOSITY_OPACITY: f64 = 0.15;
141    const MAX_LUMINOSITY_OPACITY: f64 = 1.03;
142    let range = MAX_LUMINOSITY_OPACITY - MIN_LUMINOSITY_OPACITY;
143    let mapped = (tint_color.a as f64 / 255.0) * range + MIN_LUMINOSITY_OPACITY;
144    Color::from_argb(channel(mapped.min(1.0)), channel(lr), channel(lg), channel(lb))
145}
146
147// --- the effect graph ------------------------------------------------------------------
148
149fn parameter(name: &str) -> Option<GraphicsEffectSource> {
150    Some(CompositionEffectSourceParameter::new(name).into())
151}
152
153/// What an acrylic brush blurs (WinUI 2 `AcrylicBrushBackgroundSource`, the
154/// `BackgroundSource` of `AcrylicBrush`; WinUI 3 has the backdrop only).
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
156#[repr(i32)]
157pub enum AcrylicBrushBackgroundSource {
158    /// What is behind the window, blurred by the platform (window acrylic).
159    HostBackdrop = 0,
160    /// What the window draws behind the element (in-app acrylic).
161    #[default]
162    Backdrop = 1,
163}
164
165crate::ferro_markup_enum!(AcrylicBrushBackgroundSource { HostBackdrop, Backdrop }, { namespace: "FerroUI.Media" });
166
167/// The effect of WinUI's acrylic ("shadow-friendly luminosity-based
168/// recipe"): an opaque tint with noise, or the backdrop over the opaque
169/// fallback colour, blurred by 30 (with hard edges), its luminosity taken
170/// from the luminosity colour, its colour from the tint, with noise; with
171/// `use_cross_fade`, cross-faded with the fallback colour. Returns the
172/// effect and its animatable properties.
173pub fn acrylic_effect(
174    should_brush_be_opaque: bool,
175    use_cross_fade: bool,
176    tint_color: Color,
177    luminosity_color: Color,
178    fallback_color: Color,
179) -> (GraphicsEffect, Vec<&'static str>) {
180    acrylic_effect_with_source(
181        should_brush_be_opaque,
182        use_cross_fade,
183        AcrylicBrushBackgroundSource::Backdrop,
184        tint_color,
185        luminosity_color,
186        fallback_color,
187    )
188}
189
190/// [`acrylic_effect`] for a background source. The host backdrop (WinUI 2's
191/// window acrylic) is blurred by the platform behind the window, so it is
192/// not blurred again; the compositor cannot read it, so the luminosity and
193/// the tint are composed over it source-over (winui-materials.md,
194/// deviations), the host backdrop brush first, so that it shows through the
195/// window where the brush paints.
196pub fn acrylic_effect_with_source(
197    should_brush_be_opaque: bool,
198    use_cross_fade: bool,
199    source: AcrylicBrushBackgroundSource,
200    tint_color: Color,
201    luminosity_color: Color,
202    fallback_color: Color,
203) -> (GraphicsEffect, Vec<&'static str>) {
204    let tint: GraphicsEffect = ColorSourceEffect { name: "TintColor".into(), color: tint_color }.into();
205    let mut animatable = vec![TINT_COLOR_COLOR];
206
207    let tint_output: GraphicsEffect = if should_brush_be_opaque {
208        tint
209    } else if source == AcrylicBrushBackgroundSource::HostBackdrop {
210        animatable.push(LUMINOSITY_COLOR_COLOR);
211        CompositeEffect {
212            name: String::new(),
213            mode: CanvasComposite::SourceOver,
214            sources: vec![
215                CompositionEffectSourceParameter::new("Backdrop").into(),
216                ColorSourceEffect { name: "LuminosityColor".into(), color: luminosity_color }.into(),
217                tint.into(),
218            ],
219        }
220        .into()
221    } else {
222        // The backdrop over the opaque fallback colour, so that the blur
223        // does not blur with transparency.
224        let opaque_fallback = Color { a: 255, ..fallback_color };
225        animatable.push(OPAQUE_FALLBACK_COLOR_COLOR);
226        let blended_backdrop = CompositeEffect {
227            name: String::new(),
228            mode: CanvasComposite::SourceOver,
229            sources: vec![
230                ColorSourceEffect { name: "OpaqueFallbackColor".into(), color: opaque_fallback }.into(),
231                CompositionEffectSourceParameter::new("Backdrop").into(),
232            ],
233        };
234        let blur = GaussianBlurEffect {
235            name: "Blur".into(),
236            blur_amount: ACRYLIC_BLUR_AMOUNT,
237            border_mode: EffectBorderMode::Hard,
238            source: Some(blended_backdrop.into()),
239            ..Default::default()
240        };
241        animatable.push(LUMINOSITY_COLOR_COLOR);
242        // The hue and saturation of the blurred backdrop with the
243        // luminosity of the luminosity colour.
244        //
245        // Deviation (winui-materials.md): WinUI writes `BlendEffectMode::Color`
246        // here, because the names of `Color` and `Luminosity` are swapped in
247        // its blend effect; FerroUI's blend effect has the meaning Win2D
248        // documents, so the mode that does what WinUI draws is `Luminosity`.
249        let luminosity_blend = BlendEffect {
250            name: String::new(),
251            mode: BlendEffectMode::Luminosity,
252            background: Some(blur.into()),
253            foreground: Some(ColorSourceEffect { name: "LuminosityColor".into(), color: luminosity_color }.into()),
254        };
255        // The hue and saturation of the tint with the luminosity of the
256        // above (WinUI writes `Luminosity`, see above).
257        BlendEffect {
258            name: String::new(),
259            mode: BlendEffectMode::Color,
260            background: Some(luminosity_blend.into()),
261            foreground: Some(tint.into()),
262        }
263        .into()
264    };
265
266    // The noise, tiled, at 2 %, over the tint.
267    let noise = OpacityEffect {
268        name: "NoiseOpacity".into(),
269        opacity: ACRYLIC_NOISE_OPACITY,
270        source: Some(
271            BorderEffect {
272                name: String::new(),
273                extend_x: CanvasEdgeBehavior::Wrap,
274                extend_y: CanvasEdgeBehavior::Wrap,
275                source: parameter("Noise"),
276            }
277            .into(),
278        ),
279    };
280    let outer: GraphicsEffect = CompositeEffect {
281        name: String::new(),
282        mode: CanvasComposite::SourceOver,
283        sources: vec![tint_output.into(), noise.into()],
284    }
285    .into();
286
287    if !use_cross_fade {
288        return (outer, animatable);
289    }
290    animatable.push(FALLBACK_COLOR_COLOR);
291    animatable.push(FADE_IN_OUT_WEIGHT);
292    let fade = CrossFadeEffect {
293        name: "FadeInOut".into(),
294        weight: 1.0,
295        source1: Some(ColorSourceEffect { name: "FallbackColor".into(), color: fallback_color }.into()),
296        source2: Some(outer.into()),
297    };
298    (fade.into(), animatable)
299}
300
301crate::ferro_thread_local! {
302    /// The effect factories of acrylic per compositor and form (WinUI's
303    /// `MaterialHelper` cache), keyed by opaque and cross-fade.
304    static FACTORIES: RefCell<Vec<(Weak<Compositor>, bool, bool, AcrylicBrushBackgroundSource, Rc<CompositionEffectFactory>)>> = const { RefCell::new(Vec::new()) };
305}
306
307fn factory(
308    compositor: &Rc<Compositor>,
309    opaque: bool,
310    cross_fade: bool,
311    source: AcrylicBrushBackgroundSource,
312    tint: Color,
313    luminosity: Color,
314    fallback: Color,
315) -> Rc<CompositionEffectFactory> {
316    let cached = FACTORIES.with(|factories| {
317        let mut factories = factories.borrow_mut();
318        factories.retain(|(owner, ..)| owner.strong_count() > 0);
319        factories
320            .iter()
321            .find(|(owner, o, c, s, _)| {
322                *o == opaque && *c == cross_fade && *s == source && owner.upgrade().is_some_and(|owner| Rc::ptr_eq(&owner, compositor))
323            })
324            .map(|(.., factory)| factory.clone())
325    });
326    if let Some(cached) = cached {
327        return cached;
328    }
329    let (effect, animatable) = acrylic_effect_with_source(opaque, cross_fade, source, tint, luminosity, fallback);
330    let factory = compositor.create_effect_factory_with_properties(effect, &animatable);
331    FACTORIES.with(|factories| factories.borrow_mut().push((Rc::downgrade(compositor), opaque, cross_fade, source, factory.clone())));
332    factory
333}
334
335// --- the brush -----------------------------------------------------------------------
336
337#[derive(Default)]
338struct AcrylicState {
339    /// The composition brush: the effect brush of the material, or a colour
340    /// brush of the fallback colour.
341    brush: Option<Rc<CompositionBrush>>,
342    is_using_acrylic: bool,
343    is_using_opaque: bool,
344    /// The background source the brush was created for.
345    is_using_host_backdrop: bool,
346    is_disabled_by_policy: bool,
347    /// The cross-fade between the material and the fallback colour that is
348    /// running, and the subscription to its end.
349    waiting_for_fallback: Option<(Rc<CompositionScopedBatch>, Rc<dyn IDisposable>)>,
350    policy_subscription: Option<Rc<dyn IDisposable>>,
351    activation_subscription: Option<Rc<dyn IDisposable>>,
352}
353
354/// A brush that paints the acrylic material of WinUI (WinUI
355/// `Microsoft.UI.Xaml.Media.AcrylicBrush`): what is behind the element,
356/// blurred, with its luminosity taken from a luminosity colour and its
357/// colour from the tint, and a little noise; or, when the tint is opaque,
358/// the tint with noise.
359///
360/// The brush falls back to its [`fallback_color`](XamlCompositionBrushBase::fallback_color)
361/// when [`always_use_fallback`](Self::always_use_fallback) is set or the
362/// [`MaterialPolicy`] disables materials (energy saver, transparency effects
363/// off), cross-fading over 167 ms. With the [`background_source`](Self::background_source)
364/// `HostBackdrop` (WinUI 2's window acrylic) it shows what is behind the
365/// window, and also falls back while no window of the application is
366/// active; in a window the platform shows nothing behind (an opaque
367/// window) it paints its fallback colour. Changes of the tint animate over
368/// [`tint_transition_duration`](Self::tint_transition_duration).
369#[repr(C)]
370pub struct AcrylicBrush {
371    base: XamlCompositionBrushBase,
372    state: RefCell<AcrylicState>,
373}
374
375ferro_class!(AcrylicBrush: XamlCompositionBrushBase);
376crate::ferro_class_info!(AcrylicBrush {
377    new: AcrylicBrush::new,
378    markup: {
379        namespace: "FerroUI.Media",
380    },
381});
382ferro_impl_classes!(AcrylicBrush: BrushImpl);
383
384impl FerroObjectImpl for AcrylicBrush {
385    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
386        Self::parent_on_property_changed(this, change);
387        let property = change.property();
388        if property == Self::tint_color_property().as_property()
389            || property == Self::tint_opacity_property().as_property()
390            || property == Self::tint_luminosity_opacity_property().as_property()
391        {
392            this.on_tint_changed(property == Self::tint_luminosity_opacity_property().as_property());
393        } else if property == Self::always_use_fallback_property().as_property()
394            || property == Self::background_source_property().as_property()
395        {
396            this.update_acrylic_brush();
397        } else if property == XamlCompositionBrushBase::fallback_color_property().as_property() {
398            this.on_fallback_color_changed();
399        }
400    }
401}
402
403impl XamlCompositionBrushBaseImpl for AcrylicBrush {
404    fn on_connected(this: &Self) {
405        let weak = this.to_ref().downgrade();
406        // Called at once with the current state, before the brush counts as
407        // connected (as in WinUI).
408        let subscription = MaterialPolicy::policy_changed(move |disabled| {
409            if let Some(this) = weak.upgrade() {
410                this.state.borrow_mut().is_disabled_by_policy = disabled;
411                if this.state.borrow().policy_subscription.is_some() {
412                    this.update_acrylic_brush();
413                }
414            }
415        });
416        this.state.borrow_mut().policy_subscription = Some(subscription);
417        let weak = this.to_ref().downgrade();
418        let activation = MaterialPolicy::window_activation_changed(move |_| {
419            if let Some(this) = weak.upgrade() {
420                if this.background_source() == AcrylicBrushBackgroundSource::HostBackdrop {
421                    this.update_acrylic_brush();
422                }
423            }
424        });
425        this.state.borrow_mut().activation_subscription = Some(activation);
426        this.update_acrylic_brush();
427    }
428
429    fn on_disconnected(this: &Self) {
430        this.cancel_fallback_animation_complete_wait();
431        let (subscriptions, brush) = {
432            let mut state = this.state.borrow_mut();
433            state.is_using_acrylic = false;
434            ([state.policy_subscription.take(), state.activation_subscription.take()], state.brush.take())
435        };
436        for subscription in subscriptions.into_iter().flatten() {
437            subscription.dispose();
438        }
439        if let Some(brush) = brush {
440            brush.object().dispose();
441        }
442        this.set_composition_brush(None);
443    }
444}
445
446crate::ferro_properties! { impl AcrylicBrush {
447    ferro_property!(
448        /// `TintColor` (default `#CCFFFFFF`).
449        pub fn tint_color_property() -> StyledProperty<Color> {
450            FerroProperty::register::<AcrylicBrush, _>("TintColor", Color::from_argb(204, 255, 255, 255))
451        }
452    );
453
454    ferro_property!(
455        /// `TintOpacity` (default 1), coerced to 0..1.
456        pub fn tint_opacity_property() -> StyledProperty<f64> {
457            FerroProperty::register_with::<AcrylicBrush, _>(
458                "TintOpacity",
459                StyledPropertyOptions::new(1.0).coerce(|_, value: f64| if value.is_nan() { value } else { value.clamp(0.0, 1.0) }),
460            )
461        }
462    );
463
464    ferro_property!(
465        /// `TintLuminosityOpacity` (default none), coerced to 0..1.
466        pub fn tint_luminosity_opacity_property() -> StyledProperty<Option<f64>> {
467            FerroProperty::register_with::<AcrylicBrush, _>(
468                "TintLuminosityOpacity",
469                StyledPropertyOptions::new(None).coerce(|_, value: Option<f64>| {
470                    value.map(|value| if value.is_nan() { value } else { value.clamp(0.0, 1.0) })
471                }),
472            )
473        }
474    );
475
476    ferro_property!(
477        /// `TintTransitionDuration` (default 500 ms): how long a change of
478        /// the tint or of the fallback colour animates.
479        pub fn tint_transition_duration_property() -> StyledProperty<Duration> {
480            FerroProperty::register::<AcrylicBrush, _>("TintTransitionDuration", Duration::from_millis(500))
481        }
482    );
483
484    ferro_property!(
485        /// `BackgroundSource` (WinUI 2; default `Backdrop`): what the brush
486        /// blurs.
487        pub fn background_source_property() -> StyledProperty<AcrylicBrushBackgroundSource> {
488            FerroProperty::register::<AcrylicBrush, _>("BackgroundSource", AcrylicBrushBackgroundSource::Backdrop)
489        }
490    );
491
492    ferro_property!(
493        /// `AlwaysUseFallback` (default `false`).
494        pub fn always_use_fallback_property() -> StyledProperty<bool> {
495            FerroProperty::register::<AcrylicBrush, _>("AlwaysUseFallback", false)
496        }
497    );
498} }
499
500impl AcrylicBrush {
501    /// Creates the class data.
502    pub fn construct() -> Self {
503        Self { base: XamlCompositionBrushBase::construct(), state: RefCell::new(AcrylicState::default()) }
504    }
505
506    pub fn new() -> Ref<Self> {
507        instantiate(Self::construct())
508    }
509
510    pub fn tint_color(&self) -> Color {
511        self.get_value(Self::tint_color_property())
512    }
513
514    pub fn set_tint_color(&self, value: Color) {
515        self.set_value(Self::tint_color_property(), value)
516    }
517
518    pub fn tint_opacity(&self) -> f64 {
519        self.get_value(Self::tint_opacity_property())
520    }
521
522    pub fn set_tint_opacity(&self, value: f64) {
523        self.set_value(Self::tint_opacity_property(), value)
524    }
525
526    pub fn tint_luminosity_opacity(&self) -> Option<f64> {
527        self.get_value(Self::tint_luminosity_opacity_property())
528    }
529
530    pub fn set_tint_luminosity_opacity(&self, value: Option<f64>) {
531        self.set_value(Self::tint_luminosity_opacity_property(), value)
532    }
533
534    pub fn tint_transition_duration(&self) -> Duration {
535        self.get_value(Self::tint_transition_duration_property())
536    }
537
538    pub fn set_tint_transition_duration(&self, value: Duration) {
539        self.set_value(Self::tint_transition_duration_property(), value)
540    }
541
542    pub fn background_source(&self) -> AcrylicBrushBackgroundSource {
543        self.get_value(Self::background_source_property())
544    }
545
546    pub fn set_background_source(&self, value: AcrylicBrushBackgroundSource) {
547        self.set_value(Self::background_source_property(), value)
548    }
549
550    pub fn always_use_fallback(&self) -> bool {
551        self.get_value(Self::always_use_fallback_property())
552    }
553
554    pub fn set_always_use_fallback(&self, value: bool) {
555        self.set_value(Self::always_use_fallback_property(), value)
556    }
557
558    /// `GetEffectiveTintColor`: the tint colour with its alpha times the
559    /// tint opacity (and the tint opacity modifier when no luminosity
560    /// opacity is set).
561    pub fn effective_tint_color(&self) -> Color {
562        let mut tint = self.tint_color();
563        let opacity = self.tint_opacity();
564        let modifier = if self.tint_luminosity_opacity().is_some() { 1.0 } else { tint_opacity_modifier(tint) };
565        tint.a = (tint.a as f64 * opacity * modifier).round().clamp(0.0, 255.0) as u8;
566        tint
567    }
568
569    /// `GetEffectiveLuminosityColor`: the luminosity colour of the tint at
570    /// the tint opacity the user set.
571    pub fn effective_luminosity_color(&self) -> Color {
572        let mut tint = self.tint_color();
573        tint.a = (tint.a as f64 * self.tint_opacity()).round().clamp(0.0, 255.0) as u8;
574        luminosity_color(tint, self.tint_luminosity_opacity())
575    }
576
577    /// Whether the brush draws the material now (rather than its fallback
578    /// colour).
579    pub fn is_using_acrylic_brush(&self) -> bool {
580        self.state.borrow().is_using_acrylic
581    }
582
583    /// Whether the cross-fade between the material and the fallback colour
584    /// is running.
585    pub fn is_waiting_for_fallback_animation(&self) -> bool {
586        self.state.borrow().waiting_for_fallback.is_some()
587    }
588
589    fn on_tint_changed(&self, luminosity_only: bool) {
590        let should_use_opaque = self.effective_tint_color().a == 255;
591        if self.state.borrow().is_using_opaque != should_use_opaque {
592            self.update_acrylic_brush();
593        }
594        let (brush, animate) = {
595            let state = self.state.borrow();
596            (state.brush.clone(), state.is_using_acrylic || state.waiting_for_fallback.is_some())
597        };
598        let Some(brush) = brush.filter(|_| animate) else { return };
599        let duration = self.tint_transition_duration();
600        if !luminosity_only {
601            animate_color(&brush, TINT_COLOR_COLOR, self.effective_tint_color(), duration);
602        }
603        if !should_use_opaque {
604            animate_color(&brush, LUMINOSITY_COLOR_COLOR, self.effective_luminosity_color(), duration);
605        }
606    }
607
608    fn on_fallback_color_changed(&self) {
609        let (brush, using_opaque, using_acrylic, waiting) = {
610            let state = self.state.borrow();
611            (state.brush.clone(), state.is_using_opaque, state.is_using_acrylic, state.waiting_for_fallback.is_some())
612        };
613        let Some(brush) = brush else { return };
614        let fallback = self.fallback_color();
615        if !using_opaque && (using_acrylic || waiting) {
616            brush.object().properties().insert_color(OPAQUE_FALLBACK_COLOR_COLOR, Color { a: 255, ..fallback });
617        }
618        if using_acrylic && !waiting {
619            // The material without the cross-fade has no fallback colour.
620            return;
621        }
622        let name = if using_acrylic || waiting { FALLBACK_COLOR_COLOR } else { "Color" };
623        animate_color(&brush, name, fallback, self.tint_transition_duration());
624    }
625
626    /// `UpdateAcrylicBrush`: creates the brush the first time, again when
627    /// the tint becomes opaque or stops being opaque, or cross-fades to or
628    /// from the fallback colour when the policy or `AlwaysUseFallback`
629    /// changes.
630    fn update_acrylic_brush(&self) {
631        if !self.is_connected() {
632            return;
633        }
634        let use_host_backdrop = self.background_source() == AcrylicBrushBackgroundSource::HostBackdrop;
635        let is_using_acrylic = !(self.state.borrow().is_disabled_by_policy
636            || self.always_use_fallback()
637            || (use_host_backdrop && !MaterialPolicy::is_window_active()));
638        let should_use_opaque = self.effective_tint_color().a == 255;
639        let (has_brush, using_opaque, currently_acrylic, using_host_backdrop) = {
640            let state = self.state.borrow();
641            (state.brush.is_some(), state.is_using_opaque, state.is_using_acrylic, state.is_using_host_backdrop)
642        };
643        self.set_needs_transparent_window(use_host_backdrop);
644        if !has_brush || using_opaque != should_use_opaque || using_host_backdrop != use_host_backdrop {
645            self.state.borrow_mut().is_using_host_backdrop = use_host_backdrop;
646            self.state.borrow_mut().is_using_acrylic = is_using_acrylic;
647            self.create_acrylic_brush(false, false);
648            return;
649        }
650        if currently_acrylic == is_using_acrylic {
651            return;
652        }
653        self.cancel_fallback_animation_complete_wait();
654        // The material with the cross-fade, animated between the fallback
655        // colour (weight 0) and the material (weight 1).
656        self.create_acrylic_brush(true, true);
657        self.state.borrow_mut().is_using_acrylic = is_using_acrylic;
658        let (start, end) = if is_using_acrylic { (0.0, 1.0) } else { (1.0, 0.0) };
659        let Some(brush) = self.state.borrow().brush.clone() else { return };
660        let compositor = brush.compositor().clone();
661        let batch = compositor.create_scoped_batch(CompositionBatchTypes::ANIMATION);
662        let animation = compositor.create_scalar_key_frame_animation();
663        let easing = compositor.create_cubic_bezier_easing_function(Vector2::new(0.5, 0.0), Vector2::new(0.0, 0.9));
664        animation.insert_key_frame(0.0, start);
665        animation.insert_key_frame_with_easing(1.0, end, easing);
666        animation.set_duration(ACRYLIC_FALLBACK_CROSS_FADE);
667        brush.start_animation(FADE_IN_OUT_WEIGHT, &*animation);
668        batch.end();
669        let weak = self.to_ref().downgrade();
670        let subscription = batch.completed(move |_| {
671            if let Some(this) = weak.upgrade() {
672                this.cancel_fallback_animation_complete_wait();
673                // The material (or the fallback colour) without the
674                // cross-fade.
675                this.create_acrylic_brush(false, false);
676            }
677        });
678        self.state.borrow_mut().waiting_for_fallback = Some((batch, subscription));
679    }
680
681    fn cancel_fallback_animation_complete_wait(&self) {
682        let waiting = self.state.borrow_mut().waiting_for_fallback.take();
683        if let Some((_, subscription)) = waiting {
684            subscription.dispose();
685        }
686    }
687
688    /// `CreateAcrylicBrush`: the effect brush of the material (with the
689    /// cross-fade when `use_cross_fade`), when the brush uses the material or
690    /// `force` is set; a colour brush of the fallback colour otherwise.
691    fn create_acrylic_brush(&self, use_cross_fade: bool, force: bool) {
692        self.cancel_fallback_animation_complete_wait();
693        let Some(compositor) = self.compositor() else { return };
694        let fallback = self.fallback_color();
695        let using_acrylic = self.state.borrow().is_using_acrylic;
696        let brush: Rc<CompositionBrush> = if force || using_acrylic {
697            let tint = self.effective_tint_color();
698            let luminosity = self.effective_luminosity_color();
699            let opaque = tint.a == 255;
700            self.state.borrow_mut().is_using_opaque = opaque;
701            let host = self.background_source() == AcrylicBrushBackgroundSource::HostBackdrop;
702            let source = if host { AcrylicBrushBackgroundSource::HostBackdrop } else { AcrylicBrushBackgroundSource::Backdrop };
703            let effect_brush = factory(&compositor, opaque, use_cross_fade, source, tint, luminosity, fallback).create_brush();
704            effect_brush.set_source_parameter("Noise", Some((*MaterialPolicy::noise_brush(&compositor)).clone()));
705            if !opaque {
706                let backdrop: Rc<CompositionBrush> = if host {
707                    (*compositor.create_host_backdrop_brush()).clone()
708                } else {
709                    (*compositor.create_backdrop_brush()).clone()
710                };
711                effect_brush.set_source_parameter("Backdrop", Some(backdrop));
712            }
713            let properties = effect_brush.properties();
714            properties.insert_color(TINT_COLOR_COLOR, tint);
715            if !opaque {
716                properties.insert_color(LUMINOSITY_COLOR_COLOR, luminosity);
717                if !host {
718                    properties.insert_color(OPAQUE_FALLBACK_COLOR_COLOR, Color { a: 255, ..fallback });
719                }
720            }
721            if use_cross_fade {
722                properties.insert_color(FALLBACK_COLOR_COLOR, fallback);
723            }
724            effect_brush.brush().clone()
725        } else {
726            (*compositor.create_color_brush_with_color(fallback)).clone()
727        };
728        let old = self.state.borrow_mut().brush.replace(brush.clone());
729        self.set_composition_brush(Some(brush));
730        if let Some(old) = old {
731            old.object().dispose();
732        }
733    }
734}
735
736/// Animates the colour `name` of `brush` to `color` over `duration` (at
737/// least 1 ms, as WinUI's `MakeColorAnimation`).
738fn animate_color(brush: &Rc<CompositionBrush>, name: &str, color: Color, duration: Duration) {
739    let compositor = brush.compositor().clone();
740    let animation = compositor.create_color_key_frame_animation();
741    animation.insert_key_frame(1.0, color);
742    animation.set_duration(if duration.is_zero() { Duration::from_millis(1) } else { duration });
743    brush.start_animation(name, &*animation);
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn the_tint_opacity_modifier_follows_winui() {
752        // Pure white keeps 45 %, pure black 85 %.
753        assert!((tint_opacity_modifier(Color::from_rgb(255, 255, 255)) - 0.45).abs() < 1e-9);
754        assert!((tint_opacity_modifier(Color::from_rgb(0, 0, 0)) - 0.85).abs() < 1e-9);
755        // A saturated colour is not suppressed past half its saturation.
756        assert!((tint_opacity_modifier(Color::from_rgb(255, 0, 0)) - 0.90).abs() < 1e-9);
757    }
758
759    #[test]
760    fn the_luminosity_colour_follows_winui() {
761        // With a luminosity opacity: the tint at that opacity.
762        let color = luminosity_color(Color::from_argb(38, 44, 44, 44), Some(0.96));
763        assert_eq!((color.a, color.r, color.g, color.b), (245, 44, 44, 44));
764        // Without: the value clamped to 12.5 %, the opacity mapped from the
765        // tint opacity.
766        let color = luminosity_color(Color::from_argb(0, 0, 0, 0), None);
767        assert_eq!(color.r, 32);
768        assert_eq!(color.a, (0.15f64 * 255.0).round() as u8);
769        let opaque = luminosity_color(Color::from_argb(255, 255, 255, 255), None);
770        assert_eq!(opaque.a, 255);
771        assert_eq!(opaque.r, (0.965f64 * 255.0).round() as u8);
772    }
773
774    #[test]
775    fn the_effective_colours_of_the_theme_brushes() {
776        // AcrylicInAppFillColorDefaultBrush of the dark theme.
777        let brush = AcrylicBrush::new();
778        brush.set_tint_color(Color::from_rgb(0x2c, 0x2c, 0x2c));
779        brush.set_tint_opacity(0.15);
780        brush.set_tint_luminosity_opacity(Some(0.96));
781        assert_eq!(brush.effective_tint_color().a, (255.0f64 * 0.15).round() as u8);
782        assert_eq!(brush.effective_luminosity_color().a, (0.96f64 * 255.0).round() as u8);
783        // Coercion.
784        brush.set_tint_opacity(3.0);
785        assert_eq!(brush.tint_opacity(), 1.0);
786        brush.set_tint_luminosity_opacity(Some(-1.0));
787        assert_eq!(brush.tint_luminosity_opacity(), Some(0.0));
788    }
789
790    #[test]
791    fn the_effect_has_the_animatable_properties_of_winui() {
792        let (_, translucent) = acrylic_effect(false, false, Color::default(), Color::default(), Color::default());
793        assert_eq!(translucent, vec![TINT_COLOR_COLOR, OPAQUE_FALLBACK_COLOR_COLOR, LUMINOSITY_COLOR_COLOR]);
794        let (_, opaque_fading) = acrylic_effect(true, true, Color::default(), Color::default(), Color::default());
795        assert_eq!(opaque_fading, vec![TINT_COLOR_COLOR, FALLBACK_COLOR_COLOR, FADE_IN_OUT_WEIGHT]);
796    }
797}