gpui_kit_material/components/button/
playback.rs1use super::*;
3
4#[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#[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 pub fn sync(&self, inputs: ButtonTransitionInputs, cx: &App) {
72 let observed = Self::observed(inputs, cx);
73 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 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 assert_eq!(
261 playback
262 .radius
263 .resample(MotionToken::FastSpatial, cx)
264 .unwrap()
265 .status,
266 gpui_base::MotionStatus::Finished
267 );
268 });
269 }
270}