Skip to main content

gpui_kit_material/components/button/
playback.rs

1//! Concrete button presentation channels; timing/retargeting remain in Base.
2use super::*;
3
4/// Controlled presentation inputs for an unmounted logical button.
5/// `disabled` includes loading's disabled presentation. Transient interaction
6/// is deliberately absent: sync uses resting hover/focus/pointer state.
7#[derive(Clone, Copy, PartialEq)]
8pub struct ButtonTransitionInputs {
9    pub variant: ButtonVariant,
10    pub size: ButtonSize,
11    pub shape: ButtonShape,
12    pub toggle: bool,
13    pub selected: bool,
14    pub disabled: bool,
15    pub active: bool,
16    pub ripple: bool,
17}
18
19impl Default for ButtonTransitionInputs {
20    fn default() -> Self {
21        Self {
22            variant: ButtonVariant::default(),
23            size: ButtonSize::default(),
24            shape: ButtonShape::default(),
25            toggle: false,
26            selected: false,
27            disabled: false,
28            active: false,
29            ripple: true,
30        }
31    }
32}
33
34/// One logical button's visual transitions, independent of virtual mounting.
35/// Clones share only samples/curves. No focus, pointer, hover, ripple, window,
36/// or queued callback is retained. Forward offscreen controlled input changes
37/// through `sync`; call it after updating the Material motion scheme/theme.
38#[derive(Clone, Default)]
39pub struct ButtonTransitionPlayback {
40    last_sync: std::rc::Rc<std::cell::Cell<Option<SyncInputs>>>,
41    pub(super) background: motion::TokenTransitionPlayback<CssColor>,
42    pub(super) foreground: motion::TokenTransitionPlayback<CssColor>,
43    pub(super) border: motion::TokenTransitionPlayback<CssColor>,
44    pub(super) radius: motion::TokenTransitionPlayback<f32>,
45    pub(super) shadow: motion::TokenTransitionPlayback<shadow::ShadowPair>,
46    pub(super) opacity: gpui_base::TransitionPlayback<f32>,
47    opacity_target: std::rc::Rc<std::cell::Cell<f32>>,
48}
49#[derive(Clone, Copy, PartialEq)]
50struct SyncInputs {
51    inputs: ButtonTransitionInputs,
52    reduced: bool,
53    spatial: motion::MotionCurve,
54    effects: motion::MotionCurve,
55}
56impl ButtonTransitionPlayback {
57    pub(super) fn record_inputs(&self, inputs: ButtonTransitionInputs, cx: &App) {
58        self.last_sync.set(Some(Self::observed(inputs, cx)));
59    }
60
61    fn observed(inputs: ButtonTransitionInputs, cx: &App) -> SyncInputs {
62        SyncInputs {
63            inputs,
64            reduced: cx.reduce_motion(),
65            spatial: motion::curve(MotionToken::FastSpatial),
66            effects: motion::curve(MotionToken::DefaultEffects),
67        }
68    }
69    /// Sample actual inputs without requesting frames. First sync adopts the
70    /// target, so initializing unused handles does not create an animation.
71    pub fn sync(&self, inputs: ButtonTransitionInputs, cx: &App) {
72        let observed = Self::observed(inputs, cx);
73        // Repeated owner renders must not reset the visible button's transient
74        // targets to rest or allocate new color expressions every frame.
75        if self.last_sync.get() == Some(observed) {
76            return;
77        }
78        let only_preferences = self.last_sync.get().is_some_and(|old| old.inputs == inputs);
79        self.last_sync.set(Some(observed));
80        if only_preferences {
81            self.background.resample(MotionToken::DefaultEffects, cx);
82            self.foreground.resample(MotionToken::DefaultEffects, cx);
83            self.border.resample(MotionToken::DefaultEffects, cx);
84            self.radius.resample(MotionToken::FastSpatial, cx);
85            self.shadow.resample(MotionToken::DefaultEffects, cx);
86            self.opacity
87                .sample(self.opacity_target.get(), opacity_policy(), cx);
88            return;
89        }
90        let (bg, fg, border) = toggle_color_targets(
91            inputs.variant,
92            inputs.disabled,
93            inputs.toggle,
94            inputs.selected,
95        );
96        self.background.sample(bg, MotionToken::DefaultEffects, cx);
97        self.foreground.sample(fg, MotionToken::DefaultEffects, cx);
98        self.border.sample(border, MotionToken::DefaultEffects, cx);
99        let radius = if inputs.active {
100            inputs.size.geometry().pressed
101        } else {
102            resting_radius(inputs.size, inputs.shape, inputs.toggle && inputs.selected)
103        };
104        self.radius.sample(radius, MotionToken::FastSpatial, cx);
105        self.shadow.sample(
106            shadow::ShadowPair::level(shadow_level(
107                inputs.variant,
108                inputs.disabled,
109                inputs.toggle,
110                false,
111                inputs.active,
112            )),
113            MotionToken::DefaultEffects,
114            cx,
115        );
116        let opacity = state_opacity(
117            inputs.active,
118            inputs.disabled,
119            inputs.ripple,
120            inputs.active,
121            false,
122            false,
123        );
124        self.opacity_target.set(opacity);
125        self.opacity.sample(opacity, opacity_policy(), cx);
126    }
127}
128
129pub(super) fn opacity_policy() -> gpui_base::Transition {
130    gpui_base::Transition::new(std::time::Duration::from_millis(100))
131        .reduced_motion(gpui_base::ReducedMotion::Continue)
132        .easing(gpui_base::Easing::cubic_bezier(0.2, 0., 0., 1.).expect("valid Material easing"))
133}
134pub(super) fn state_opacity(
135    controlled_active: bool,
136    disabled: bool,
137    ripple: bool,
138    active: bool,
139    focused: bool,
140    hovered: bool,
141) -> f32 {
142    if controlled_active && !ripple {
143        state::PRESS
144    } else if disabled {
145        0.
146    } else if active && !ripple {
147        state::PRESS
148    } else if focused {
149        state::FOCUS
150    } else if hovered {
151        state::HOVER
152    } else {
153        0.
154    }
155}
156pub(super) fn shadow_level(
157    variant: ButtonVariant,
158    disabled: bool,
159    toggle: bool,
160    hovered: bool,
161    active: bool,
162) -> u8 {
163    if disabled {
164        0
165    } else if variant == ButtonVariant::Elevated {
166        if hovered && !active { 2 } else { 1 }
167    } else if !toggle
168        && hovered
169        && !active
170        && matches!(variant, ButtonVariant::Filled | ButtonVariant::Tonal)
171    {
172        1
173    } else {
174        0
175    }
176}
177fn schedule<T>(sample: gpui_base::MotionValue<T>, window: &mut Window) -> T {
178    if matches!(
179        sample.status,
180        gpui_base::MotionStatus::Running | gpui_base::MotionStatus::Delayed
181    ) {
182        window.request_animation_frame();
183    }
184    sample.value
185}
186pub(super) fn token_sample<T: gpui_base::Interpolate + PartialEq + 'static>(
187    playback: Option<&motion::TokenTransitionPlayback<T>>,
188    id: impl Into<ElementId>,
189    target: T,
190    token: MotionToken,
191    window: &mut Window,
192    cx: &mut App,
193) -> T {
194    match playback {
195        Some(playback) => schedule(playback.sample(target, token, cx), window),
196        None => motion::token_transition(id, target, token, window, cx),
197    }
198}
199pub(super) fn opacity_sample(
200    playback: Option<&ButtonTransitionPlayback>,
201    id: impl Into<ElementId>,
202    target: f32,
203    policy: gpui_base::Transition,
204    window: &mut Window,
205    cx: &mut App,
206) -> f32 {
207    let id: ElementId = id.into();
208    match playback {
209        Some(playback) => {
210            playback.opacity_target.set(target);
211            schedule(playback.opacity.sample(target, policy, cx), window)
212        }
213        None => gpui_base::transition(id, target, policy, window, cx),
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{ButtonTransitionInputs, ButtonTransitionPlayback, opacity_policy, shadow};
220    use crate::motion::{self, MotionToken};
221    use gpui::TestAppContext;
222
223    #[gpui::test]
224    fn preference_sync_keeps_last_visual_targets(cx: &mut TestAppContext) {
225        cx.update(|cx| {
226            gpui_base::init(cx);
227            cx.set_reduce_motion(false);
228            motion::set_motion_scheme(motion::MotionScheme::Expressive);
229            let playback = ButtonTransitionPlayback::default();
230            let inputs = ButtonTransitionInputs::default();
231            playback.sync(inputs, cx);
232            // Simulate the last targets produced by live transient presentation,
233            // without putting pointer/focus flags into the persistent handle.
234            let pressed = inputs.size.geometry().pressed;
235            playback
236                .radius
237                .sample(pressed, MotionToken::FastSpatial, cx);
238            playback.shadow.sample(
239                shadow::ShadowPair::level(1),
240                MotionToken::DefaultEffects,
241                cx,
242            );
243            playback.opacity_target.set(crate::tokens::state::HOVER);
244            playback
245                .opacity
246                .sample(crate::tokens::state::HOVER, opacity_policy(), cx);
247            cx.set_reduce_motion(true);
248            motion::set_motion_scheme(motion::MotionScheme::Standard);
249            playback.sync(inputs, cx);
250            assert_eq!(
251                playback
252                    .radius
253                    .resample(MotionToken::FastSpatial, cx)
254                    .unwrap()
255                    .value,
256                pressed
257            );
258            assert_eq!(playback.opacity_target.get(), crate::tokens::state::HOVER);
259            // Same target remains a continuation, not a rest-target reversal.
260            assert_eq!(
261                playback
262                    .radius
263                    .resample(MotionToken::FastSpatial, cx)
264                    .unwrap()
265                    .status,
266                gpui_base::MotionStatus::Finished
267            );
268        });
269    }
270}