1use crate::{
5 animation::Easing,
6 context::{GuiContext, GuiError},
7 widget::WidgetId,
8 widget_animation::{AnimationConflictPolicy, WidgetAnimationError, WidgetAnimator},
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct PeekRevealSpec {
13 pub dot_px: u32,
14 pub icon_expand_px: u32,
15 pub icon_duration_ms: u32,
16 pub text_stagger_ms: u32,
17 pub text_duration_ms: u32,
18}
19
20impl Default for PeekRevealSpec {
21 fn default() -> Self {
22 Self {
23 dot_px: 3,
24 icon_expand_px: 24,
25 icon_duration_ms: 300,
26 text_stagger_ms: 90,
27 text_duration_ms: 160,
28 }
29 }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct GlanceTileSpec {
34 pub focus_bump_px: i32,
35 pub focus_slide_px: i32,
36 pub focus_duration_ms: u32,
37 pub dim_opacity: u8,
38}
39
40impl Default for GlanceTileSpec {
41 fn default() -> Self {
42 Self {
43 focus_bump_px: 3,
44 focus_slide_px: 6,
45 focus_duration_ms: 120,
46 dim_opacity: 170,
47 }
48 }
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct MotionTokens {
53 pub peek_dot_px: u32,
54 pub peek_icon_expand_px: u32,
55 pub peek_icon_duration_ms: u32,
56 pub peek_text_stagger_ms: u32,
57 pub peek_text_duration_ms: u32,
58 pub glance_focus_bump_px: i32,
59 pub glance_focus_slide_px: i32,
60 pub glance_focus_duration_ms: u32,
61 pub glance_dim_opacity: u8,
62}
63
64impl Default for MotionTokens {
65 fn default() -> Self {
66 Self {
67 peek_dot_px: 3,
68 peek_icon_expand_px: 24,
69 peek_icon_duration_ms: 300,
70 peek_text_stagger_ms: 90,
71 peek_text_duration_ms: 160,
72 glance_focus_bump_px: 3,
73 glance_focus_slide_px: 6,
74 glance_focus_duration_ms: 120,
75 glance_dim_opacity: 170,
76 }
77 }
78}
79
80impl MotionTokens {
81 pub const fn to_peek_spec(self) -> PeekRevealSpec {
82 PeekRevealSpec {
83 dot_px: self.peek_dot_px,
84 icon_expand_px: self.peek_icon_expand_px,
85 icon_duration_ms: self.peek_icon_duration_ms,
86 text_stagger_ms: self.peek_text_stagger_ms,
87 text_duration_ms: self.peek_text_duration_ms,
88 }
89 }
90
91 pub const fn to_glance_spec(self) -> GlanceTileSpec {
92 GlanceTileSpec {
93 focus_bump_px: self.glance_focus_bump_px,
94 focus_slide_px: self.glance_focus_slide_px,
95 focus_duration_ms: self.glance_focus_duration_ms,
96 dim_opacity: self.glance_dim_opacity,
97 }
98 }
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum CardDeckDirection {
103 Forward,
104 Backward,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct CardDeckState {
109 current: usize,
110 len: usize,
111}
112
113impl CardDeckState {
114 pub const fn new(len: usize) -> Self {
115 Self { current: 0, len }
116 }
117
118 pub const fn current(&self) -> usize {
119 self.current
120 }
121
122 pub const fn len(&self) -> usize {
123 self.len
124 }
125
126 pub const fn is_empty(&self) -> bool {
127 self.len == 0
128 }
129
130 pub fn set_len(&mut self, len: usize) {
131 self.len = len;
132 if self.current >= self.len {
133 self.current = self.len.saturating_sub(1);
134 }
135 }
136
137 pub fn move_next(&mut self) -> Option<CardDeckDirection> {
138 if self.current + 1 < self.len {
139 self.current += 1;
140 Some(CardDeckDirection::Forward)
141 } else {
142 None
143 }
144 }
145
146 pub fn move_prev(&mut self) -> Option<CardDeckDirection> {
147 if self.current > 0 {
148 self.current -= 1;
149 Some(CardDeckDirection::Backward)
150 } else {
151 None
152 }
153 }
154}
155
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub struct CardStory<'a> {
158 cards: &'a [WidgetId],
159 state: CardDeckState,
160 transition: TimelineMotionPreset,
161 slide_px: i32,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub struct CardStoryTransition {
166 pub from: WidgetId,
167 pub to: WidgetId,
168 pub direction: CardDeckDirection,
169 pub preset: TimelineMotionPreset,
170 pub slide_px: i32,
171}
172
173impl<'a> CardStory<'a> {
174 pub fn new(cards: &'a [WidgetId], transition: TimelineMotionPreset) -> Self {
175 Self {
176 cards,
177 state: CardDeckState::new(cards.len()),
178 transition,
179 slide_px: 14,
180 }
181 }
182
183 pub fn with_slide_px(mut self, slide_px: i32) -> Self {
184 self.slide_px = slide_px.max(1);
185 self
186 }
187
188 pub const fn state(&self) -> &CardDeckState {
189 &self.state
190 }
191
192 pub fn current_widget(&self) -> Option<WidgetId> {
193 self.cards.get(self.state.current()).copied()
194 }
195
196 pub fn apply<'g, const NODES: usize, const EVENTS: usize, const DIRTY: usize>(
197 &self,
198 gui: &mut GuiContext<'g, NODES, EVENTS, DIRTY>,
199 ) -> Result<(), GuiError> {
200 apply_carddeck_visibility(gui, self.cards, self.state.current())
201 }
202
203 #[allow(clippy::should_implement_trait)]
204 pub fn next(&mut self) -> Option<CardStoryTransition> {
205 let from_idx = self.state.current();
206 self.state.move_next()?;
207 let to_idx = self.state.current();
208 Some(CardStoryTransition {
209 from: self.cards[from_idx],
210 to: self.cards[to_idx],
211 direction: CardDeckDirection::Forward,
212 preset: self.transition,
213 slide_px: self.slide_px,
214 })
215 }
216
217 pub fn prev(&mut self) -> Option<CardStoryTransition> {
218 let from_idx = self.state.current();
219 self.state.move_prev()?;
220 let to_idx = self.state.current();
221 Some(CardStoryTransition {
222 from: self.cards[from_idx],
223 to: self.cards[to_idx],
224 direction: CardDeckDirection::Backward,
225 preset: self.transition,
226 slide_px: self.slide_px,
227 })
228 }
229
230 pub fn jump_to(&mut self, index: usize) -> Option<CardStoryTransition> {
231 if self.cards.is_empty() {
232 return None;
233 }
234 let clamped = index.min(self.cards.len() - 1);
235 let from_idx = self.state.current();
236 if clamped == from_idx {
237 return None;
238 }
239 let direction = if clamped > from_idx {
240 CardDeckDirection::Forward
241 } else {
242 CardDeckDirection::Backward
243 };
244 self.state.current = clamped;
245 Some(CardStoryTransition {
246 from: self.cards[from_idx],
247 to: self.cards[clamped],
248 direction,
249 preset: self.transition,
250 slide_px: self.slide_px,
251 })
252 }
253}
254
255impl CardStoryTransition {
256 pub fn animate<const TRACKS: usize, const BINDINGS: usize>(
257 self,
258 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
259 base_x: i32,
260 ) -> Result<(), WidgetAnimationError> {
261 let duration = self.preset.duration_ms();
262 let easing = self.preset.easing();
263 let delta = match self.direction {
264 CardDeckDirection::Forward => self.slide_px,
265 CardDeckDirection::Backward => -self.slide_px,
266 };
267 animator.animate_widget_x(self.from, base_x, base_x - delta, duration, easing)?;
268 animator.animate_opacity(self.from, 255, 90, duration, Easing::OutSine)?;
269 animator.animate_widget_x(self.to, base_x + delta, base_x, duration, easing)?;
270 animator.animate_opacity(self.to, 90, 255, duration, Easing::OutSine)?;
271 Ok(())
272 }
273}
274
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum TimelineMotionPreset {
277 PeekIn,
278 PeekOut,
279 PinExpand,
280 ScrubSettle,
281}
282
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284pub enum CinematicPreset {
285 PeekTimeline,
286 LauncherGlance,
287 CardStory,
288}
289
290impl CinematicPreset {
291 pub const fn name(self) -> &'static str {
292 match self {
293 Self::PeekTimeline => "peek-timeline",
294 Self::LauncherGlance => "launcher-glance",
295 Self::CardStory => "card-story",
296 }
297 }
298}
299
300impl TimelineMotionPreset {
301 pub const fn duration_ms(self) -> u32 {
302 match self {
303 Self::PeekIn | Self::PeekOut => 220,
304 Self::PinExpand => 260,
305 Self::ScrubSettle => 140,
306 }
307 }
308
309 pub const fn easing(self) -> Easing {
310 match self {
311 Self::PeekIn => Easing::OutBack,
312 Self::PeekOut => Easing::InSine,
313 Self::PinExpand => Easing::OutCubic,
314 Self::ScrubSettle => Easing::OutBounce,
315 }
316 }
317}
318
319pub fn animate_peek_reveal<const TRACKS: usize, const BINDINGS: usize>(
320 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
321 icon_widget: WidgetId,
322 title_widget: Option<WidgetId>,
323 subtitle_widget: Option<WidgetId>,
324 base_x: i32,
325 base_y: i32,
326 spec: PeekRevealSpec,
327) -> Result<(), WidgetAnimationError> {
328 let dot = spec.dot_px.max(1);
329 animator.animate_widget_width(
330 icon_widget,
331 dot,
332 spec.icon_expand_px.max(dot),
333 spec.icon_duration_ms,
334 Easing::OutBack,
335 )?;
336 animator.animate_widget_height(
337 icon_widget,
338 dot,
339 spec.icon_expand_px.max(dot),
340 spec.icon_duration_ms,
341 Easing::OutBack,
342 )?;
343 animator.animate_opacity(
344 icon_widget,
345 180,
346 255,
347 spec.icon_duration_ms,
348 Easing::OutSine,
349 )?;
350
351 if let Some(title) = title_widget {
352 let title_anim = crate::animation::Animation::new(
353 (base_y + 4) as f32,
354 base_y as f32,
355 spec.text_duration_ms,
356 Easing::OutCubic,
357 )
358 .with_delay(spec.text_stagger_ms);
359 animator.bind_property_with_policy(
360 title,
361 crate::widget_animation::AnimatedProperty::WidgetY,
362 title_anim,
363 AnimationConflictPolicy::Replace,
364 )?;
365 animator.animate_opacity(
366 title,
367 0,
368 255,
369 spec.text_duration_ms + spec.text_stagger_ms,
370 Easing::OutSine,
371 )?;
372 }
373
374 if let Some(subtitle) = subtitle_widget {
375 let subtitle_anim = crate::animation::Animation::new(
376 (base_x - 6) as f32,
377 base_x as f32,
378 spec.text_duration_ms,
379 Easing::OutSine,
380 )
381 .with_delay(spec.text_stagger_ms.saturating_mul(2));
382 animator.bind_property_with_policy(
383 subtitle,
384 crate::widget_animation::AnimatedProperty::WidgetX,
385 subtitle_anim,
386 AnimationConflictPolicy::Replace,
387 )?;
388 animator.animate_opacity(
389 subtitle,
390 0,
391 255,
392 spec.text_duration_ms + spec.text_stagger_ms.saturating_mul(2),
393 Easing::OutSine,
394 )?;
395 }
396
397 Ok(())
398}
399
400pub fn animate_glance_focus<const TRACKS: usize, const BINDINGS: usize>(
401 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
402 focused: WidgetId,
403 neighbors: &[WidgetId],
404 base_x: i32,
405 base_y: i32,
406 spec: GlanceTileSpec,
407) -> Result<(), WidgetAnimationError> {
408 animator.preset_selection_bump_settle(
409 focused,
410 base_y,
411 spec.focus_bump_px,
412 spec.focus_duration_ms,
413 )?;
414 animator.animate_widget_x(
415 focused,
416 base_x,
417 base_x.saturating_add(spec.focus_slide_px),
418 spec.focus_duration_ms,
419 Easing::OutSine,
420 )?;
421 animator.animate_opacity(focused, 200, 255, spec.focus_duration_ms, Easing::OutSine)?;
422
423 for neighbor in neighbors.iter().copied() {
424 animator.animate_widget_x(
425 neighbor,
426 base_x,
427 base_x.saturating_sub((spec.focus_slide_px / 2).max(1)),
428 spec.focus_duration_ms,
429 Easing::OutSine,
430 )?;
431 animator.animate_opacity(
432 neighbor,
433 255,
434 spec.dim_opacity,
435 spec.focus_duration_ms,
436 Easing::OutSine,
437 )?;
438 }
439 Ok(())
440}
441
442pub fn apply_carddeck_visibility<
443 'a,
444 const NODES: usize,
445 const EVENTS: usize,
446 const DIRTY: usize,
447>(
448 gui: &mut GuiContext<'a, NODES, EVENTS, DIRTY>,
449 cards: &[WidgetId],
450 active: usize,
451) -> Result<(), GuiError> {
452 for (idx, id) in cards.iter().copied().enumerate() {
453 gui.set_hidden(id, idx != active)?;
454 }
455 Ok(())
456}
457
458pub fn setup_peek_timeline<const TRACKS: usize, const BINDINGS: usize>(
459 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
460 peek_widget: WidgetId,
461 title_widget: Option<WidgetId>,
462 subtitle_widget: Option<WidgetId>,
463 base_x: i32,
464 base_y: i32,
465) -> Result<(), WidgetAnimationError> {
466 setup_peek_timeline_with_tokens(
467 animator,
468 peek_widget,
469 title_widget,
470 subtitle_widget,
471 base_x,
472 base_y,
473 MotionTokens::default(),
474 )
475}
476
477pub fn setup_peek_timeline_with_tokens<const TRACKS: usize, const BINDINGS: usize>(
478 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
479 peek_widget: WidgetId,
480 title_widget: Option<WidgetId>,
481 subtitle_widget: Option<WidgetId>,
482 base_x: i32,
483 base_y: i32,
484 tokens: MotionTokens,
485) -> Result<(), WidgetAnimationError> {
486 animate_peek_reveal(
487 animator,
488 peek_widget,
489 title_widget,
490 subtitle_widget,
491 base_x,
492 base_y,
493 tokens.to_peek_spec(),
494 )
495}
496
497pub fn setup_launcher_glance<const TRACKS: usize, const BINDINGS: usize>(
498 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
499 focused: WidgetId,
500 neighbors: &[WidgetId],
501 base_x: i32,
502 base_y: i32,
503) -> Result<(), WidgetAnimationError> {
504 setup_launcher_glance_with_tokens(
505 animator,
506 focused,
507 neighbors,
508 base_x,
509 base_y,
510 MotionTokens::default(),
511 )
512}
513
514pub fn setup_launcher_glance_with_tokens<const TRACKS: usize, const BINDINGS: usize>(
515 animator: &mut WidgetAnimator<TRACKS, BINDINGS>,
516 focused: WidgetId,
517 neighbors: &[WidgetId],
518 base_x: i32,
519 base_y: i32,
520 tokens: MotionTokens,
521) -> Result<(), WidgetAnimationError> {
522 animate_glance_focus(
523 animator,
524 focused,
525 neighbors,
526 base_x,
527 base_y,
528 tokens.to_glance_spec(),
529 )
530}
531
532pub fn setup_card_story<'a, const NODES: usize, const EVENTS: usize, const DIRTY: usize>(
533 gui: &mut GuiContext<'a, NODES, EVENTS, DIRTY>,
534 cards: &[WidgetId],
535 state: &CardDeckState,
536) -> Result<(), GuiError> {
537 if state.is_empty() {
538 return Ok(());
539 }
540 apply_carddeck_visibility(gui, cards, state.current())
541}