Skip to main content

embedded_gui/
cinematic.rs

1//! High-level cinematic UI building blocks inspired by smartwatch UX patterns.
2//! These helpers stay no_std friendly and compose with existing widgets/animators.
3
4use 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}