diff --git a/README.md b/README.md index c235872..d12c7b5 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,10 @@ The current widget set includes: ### Animation and transitions - Core animation manager and easing/tween/path/spring/inertia primitives +- Spatial timing (`moook_curve`, `TransitionPreset`) — see `docs/transition-presets.md` - Widget property animation (`WidgetAnimator`) and preset helpers (`presets`) - Timeline/keyframe sequencing support (`AnimationSequence`, `SequencePlayer`) -- Screen stack + transition primitives for app-level flows +- Screen stack + transition primitives for app-level flows (fade, slide, wipe, shutter, port-hole, round-flip, modal) ## Animation Quickstart diff --git a/docs/transition-presets.md b/docs/transition-presets.md new file mode 100644 index 0000000..245e4b1 --- /dev/null +++ b/docs/transition-presets.md @@ -0,0 +1,37 @@ +# Screen transition presets + +`embedded-gui` provides named transition presets and timing helpers for common shell navigation patterns on fixed-frame embedded displays. + +## Quick use + +```rust +use embedded_gui::prelude::*; + +let spec = TransitionPreset::WindowPush.spec(); +// or: ScreenTransitionSpec::push_moook(MOOOK_DURATION_MS) + +runner.apply(&mut stack, ScreenCommand::Push(id), spec, &mut events)?; +``` + +## Catalog + +| `TransitionPreset` | `ScreenTransitionEffect` | +|---|---| +| `WindowPush` | `PushMoook` | +| `WindowPop` | `PopMoook` | +| `WindowPushRound` | `PortHoleLeft` | +| `WindowPopRound` | `PortHoleRight` | +| `Shutter*` | `Shutter*` | +| `RoundFlip*` | `RoundFlip*` | +| `PortHole*` | `PortHole*` | +| `ModalPresent` / `ModalDismiss` | `ModalSlideUp` / `ModalSlideDown` | +| `TimelineSlide` | `SlideLeft` | +| `Fade` | `Fade` | + +## Timing + +- `MOOOK_DURATION_MS` — window push/pop spatial curve (7 frames @ 30 Hz) +- `SHUTTER_DURATION_MS` / `PORT_HOLE_DURATION_MS` — 6 frames (198 ms) +- `Easing::Moook` — `moook_curve` for property animations + +Vector asset–driven modal/dot/launcher sequences are not supported; rectangular clip and slide presets cover typical stack navigation. diff --git a/examples/timeline_transition_showcase.rs b/examples/timeline_transition_showcase.rs index e3f01b9..9e5c859 100644 --- a/examples/timeline_transition_showcase.rs +++ b/examples/timeline_transition_showcase.rs @@ -41,8 +41,10 @@ fn main() { let mut lifecycle = heapless::Vec::::new(); let mut effect_idx = 0usize; let effects = [ - ScreenTransitionSpec::slide_left(420), + TransitionPreset::WindowPush.spec(), + TransitionPreset::ShutterLeft.spec(), ScreenTransitionSpec::fade(420), + TransitionPreset::PortHoleLeft.spec(), ScreenTransitionSpec::circular_reveal(420), ]; diff --git a/src/animation.rs b/src/animation.rs index d359d8d..8d74f2e 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -37,6 +37,8 @@ pub enum Easing { InElastic, OutElastic, InOutElastic, + /// Spatial curve for stack push/pop (`interpolate_moook`). + Moook, } #[inline] @@ -198,6 +200,7 @@ pub fn apply_easing(t: f32, easing: Easing) -> f32 { } } } + Easing::Moook => crate::animation_timing::moook_curve(t), } } diff --git a/src/animation_timing.rs b/src/animation_timing.rs new file mode 100644 index 0000000..a7b4552 --- /dev/null +++ b/src/animation_timing.rs @@ -0,0 +1,218 @@ +//! Animation timing helpers for embedded transitions. +//! +//! Provides interval remapping, table-based cubic easing samples, and the +//! moook spatial interpolation curve used for stack push/pop motion. + +/// Normalized animation progress maximum (16-bit). +pub const NORMALIZED_MAX: i32 = 65_535; + +/// Target frame interval at 30 Hz. +pub const FRAME_INTERVAL_MS: u32 = 33; + +/// Default single-animation duration. +pub const DEFAULT_DURATION_MS: u32 = 250; + +/// `PORT_HOLE_TRANSITION_DURATION_MS` / `ROUND_FLIP_ANIMATION_DURATION_MS`. +pub const PORT_HOLE_DURATION_MS: u32 = 6 * FRAME_INTERVAL_MS; + +/// `SHUTTER_TRANSITION_DURATION_MS` (2 + 4 frames). +pub const SHUTTER_DURATION_MS: u32 = 6 * FRAME_INTERVAL_MS; + +/// `interpolate_moook_duration()` (3 in + 4 out frames). +pub const MOOOK_DURATION_MS: u32 = + (MOOOK_IN.len() as u32 + MOOOK_OUT.len() as u32) * FRAME_INTERVAL_MS; + +const MOOOK_IN: [i32; 3] = [0, 1, 20]; +const MOOOK_OUT: [i32; 4] = [4, 2, 1, 0]; + +/// Remap normalized progress into `[interval_start, interval_end]`. +#[inline] +pub fn timing_scaled(time_normalized: i32, interval_start: i32, interval_end: i32) -> i32 { + if interval_end == interval_start { + return NORMALIZED_MAX; + } + let result = time_normalized - interval_start; + (result * NORMALIZED_MAX) / (interval_end - interval_start) +} + +/// Clip normalized progress to `[0, NORMALIZED_MAX]`. +#[inline] +pub fn timing_clip(progress: i32) -> i32 { + progress.clamp(0, NORMALIZED_MAX) +} + +/// Two-phase helper: first half / second half of a transition (port-hole, shutter, round window). +#[inline] +pub fn timing_half_phase(progress: f32) -> (f32, bool) { + if progress < 0.5 { + (progress * 2.0, true) + } else { + ((progress - 0.5) * 2.0, false) + } +} + +/// Shutter timing: first 2/6 then 4/6 of total duration. +#[inline] +pub fn timing_shutter_phase(progress: f32) -> (f32, bool) { + const FIRST: f32 = 2.0 / 6.0; + if progress < FIRST { + (progress / FIRST, true) + } else { + ((progress - FIRST) / (1.0 - FIRST), false) + } +} + +#[inline] +pub fn moook_in_duration_ms() -> u32 { + MOOOK_IN.len() as u32 * FRAME_INTERVAL_MS +} + +#[inline] +pub fn moook_out_duration_ms() -> u32 { + MOOOK_OUT.len() as u32 * FRAME_INTERVAL_MS +} + +#[inline] +pub fn moook_duration_ms() -> u32 { + moook_in_duration_ms() + moook_out_duration_ms() +} + +#[inline] +pub fn moook_soft_duration_ms(mid_frames: i32) -> u32 { + moook_duration_ms() + mid_frames.max(0) as u32 * FRAME_INTERVAL_MS +} + +fn interpolate_linear(normalized: i32, from: i64, to: i64) -> i64 { + from + (normalized as i64 * (to - from)) / NORMALIZED_MAX as i64 +} + +fn interpolate_moook_frames( + normalized: i32, + from: i64, + to: i64, + frames_in: &[i32], + frames_out: &[i32], + mid_frames: i32, + bounce_back: bool, +) -> i64 { + let direction = if from == to { + 0 + } else if from < to { + 1 + } else { + -1 + }; + if direction == 0 { + return from; + } + let direction_out = if bounce_back { direction } else { -direction }; + let num_in = frames_in.len() as i32; + let num_out = frames_out.len() as i32; + let num_total = num_in + mid_frames + num_out; + if num_total <= 0 { + return if normalized >= NORMALIZED_MAX { + to + } else { + from + }; + } + + let mut frame_idx = ((normalized as i64 * num_total as i64 + + (NORMALIZED_MAX as i64 / (2 * num_total as i64))) + / NORMALIZED_MAX as i64) as i32; + frame_idx = frame_idx.clamp(0, num_total - 1); + + if normalized >= NORMALIZED_MAX { + return to; + } + if frame_idx < 0 { + return from; + } + if frame_idx < num_in { + return from + direction as i64 * frames_in[frame_idx as usize] as i64; + } + if frame_idx < num_in + mid_frames && mid_frames > 0 { + let shifted = + normalized - ((num_in as i64 * NORMALIZED_MAX as i64) / num_total as i64) as i32; + let mid_normalized = ((num_total as i64 * shifted as i64) / mid_frames as i64) as i32; + let from_mid = from + direction as i64 * frames_in[(num_in - 1) as usize] as i64; + let to_mid = to + direction_out as i64 * frames_out[0] as i64; + return interpolate_linear(mid_normalized, from_mid, to_mid); + } + let out_idx = frame_idx - num_in - mid_frames; + to + direction_out as i64 * frames_out[out_idx as usize] as i64 +} + +/// Full moook spatial interpolation (`interpolate_moook`). +pub fn interpolate_moook(normalized: i32, from: i64, to: i64) -> i64 { + interpolate_moook_frames(normalized, from, to, &MOOOK_IN, &MOOOK_OUT, 0, true) +} + +/// Moook with linear middle segment (`interpolate_moook_soft`). +pub fn interpolate_moook_soft(normalized: i32, from: i64, to: i64, mid_frames: i32) -> i64 { + interpolate_moook_frames( + normalized, from, to, &MOOOK_IN, &MOOOK_OUT, mid_frames, true, + ) +} + +/// Map linear progress `t` in `[0, 1]` through moook spatial easing to `[0, 1]` (may overshoot). +pub fn moook_curve(t: f32) -> f32 { + let normalized = (t.clamp(0.0, 1.0) * NORMALIZED_MAX as f32).round() as i32; + let v = interpolate_moook(normalized, 0, NORMALIZED_MAX as i64); + v as f32 / NORMALIZED_MAX as f32 +} + +/// Table-based cubic ease-in sample (32-entry lookup). +pub fn table_ease_in_sample(t: f32) -> f32 { + const TABLE: [u16; 33] = [ + 0, 64, 256, 576, 1024, 1600, 2304, 3136, 4096, 5184, 6400, 7744, 9216, 10816, 12544, 14400, + 16384, 18496, 20736, 23104, 25600, 28224, 30976, 33856, 36864, 40000, 43264, 46656, 50176, + 53824, 57600, 61504, 65535, + ]; + ease_table_sample(t, &TABLE) +} + +fn ease_table_sample(t: f32, table: &[u16]) -> f32 { + if table.is_empty() { + return t; + } + let progress = (t.clamp(0.0, 1.0) * NORMALIZED_MAX as f32).round() as i32; + if progress <= 0 { + return 0.0; + } + if progress >= NORMALIZED_MAX { + return 1.0; + } + let max_entry = table.len() - 1; + let stride = NORMALIZED_MAX / max_entry as i32; + let index = (progress * max_entry as i32) / NORMALIZED_MAX; + let from = table[index as usize] as i64; + let delta = table[(index + 1) as usize] as i64 - from; + let v = from + (delta * (progress - index * stride) as i64) / stride as i64; + v as f32 / NORMALIZED_MAX as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn moook_reaches_endpoints() { + assert_eq!(interpolate_moook(0, 0, 100), 0); + assert_eq!(interpolate_moook(NORMALIZED_MAX, 0, 100), 100); + } + + #[test] + fn timing_scaled_maps_interval() { + let mid = timing_scaled(NORMALIZED_MAX / 2, 0, NORMALIZED_MAX); + assert!((mid - NORMALIZED_MAX / 2).abs() <= 1); + } + + #[test] + fn moook_curve_is_monotonic_overall() { + let a = moook_curve(0.0); + let b = moook_curve(1.0); + assert!((a - 0.0).abs() < 0.01); + assert!((b - 1.0).abs() < 0.01); + } +} diff --git a/src/lib.rs b/src/lib.rs index b22d59f..5914438 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ extern crate std; pub mod animation; pub mod animation_timeline; +pub mod animation_timing; pub mod block; pub mod context; pub mod font; @@ -22,6 +23,7 @@ pub mod style; #[cfg(feature = "std")] pub mod test_buffer; pub mod text; +pub mod transition_preset; pub mod widget; pub mod widget_animation; pub mod widgets; @@ -37,6 +39,11 @@ pub use animation_timeline::{ Keyframe, KeyframeTrack, KeyframeTrackCallbacks, SequencePlayer, SequencePlayerStatus, SequenceRepeatMode, TimelineError, TimelineStep, }; +pub use animation_timing::{ + DEFAULT_DURATION_MS, FRAME_INTERVAL_MS, MOOOK_DURATION_MS, NORMALIZED_MAX, + PORT_HOLE_DURATION_MS, SHUTTER_DURATION_MS, interpolate_moook, moook_curve, moook_duration_ms, + timing_half_phase, timing_scaled, timing_shutter_phase, +}; pub use block::Block; pub use context::{ GuiContext, GuiError, KeyBindingAction, PressTiming, WidgetKeyBindings, WidgetKeyInputPolicy, @@ -78,6 +85,7 @@ pub use test_buffer::{LayerCanvas, TestBuffer}; pub use text::{ BasicTextShaper, Line, ShapedGlyph, ShapingConfig, Span, Text, TextDirection, TextShaper, }; +pub use transition_preset::TransitionPreset; pub use widget::{ EventContext, EventPhase, EventPolicy, FocusGroupId, StatefulWidget, StyleClassId, WidgetFlags, WidgetId, @@ -111,8 +119,8 @@ pub mod prelude { SpriteSheet, StateStyle, StatefulWidget, StrokeCap, StrokeJoin, StrokeStyle, Style, StyleClassId, StyleTransition, TabsState, Text, TextAlign, TextDirection, TextMetrics, TextOverflow, TextOverflowPolicy, TextShaper, TextStyle, TextWrap, Theme, TimelineError, - TimelineStep, Timer, Transform2D, Tween, UiEvent, UiEventFilter, VerticalAlign, - VisualState, WidgetAnimationCallbacks, WidgetAnimationError, WidgetAnimator, + TimelineStep, Timer, Transform2D, TransitionPreset, Tween, UiEvent, UiEventFilter, + VerticalAlign, VisualState, WidgetAnimationCallbacks, WidgetAnimationError, WidgetAnimator, WidgetDispatchPolicy, WidgetEvent, WidgetEventFilter, WidgetEventKind, WidgetFlags, WidgetId, WidgetKeyBindings, WidgetKeyInputPolicy, WidgetKeyframeState, WidgetKind, WidgetPropertyKeyframe, WidgetStyle, apply_easing, lerp_style, presets, diff --git a/src/screen_transition.rs b/src/screen_transition.rs index 598b1ea..c3f317b 100644 --- a/src/screen_transition.rs +++ b/src/screen_transition.rs @@ -2,12 +2,14 @@ use heapless::Vec; use crate::{ animation::{Animation, Easing}, + animation_timing::{self, timing_half_phase, timing_shutter_phase}, context::GuiContext, geometry::Rect, math::F32Ext as _, screen::{ScreenCommand, ScreenId, ScreenLifecycleEvent, ScreenStack, ScreenStackError}, }; +/// Screen transition visual effect. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ScreenTransitionEffect { #[default] @@ -15,12 +17,34 @@ pub enum ScreenTransitionEffect { Fade, SlideLeft, SlideRight, + SlideUp, + SlideDown, + /// Rectangular push: incoming from the right with moook easing. + PushMoook, + /// Rectangular pop: incoming from the left with moook easing. + PopMoook, Zoom, CircularReveal, WipeLeft, WipeRight, WipeUp, WipeDown, + /// Two-phase directional shutter wipe. + ShutterLeft, + ShutterRight, + ShutterUp, + ShutterDown, + /// Round-display card flip (vertical clip). + RoundFlipLeft, + RoundFlipRight, + /// Two-phase slide with a mid-transition seam. + PortHoleLeft, + PortHoleRight, + PortHoleUp, + PortHoleDown, + /// Modal overlay slide from top or bottom. + ModalSlideUp, + ModalSlideDown, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -82,6 +106,150 @@ impl ScreenTransitionSpec { } } + pub const fn slide_up(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::SlideUp, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::InOutSine, + } + } + + pub const fn slide_down(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::SlideDown, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::InOutSine, + } + } + + pub const fn push_moook(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PushMoook, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::Moook, + } + } + + pub const fn pop_moook(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PopMoook, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::Moook, + } + } + + pub const fn shutter_left(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ShutterLeft, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn shutter_right(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ShutterRight, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn shutter_up(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ShutterUp, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn shutter_down(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ShutterDown, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn round_flip_left(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::RoundFlipLeft, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::Linear, + } + } + + pub const fn round_flip_right(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::RoundFlipRight, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::Linear, + } + } + + pub const fn port_hole_left(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PortHoleLeft, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn port_hole_right(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PortHoleRight, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn port_hole_up(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PortHoleUp, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn port_hole_down(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::PortHoleDown, + duration_ms, + origin: ScreenTransitionOrigin::Center, + easing: Easing::EaseInOut, + } + } + + pub const fn modal_slide_up(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ModalSlideUp, + duration_ms, + origin: ScreenTransitionOrigin::Bottom, + easing: Easing::EaseOut, + } + } + + pub const fn modal_slide_down(duration_ms: u32) -> Self { + Self { + effect: ScreenTransitionEffect::ModalSlideDown, + duration_ms, + origin: ScreenTransitionOrigin::Top, + easing: Easing::EaseOut, + } + } + pub const fn zoom(duration_ms: u32) -> Self { Self { effect: ScreenTransitionEffect::Zoom, @@ -162,10 +330,34 @@ impl ActiveScreenTransition { } pub fn slide_offset_x(&self, width: u32) -> i32 { - let px = (width as f32 * self.progress.clamp(0.0, 1.0)).round() as i32; + let t = eased_progress(self.progress, self.effect); + let px = (width as f32 * t).round() as i32; match self.effect { - ScreenTransitionEffect::SlideLeft => -px, - ScreenTransitionEffect::SlideRight => px, + ScreenTransitionEffect::SlideLeft + | ScreenTransitionEffect::ShutterLeft + | ScreenTransitionEffect::PortHoleLeft => -px, + ScreenTransitionEffect::SlideRight + | ScreenTransitionEffect::PushMoook + | ScreenTransitionEffect::ShutterRight + | ScreenTransitionEffect::PortHoleRight + | ScreenTransitionEffect::RoundFlipRight => px, + ScreenTransitionEffect::PopMoook => px, + _ => 0, + } + } + + pub fn slide_offset_y(&self, height: u32) -> i32 { + let t = eased_progress(self.progress, self.effect); + let px = (height as f32 * t).round() as i32; + match self.effect { + ScreenTransitionEffect::SlideUp + | ScreenTransitionEffect::ShutterUp + | ScreenTransitionEffect::PortHoleUp + | ScreenTransitionEffect::ModalSlideUp => -px, + ScreenTransitionEffect::SlideDown + | ScreenTransitionEffect::ShutterDown + | ScreenTransitionEffect::PortHoleDown + | ScreenTransitionEffect::ModalSlideDown => px, _ => 0, } } @@ -174,13 +366,70 @@ impl ActiveScreenTransition { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ScreenTransitionSample { pub outgoing_offset_x: i32, + pub outgoing_offset_y: i32, pub incoming_offset_x: i32, + pub incoming_offset_y: i32, pub outgoing_opacity: u8, pub incoming_opacity: u8, pub outgoing_clip: Option, pub incoming_clip: Option, } +fn eased_progress(progress: f32, effect: ScreenTransitionEffect) -> f32 { + match effect { + ScreenTransitionEffect::PushMoook | ScreenTransitionEffect::PopMoook => { + animation_timing::moook_curve(progress) + } + _ => progress.clamp(0.0, 1.0), + } +} + +fn shutter_offset(progress: f32, viewport: u32, horizontal: bool, negative: bool) -> (i32, i32) { + let (phase_t, first_half) = timing_shutter_phase(progress); + let span = viewport as i32; + let sign = if negative { -1 } else { 1 }; + if horizontal { + if first_half { + (sign * -((span as f32 * phase_t).round() as i32), 0) + } else { + ( + sign * span, + sign * (span - (span as f32 * phase_t).round() as i32), + ) + } + } else if first_half { + (0, sign * -((span as f32 * phase_t).round() as i32)) + } else { + (0, sign * (span - (span as f32 * phase_t).round() as i32)) + } +} + +fn port_hole_offsets( + progress: f32, + viewport_w: u32, + viewport_h: u32, + horizontal: bool, + negative: bool, +) -> (i32, i32, i32, i32) { + let viewport = if horizontal { viewport_w } else { viewport_h }; + let (out, inc) = { + let (phase_t, first_half) = timing_half_phase(progress); + let gap = (viewport as f32 * 80.0 / 180.0).round() as i32; + let full = viewport as i32; + let sign = if negative { -1 } else { 1 }; + if first_half { + (sign * (full - (gap as f32 * phase_t) as i32), sign * full) + } else { + (sign * gap, sign * ((gap as f32 * (1.0 - phase_t)) as i32)) + } + }; + if horizontal { + (out, 0, inc, 0) + } else { + (0, out, 0, inc) + } +} + impl ActiveScreenTransition { pub fn sample(&self, viewport_w: u32, viewport_h: u32) -> ScreenTransitionSample { match self.effect { @@ -188,38 +437,193 @@ impl ActiveScreenTransition { let incoming = self.opacity_u8(); ScreenTransitionSample { outgoing_offset_x: 0, + outgoing_offset_y: 0, incoming_offset_x: 0, + incoming_offset_y: 0, outgoing_opacity: 255u8.saturating_sub(incoming), incoming_opacity: incoming, outgoing_clip: None, incoming_clip: None, } } - ScreenTransitionEffect::SlideLeft => { + ScreenTransitionEffect::SlideLeft | ScreenTransitionEffect::PushMoook => { let out = self.slide_offset_x(viewport_w); ScreenTransitionSample { outgoing_offset_x: out, + outgoing_offset_y: 0, incoming_offset_x: out + viewport_w as i32, + incoming_offset_y: 0, outgoing_opacity: 255, incoming_opacity: 255, outgoing_clip: None, incoming_clip: None, } } - ScreenTransitionEffect::SlideRight => { + ScreenTransitionEffect::SlideRight | ScreenTransitionEffect::PopMoook => { let out = self.slide_offset_x(viewport_w); ScreenTransitionSample { outgoing_offset_x: out, + outgoing_offset_y: 0, incoming_offset_x: out - viewport_w as i32, + incoming_offset_y: 0, outgoing_opacity: 255, incoming_opacity: 255, outgoing_clip: None, incoming_clip: None, } } + ScreenTransitionEffect::SlideUp | ScreenTransitionEffect::ModalSlideUp => { + let out = self.slide_offset_y(viewport_h); + ScreenTransitionSample { + outgoing_offset_x: 0, + outgoing_offset_y: out, + incoming_offset_x: 0, + incoming_offset_y: out + viewport_h as i32, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::SlideDown | ScreenTransitionEffect::ModalSlideDown => { + let out = self.slide_offset_y(viewport_h); + ScreenTransitionSample { + outgoing_offset_x: 0, + outgoing_offset_y: out, + incoming_offset_x: 0, + incoming_offset_y: out - viewport_h as i32, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::ShutterLeft => { + let (ox, ix) = shutter_offset(self.progress, viewport_w, true, true); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: 0, + incoming_offset_x: ix, + incoming_offset_y: 0, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::ShutterRight => { + let (ox, ix) = shutter_offset(self.progress, viewport_w, true, false); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: 0, + incoming_offset_x: ix, + incoming_offset_y: 0, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::ShutterUp => { + let (oy, iy) = shutter_offset(self.progress, viewport_h, false, true); + ScreenTransitionSample { + outgoing_offset_x: 0, + outgoing_offset_y: oy, + incoming_offset_x: 0, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::ShutterDown => { + let (oy, iy) = shutter_offset(self.progress, viewport_h, false, false); + ScreenTransitionSample { + outgoing_offset_x: 0, + outgoing_offset_y: oy, + incoming_offset_x: 0, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::PortHoleLeft => { + let (ox, oy, ix, iy) = + port_hole_offsets(self.progress, viewport_w, viewport_h, true, true); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: oy, + incoming_offset_x: ix, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::PortHoleRight => { + let (ox, oy, ix, iy) = + port_hole_offsets(self.progress, viewport_w, viewport_h, true, false); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: oy, + incoming_offset_x: ix, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::PortHoleUp => { + let (ox, oy, ix, iy) = + port_hole_offsets(self.progress, viewport_w, viewport_h, false, true); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: oy, + incoming_offset_x: ix, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::PortHoleDown => { + let (ox, oy, ix, iy) = + port_hole_offsets(self.progress, viewport_w, viewport_h, false, false); + ScreenTransitionSample { + outgoing_offset_x: ox, + outgoing_offset_y: oy, + incoming_offset_x: ix, + incoming_offset_y: iy, + outgoing_opacity: 255, + incoming_opacity: 255, + outgoing_clip: None, + incoming_clip: None, + } + } + ScreenTransitionEffect::RoundFlipLeft | ScreenTransitionEffect::RoundFlipRight => { + let (out_clip, in_clip) = round_flip_clip(viewport_w, viewport_h, self.progress); + ScreenTransitionSample { + outgoing_offset_x: 0, + outgoing_offset_y: 0, + incoming_offset_x: 0, + incoming_offset_y: 0, + outgoing_opacity: if self.progress < 0.5 { 255 } else { 128 }, + incoming_opacity: if self.progress < 0.5 { 128 } else { 255 }, + outgoing_clip: out_clip, + incoming_clip: in_clip, + } + } ScreenTransitionEffect::None => ScreenTransitionSample { outgoing_offset_x: 0, + outgoing_offset_y: 0, incoming_offset_x: 0, + incoming_offset_y: 0, outgoing_opacity: 255, incoming_opacity: 255, outgoing_clip: None, @@ -229,7 +633,9 @@ impl ActiveScreenTransition { let incoming = self.opacity_u8(); ScreenTransitionSample { outgoing_offset_x: 0, + outgoing_offset_y: 0, incoming_offset_x: 0, + incoming_offset_y: 0, outgoing_opacity: 255u8.saturating_sub(incoming / 2), incoming_opacity: incoming, outgoing_clip: None, @@ -241,7 +647,9 @@ impl ActiveScreenTransition { let clip = reveal_clip(viewport_w, viewport_h, self.progress, self.origin); ScreenTransitionSample { outgoing_offset_x: 0, + outgoing_offset_y: 0, incoming_offset_x: 0, + incoming_offset_y: 0, outgoing_opacity: 255u8.saturating_sub(incoming / 4), incoming_opacity: incoming, outgoing_clip: None, @@ -255,7 +663,9 @@ impl ActiveScreenTransition { let clip = wipe_clip(viewport_w, viewport_h, self.progress, self.effect); ScreenTransitionSample { outgoing_offset_x: 0, + outgoing_offset_y: 0, incoming_offset_x: 0, + incoming_offset_y: 0, outgoing_opacity: 255, incoming_opacity: 255, outgoing_clip: None, @@ -266,6 +676,28 @@ impl ActiveScreenTransition { } } +fn round_flip_clip( + viewport_w: u32, + viewport_h: u32, + progress: f32, +) -> (Option, Option) { + let h = viewport_h as i32; + let mid = h / 2; + let scale = if progress < 0.5 { + 1.0 - progress * 2.0 + } else { + (progress - 0.5) * 2.0 + }; + let visible = ((h as f32 * scale).round() as i32).max(1); + let top = mid - visible / 2; + let clip = Rect::new(0, top.max(0), viewport_w, visible.max(1) as u32); + if progress < 0.5 { + (None, Some(clip)) + } else { + (Some(clip), Some(clip)) + } +} + fn reveal_clip( viewport_w: u32, viewport_h: u32, @@ -417,7 +849,7 @@ where outgoing.render_with_offset_opacity_and_clip( target, sample.outgoing_offset_x, - 0, + sample.outgoing_offset_y, sample.outgoing_opacity, clip, )?; @@ -425,7 +857,7 @@ where outgoing.render_with_offset_and_opacity( target, sample.outgoing_offset_x, - 0, + sample.outgoing_offset_y, sample.outgoing_opacity, )?; } @@ -433,7 +865,7 @@ where incoming.render_with_offset_opacity_and_clip( target, sample.incoming_offset_x, - 0, + sample.incoming_offset_y, sample.incoming_opacity, clip, )?; @@ -441,7 +873,7 @@ where incoming.render_with_offset_and_opacity( target, sample.incoming_offset_x, - 0, + sample.incoming_offset_y, sample.incoming_opacity, )?; } diff --git a/src/transition_preset.rs b/src/transition_preset.rs new file mode 100644 index 0000000..e1a66d5 --- /dev/null +++ b/src/transition_preset.rs @@ -0,0 +1,90 @@ +//! Named screen transition presets with default durations and easing. +//! +//! Durations assume a 30 Hz frame interval (`FRAME_INTERVAL_MS` = 33 ms). + +use crate::{ + animation::Easing, + animation_timing::{ + DEFAULT_DURATION_MS, MOOOK_DURATION_MS, PORT_HOLE_DURATION_MS, SHUTTER_DURATION_MS, + }, + screen_transition::{ScreenTransitionEffect, ScreenTransitionSpec}, +}; + +/// Built-in screen transition presets for common navigation patterns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionPreset { + /// Instant cut (no animation). + None, + /// Horizontal push onto the stack (spatial moook curve). + WindowPush, + /// Horizontal pop off the stack (spatial moook curve). + WindowPop, + /// Round-style push (two-phase port-hole slide). + WindowPushRound, + /// Round-style pop (two-phase port-hole slide). + WindowPopRound, + /// Directional shutter wipes. + ShutterUp, + ShutterDown, + ShutterLeft, + ShutterRight, + /// Card-style flip toward launcher. + RoundFlipToLauncher, + /// Card-style flip from launcher. + RoundFlipFromLauncher, + /// Two-phase port-hole slides. + PortHoleUp, + PortHoleDown, + PortHoleLeft, + PortHoleRight, + /// Modal presented from the bottom. + ModalPresent, + /// Modal dismissed toward the bottom. + ModalDismiss, + /// Timeline-style horizontal slide. + TimelineSlide, + /// Cross-fade. + Fade, +} + +impl TransitionPreset { + pub const fn spec(self) -> ScreenTransitionSpec { + match self { + Self::None => ScreenTransitionSpec::none(), + Self::WindowPush => ScreenTransitionSpec::push_moook(MOOOK_DURATION_MS), + Self::WindowPop => ScreenTransitionSpec::pop_moook(MOOOK_DURATION_MS), + Self::WindowPushRound => ScreenTransitionSpec::port_hole_left(PORT_HOLE_DURATION_MS), + Self::WindowPopRound => ScreenTransitionSpec::port_hole_right(PORT_HOLE_DURATION_MS), + Self::ShutterUp => ScreenTransitionSpec::shutter_up(SHUTTER_DURATION_MS), + Self::ShutterDown => ScreenTransitionSpec::shutter_down(SHUTTER_DURATION_MS), + Self::ShutterLeft => ScreenTransitionSpec::shutter_left(SHUTTER_DURATION_MS), + Self::ShutterRight => ScreenTransitionSpec::shutter_right(SHUTTER_DURATION_MS), + Self::RoundFlipToLauncher => { + ScreenTransitionSpec::round_flip_right(PORT_HOLE_DURATION_MS) + } + Self::RoundFlipFromLauncher => { + ScreenTransitionSpec::round_flip_left(PORT_HOLE_DURATION_MS) + } + Self::PortHoleUp => ScreenTransitionSpec::port_hole_up(PORT_HOLE_DURATION_MS), + Self::PortHoleDown => ScreenTransitionSpec::port_hole_down(PORT_HOLE_DURATION_MS), + Self::PortHoleLeft => ScreenTransitionSpec::port_hole_left(PORT_HOLE_DURATION_MS), + Self::PortHoleRight => ScreenTransitionSpec::port_hole_right(PORT_HOLE_DURATION_MS), + Self::ModalPresent => ScreenTransitionSpec::modal_slide_up(DEFAULT_DURATION_MS), + Self::ModalDismiss => ScreenTransitionSpec::modal_slide_down(DEFAULT_DURATION_MS), + Self::TimelineSlide => { + ScreenTransitionSpec::slide_left(DEFAULT_DURATION_MS).with_easing(Easing::EaseInOut) + } + Self::Fade => ScreenTransitionSpec::fade(DEFAULT_DURATION_MS), + } + } + + pub const fn effect(self) -> ScreenTransitionEffect { + self.spec().effect + } +} + +impl From for ScreenTransitionSpec { + fn from(value: TransitionPreset) -> Self { + value.spec() + } +} diff --git a/tests/gui.rs b/tests/gui.rs index b522045..221b6d7 100644 --- a/tests/gui.rs +++ b/tests/gui.rs @@ -1631,6 +1631,68 @@ fn transition_compositor_renders_outgoing_and_incoming_contexts() { assert!(target.digest() != 0); } +#[test] +fn transition_preset_specs_use_default_durations() { + use embedded_gui::{ + MOOOK_DURATION_MS, PORT_HOLE_DURATION_MS, SHUTTER_DURATION_MS, TransitionPreset, + }; + + assert_eq!( + TransitionPreset::WindowPush.spec().duration_ms, + MOOOK_DURATION_MS + ); + assert_eq!( + TransitionPreset::ShutterLeft.spec().duration_ms, + SHUTTER_DURATION_MS + ); + assert_eq!( + TransitionPreset::PortHoleRight.spec().duration_ms, + PORT_HOLE_DURATION_MS + ); +} + +#[test] +fn shell_screen_effects_sample_with_offsets() { + let push = ActiveScreenTransition { + from: Some(ScreenId::new(1)), + to: Some(ScreenId::new(2)), + effect: ScreenTransitionEffect::PushMoook, + origin: ScreenTransitionOrigin::Center, + progress: 0.5, + }; + let sample = push.sample(144, 168); + assert_ne!(sample.incoming_offset_x, sample.outgoing_offset_x); + + let port = ActiveScreenTransition { + from: Some(ScreenId::new(1)), + to: Some(ScreenId::new(2)), + effect: ScreenTransitionEffect::PortHoleLeft, + origin: ScreenTransitionOrigin::Center, + progress: 0.25, + }; + let port_sample = port.sample(144, 168); + assert!(port_sample.outgoing_offset_x != 0 || port_sample.incoming_offset_x != 0); + + let flip = ActiveScreenTransition { + from: Some(ScreenId::new(1)), + to: Some(ScreenId::new(2)), + effect: ScreenTransitionEffect::RoundFlipLeft, + origin: ScreenTransitionOrigin::Center, + progress: 0.25, + }; + assert!(flip.sample(144, 168).incoming_clip.is_some()); +} + +#[test] +fn moook_curve_and_timing_helpers_behave() { + use embedded_gui::{NORMALIZED_MAX, moook_curve, timing_scaled}; + + assert!((moook_curve(0.0) - 0.0).abs() < 0.05); + assert!((moook_curve(1.0) - 1.0).abs() < 0.05); + let mid = timing_scaled(NORMALIZED_MAX / 2, 0, NORMALIZED_MAX); + assert!((mid - NORMALIZED_MAX / 2).abs() <= 2); +} + #[test] fn transition_wipe_and_origin_variants_produce_clips() { let wipe = ActiveScreenTransition {