From 6dff915e74c0938fcb51d794049454b540428675 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 | 275 ++++++++++++------ .../rust/src/ui/layout_eckhart/theme/mod.rs | 2 +- 2 files changed, 192 insertions(+), 85 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..11500d471d 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,47 @@ 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 { + /// Growth duration of the bottom part of the border + const BOTTOM_DURATION: Duration = Duration::from_millis(200); + /// Growth duration of the side parts of the border + const SIDES_DURATION: Duration = Duration::from_millis(800); + /// Growth duration of the top part of the border + const TOP_DURATION: Duration = Duration::from_millis(600); + + /// Duration of the rollback animation + const ROLLBACK_DURATION: Duration = Duration::from_millis(600); + pub fn new() -> Self { + debug_assert!( + theme::CONFIRM_HOLD_DURATION.to_millis() + > (Self::BOTTOM_DURATION.to_millis() + + Self::SIDES_DURATION.to_millis() + + Self::TOP_DURATION.to_millis()) + ); 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 +79,13 @@ impl HoldToConfirmAnim { } pub fn with_duration(mut self, duration: Duration) -> Self { - self.duration = duration; + debug_assert!( + duration.to_millis() + > (Self::BOTTOM_DURATION.to_millis() + + Self::SIDES_DURATION.to_millis() + + Self::TOP_DURATION.to_millis()) + ); + self.total_duration = duration; self } @@ -62,64 +99,19 @@ 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; - - let bottom_width = self.border.bottom_width(); - let total_height = SCREEN.height(); - let total_width = SCREEN.width(); - - 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; - - let vertical_cut = bottom_ratio + vertical_ratio; - - 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); - ( - SCREEN, - Some(Rect::from_center_and_size( - SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), - Offset::new(clip_width, ScreenBorder::WIDTH), - )), - ) - } + fn is_rollback(&self) -> bool { + self.rollback + .timer + .is_running_within(Self::ROLLBACK_DURATION) } } @@ -132,7 +124,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 +133,152 @@ 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 (clip, top_gap) = self.get_clips(rollback_duration_progressed); + let top_back_rollback = self.get_top_gap_rollback(rollback_elapsed); + let top_gap = top_gap.union(top_back_rollback); + self.render_clipped_border(clip, top_gap, 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 (clip, top_gap) = self.get_clips(self.timer.elapsed()); + self.render_clipped_border(clip, top_gap, 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, + clip: Rect, + top_gap: Rect, + alpha: u8, + target: &mut impl Renderer<'s>, + ) { + target.in_clip(clip, &|target| { + self.border.render(alpha, target); + }); + // optional out clip for upper line rendering + shape::Bar::new(top_gap) + .with_bg(theme::BG) + .with_fg(theme::BG) + .render(target); + } + + 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), + ); + u8::lerp(u8::MAX, u8::MIN, shift.eval(progress)) + } + + fn get_top_gap_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), + ) + } + + fn get_clips(&self, elapsed: Duration) -> (Rect, Rect) { + // Define segment-specific timings + let bottom_dur_ratio = Self::BOTTOM_DURATION / self.total_duration; + let sides_dur_ratio = Self::SIDES_DURATION / self.total_duration; + let top_dur_ratio = Self::TOP_DURATION / self.total_duration; + + let progress = (elapsed / self.total_duration).clamp(0.0, 1.0); + + const TOP_GAP_ZERO: Rect = Rect::from_center_and_size( + SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), + Offset::zero(), + ); + match progress { + // Bottom phase growing linearly + p if p < bottom_dur_ratio => { + let bottom_progress = (p / bottom_dur_ratio).clamp(0.0, 1.0); + let width = i16::lerp(0, SCREEN.width(), bottom_progress); + let clip = Rect::from_center_and_size( + SCREEN + .bottom_center() + .ofs(Offset::y(-ScreenBorder::WIDTH / 2)), + Offset::new(width, ScreenBorder::WIDTH), + ); + (clip, TOP_GAP_ZERO) + } + + // Sides phase growing up linearly + p if p < (bottom_dur_ratio + sides_dur_ratio) => { + let sides_progress = ((p - bottom_dur_ratio) / sides_dur_ratio).clamp(0.0, 1.0); + let height = i16::lerp(ScreenBorder::WIDTH, SCREEN.height(), sides_progress); + let clip = Rect::from_bottom_left_and_size( + SCREEN.bottom_left(), + Offset::new(SCREEN.width(), height), + ); + (clip, TOP_GAP_ZERO) + } + + // Top phase + p if p < 1.0 => { + let top_progress = + ((p - bottom_dur_ratio - sides_dur_ratio) / top_dur_ratio).clamp(0.0, 1.0); + 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); + let width = i16::lerp(SCREEN.width(), 0, eased_progress); + let top_gap = Rect::from_center_and_size( + SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)), + Offset::new(width, ScreenBorder::WIDTH), + ); + (SCREEN, top_gap) + } + + // Animation complete + _ => (SCREEN, TOP_GAP_ZERO), } } } 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.