From 05f68e225c6c956519e657c0aca16dec10e0357f Mon Sep 17 00:00:00 2001 From: obrusvit Date: Sun, 23 Feb 2025 17:19:20 +0100 Subject: [PATCH] feat(eckhart): add easing and rollback to HtC anim --- .../component/hold_to_confirm.rs | 260 ++++++++++++------ .../rust/src/ui/layout_eckhart/theme/mod.rs | 2 +- 2 files changed, 184 insertions(+), 78 deletions(-) diff --git a/core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs b/core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs index 92f43f844d..30a0c638b2 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs @@ -5,6 +5,7 @@ use crate::{ component::{Component, Event, EventCtx, Never}, display::Color, geometry::{Offset, Rect}, + lerp::Lerp, shape::{self, Renderer}, }, }; @@ -17,8 +18,8 @@ use super::{ /// A component that displays a border that grows from the bottom of the screen /// to the top. The animation is parametrizable by color and duration. pub struct HoldToConfirmAnim { - /// Duration of the animation - duration: Duration, + /// Intended total duration of Hold to Confirm animation + total_duration: Duration, /// Screen border and header overlay color color: Color, /// Screen border shape @@ -27,17 +28,33 @@ pub struct HoldToConfirmAnim { timer: Stopwatch, /// Header overlay text shown during the animation header_overlay: Option>, + /// Rollback animation state + rollback: RollbackState, +} + +/// State of the rollback animation, when `stop` is called. +struct RollbackState { + /// Timer for the rollback animation + timer: Stopwatch, + /// Point in time of the growth animation when the rollback was initiated + duration: Duration, } impl HoldToConfirmAnim { + const ROLLBACK_DURATION: Duration = Duration::from_millis(600); + pub fn new() -> Self { let default_color = theme::GREEN_LIME; Self { - duration: theme::CONFIRM_HOLD_DURATION, + total_duration: theme::CONFIRM_HOLD_DURATION, color: default_color, border: ScreenBorder::new(default_color), timer: Stopwatch::default(), header_overlay: None, + rollback: RollbackState { + timer: Stopwatch::default(), + duration: Duration::default(), + }, } } @@ -48,7 +65,7 @@ impl HoldToConfirmAnim { } pub fn with_duration(mut self, duration: Duration) -> Self { - self.duration = duration; + self.total_duration = duration; self } @@ -62,63 +79,117 @@ impl HoldToConfirmAnim { } pub fn stop(&mut self) { + self.rollback.timer = Stopwatch::new_started(); + self.rollback.duration = self.timer.elapsed(); self.timer = Stopwatch::new_stopped(); } fn is_active(&self) -> bool { - self.timer.is_running_within(self.duration) + self.timer.is_running_within(self.total_duration) } - fn get_clips(&self) -> (Rect, Option) { - let ratio = self.timer.elapsed() / self.duration; + fn is_rollback(&self) -> bool { + self.rollback + .timer + .is_running_within(Self::ROLLBACK_DURATION) + } - let bottom_width = self.border.bottom_width(); - let total_height = SCREEN.height(); - let total_width = SCREEN.width(); + fn get_rollback_alpha(&self, elapsed: Duration) -> u8 { + let progress = (elapsed / Self::ROLLBACK_DURATION).clamp(0.0, 1.0); + let shift = pareen::constant(0.0).seq_ease_out( + 0.0, + easer::functions::Cubic, + 1.0, + pareen::constant(1.0), + ); + let eased_progress = shift.eval(progress); + u8::lerp(u8::MAX, u8::MIN, shift.eval(progress)) + } - let circumference = 2 * total_height + total_width + bottom_width; - let bottom_ratio = bottom_width as f32 / circumference as f32; - let vertical_ratio = (2 * total_height) as f32 / circumference as f32; - let upper_ratio = total_width as f32 / circumference as f32; + fn get_clip_top_rollback(&self, elapsed: Duration) -> Rect { + let progress = (elapsed / Self::ROLLBACK_DURATION).clamp(0.0, 1.0); + let clip_width = (progress * SCREEN.width() as f32) as i16; + Rect::from_center_and_size( + SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), + Offset::new(clip_width, ScreenBorder::WIDTH), + ) + } - let vertical_cut = bottom_ratio + vertical_ratio; + fn get_clips(&self, elapsed: Duration) -> (Rect, Rect) { + // Define segment-specific timings + let bottom_duration = Duration::from_millis(200); + let sides_duration = Duration::from_millis(800); + let top_duration = Duration::from_millis(600); - if ratio < bottom_ratio { - // Animate the bottom border growing horizontally. - let clip_width = ((ratio / bottom_ratio) * bottom_width as f32) as i16; - let clip_width = clip_width.clamp(0, bottom_width); - ( - Rect::from_center_and_size( - SCREEN - .bottom_center() - .ofs(Offset::y(-ScreenBorder::WIDTH / 2)), - Offset::new(clip_width, ScreenBorder::WIDTH), - ), - None, - ) - } else if ratio < vertical_cut { - // Animate the vertical border growing from the bottom up. - let progress = (ratio - bottom_ratio) / vertical_ratio; - let clip_height = (progress * total_height as f32) as i16; - let clip_height = clip_height.clamp(0, total_height - ScreenBorder::WIDTH); - ( - Rect::from_bottom_left_and_size( - SCREEN.bottom_left(), - Offset::new(total_width, clip_height), - ), - None, - ) - } else { - // Animate the top border growing horizontally towards center. - let progress = (ratio - vertical_cut) / upper_ratio; - let clip_width = total_width - ((progress * total_width as f32) as i16); - ( + let bottom_duration_ratio = bottom_duration / self.total_duration; + let sides_duration_ratio = sides_duration / self.total_duration; + let top_duration_ratio = top_duration / self.total_duration; + + let progress = (elapsed / self.total_duration).clamp(0.0, 1.0); + + match progress { + // Bottom phase growing linearly + p if p < bottom_duration_ratio => { + let bottom_progress = p / bottom_duration_ratio; + let width = i16::lerp(0, SCREEN.width(), bottom_progress); + + ( + Rect::from_center_and_size( + SCREEN + .bottom_center() + .ofs(Offset::y(-ScreenBorder::WIDTH / 2)), + Offset::new(width, ScreenBorder::WIDTH), + ), + Rect::zero(), + ) + } + + // Sides phase growing up linearly + p if p < (bottom_duration_ratio + sides_duration_ratio) => { + let sides_progress = (p - bottom_duration_ratio) / sides_duration_ratio; + let height = i16::lerp(ScreenBorder::WIDTH, SCREEN.height(), sides_progress); + + ( + Rect::from_bottom_left_and_size( + SCREEN.bottom_left(), + Offset::new(SCREEN.width(), height), + ), + Rect::zero(), + ) + } + + // Top phase + p if p < 1.0 => { + let top_progress = + (p - bottom_duration_ratio - sides_duration_ratio) / top_duration_ratio; + + let ease = pareen::constant(0.0).seq_ease_out( + 0.0, + easer::functions::Cubic, + 1.0, + pareen::constant(1.0), + ); + + let eased_progress = ease.eval(top_progress.clamp(0.0, 1.0)); + let width = i16::lerp(SCREEN.width(), 0, eased_progress); + + ( + SCREEN, + Rect::from_center_and_size( + SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), + Offset::new(width, ScreenBorder::WIDTH), + ), + ) + } + + // Animation complete + _ => ( SCREEN, - Some(Rect::from_center_and_size( + Rect::from_center_and_size( SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), - Offset::new(clip_width, ScreenBorder::WIDTH), - )), - ) + Offset::zero(), + ), + ), } } } @@ -132,7 +203,7 @@ impl Component for HoldToConfirmAnim { fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { - if self.is_active() { + if self.is_active() || self.is_rollback() { ctx.request_anim_frame(); ctx.request_paint(); } @@ -141,37 +212,72 @@ impl Component for HoldToConfirmAnim { } fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + // Rollback & Fading out animation + if self.is_rollback() { + let rollback_elapsed = self.rollback.timer.elapsed(); + let alpha = self.get_rollback_alpha(rollback_elapsed); + let rollback_duration_progressed = self + .rollback + .duration + .checked_add(rollback_elapsed) + .unwrap_or(Duration::default()); + let (in_clip, out_clip) = self.get_clips(rollback_duration_progressed); + let out_clip_push_back = self.get_clip_top_rollback(rollback_elapsed); + let out_clip = out_clip.union(out_clip_push_back); + + self.render_clipped_border(in_clip, out_clip, alpha, target); + } + + // Growing animation if self.is_active() { // override header with custom text - if let Some(text) = self.header_overlay { - let font = fonts::FONT_SATOSHI_REGULAR_22; - let header_pad = Rect::from_top_left_and_size( - SCREEN.top_left(), - Offset::new(SCREEN.width(), Header::HEADER_HEIGHT), - ); - shape::Bar::new(header_pad) - .with_bg(theme::BG) - .render(target); - text.map(|text| { - let text_pos = header_pad.top_left() - + Offset::new(24, font.vert_center(0, Header::HEADER_HEIGHT, text)); - shape::Text::new(text_pos, text, font) - .with_fg(self.color) - .render(target); - }); + // TODO: is the delay desirable? + if self.timer.elapsed() > Duration::from_millis(300) { + self.render_header_overlay(target); } // growing border - let (in_clip, out_clip_opt) = self.get_clips(); - target.in_clip(in_clip, &|target| { - self.border.render(target); - }); - // optional out clip for upper line rendering - if let Some(out_clip) = out_clip_opt { - shape::Bar::new(out_clip) - .with_bg(theme::BG) - .with_fg(theme::BG) - .render(target); - } + let (in_clip, out_clip) = self.get_clips(self.timer.elapsed()); + self.render_clipped_border(in_clip, out_clip, u8::MAX, target); } } } + +// Rendering helpers +impl HoldToConfirmAnim { + fn render_header_overlay<'s>(&'s self, target: &mut impl Renderer<'s>) { + if let Some(text) = self.header_overlay { + let font = theme::label_title_main().text_font; + let header_pad = Rect::from_top_left_and_size( + SCREEN.top_left(), + Offset::new(SCREEN.width(), Header::HEADER_HEIGHT), + ); + shape::Bar::new(header_pad) + .with_bg(theme::BG) + .render(target); + text.map(|text| { + let text_pos = header_pad.top_left() + + Offset::new(24, font.vert_center(0, Header::HEADER_HEIGHT, text)); + shape::Text::new(text_pos, text, font) + .with_fg(self.color) + .render(target); + }); + } + } + + fn render_clipped_border<'s>( + &'s self, + in_clip: Rect, + out_clip: Rect, + alpha: u8, + target: &mut impl Renderer<'s>, + ) { + target.in_clip(in_clip, &|target| { + self.border.render(alpha, target); + }); + // optional out clip for upper line rendering + shape::Bar::new(out_clip) + .with_bg(theme::BG) + .with_fg(theme::BG) + .render(target); + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs index 47348af6f0..8c7e909a84 100644 --- a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs @@ -12,7 +12,7 @@ use super::{ fonts, }; -pub const CONFIRM_HOLD_DURATION: Duration = Duration::from_millis(1500); +pub const CONFIRM_HOLD_DURATION: Duration = Duration::from_millis(2500); pub const ERASE_HOLD_DURATION: Duration = Duration::from_millis(1500); // Color palette.