From f9091f36092728837bcc3a6ce452a354fc9d94de Mon Sep 17 00:00:00 2001 From: obrusvit Date: Fri, 14 Feb 2025 13:38:01 +0100 Subject: [PATCH] feat(eckhart): implement hold to confirm anim - HoldToConfirmAnim is driven by the ActionBar in case the right_button is configured with `long_press` - HoldToConfirmAnim optionally draws an Header overaly with custom text - disabling animations is respected - easing function is not yet finalized - a few minor fixes along the way --- core/embed/rust/src/time.rs | 2 +- .../generated/translated_string.rs | 9 +- .../ui/layout_eckhart/component/action_bar.rs | 123 ++++++++++++----- .../src/ui/layout_eckhart/component/button.rs | 4 +- .../src/ui/layout_eckhart/component/header.rs | 3 +- .../src/ui/layout_eckhart/component/hint.rs | 1 - .../component/hold_to_confirm.rs | 128 ++++++++++++++++++ .../src/ui/layout_eckhart/component/mod.rs | 2 + .../ui/layout_eckhart/cshape/screen_border.rs | 12 +- core/mocks/generated/trezorui_api.pyi | 2 +- core/mocks/trezortranslate_keys.pyi | 2 +- core/translations/en.json | 2 +- 12 files changed, 240 insertions(+), 50 deletions(-) create mode 100644 core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs diff --git a/core/embed/rust/src/time.rs b/core/embed/rust/src/time.rs index 98a5961251..2f38f53b25 100644 --- a/core/embed/rust/src/time.rs +++ b/core/embed/rust/src/time.rs @@ -158,7 +158,7 @@ pub enum Stopwatch { } impl Default for Stopwatch { - /// Returns a new sopteed stopwatch by default. + /// Returns a new stopped stopwatch by default. fn default() -> Self { Self::new_stopped() } diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index c383e180ea..ab8777fe52 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -1279,7 +1279,7 @@ pub enum TranslatedString { haptic_feedback__enable = 885, // "Enable haptic feedback?" haptic_feedback__subtitle = 886, // "Setting" haptic_feedback__title = 887, // "Haptic feedback" - instructions__continue_holding = 888, // "Continue\nholding" + instructions__continue_holding = 888, // {"Bolt": "", "Caesar": "", "Delizia": "Continue\nholding", "Eckhart": "Keep holding"} instructions__enter_next_share = 889, // "Enter next share" instructions__hold_to_continue = 890, // "Hold to continue" instructions__hold_to_exit_tutorial = 891, // "Hold to exit tutorial" @@ -2679,7 +2679,14 @@ impl TranslatedString { Self::haptic_feedback__enable => "Enable haptic feedback?", Self::haptic_feedback__subtitle => "Setting", Self::haptic_feedback__title => "Haptic feedback", + #[cfg(feature = "layout_bolt")] + Self::instructions__continue_holding => "", + #[cfg(feature = "layout_caesar")] + Self::instructions__continue_holding => "", + #[cfg(feature = "layout_delizia")] Self::instructions__continue_holding => "Continue\nholding", + #[cfg(feature = "layout_eckhart")] + Self::instructions__continue_holding => "Keep holding", Self::instructions__enter_next_share => "Enter next share", Self::instructions__hold_to_continue => "Hold to continue", Self::instructions__hold_to_exit_tutorial => "Hold to exit tutorial", diff --git a/core/embed/rust/src/ui/layout_eckhart/component/action_bar.rs b/core/embed/rust/src/ui/layout_eckhart/component/action_bar.rs index 9909c5f567..212f243aaa 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/action_bar.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/action_bar.rs @@ -1,13 +1,16 @@ -use crate::ui::{ - component::{Component, Event, EventCtx}, - geometry::{Alignment2D, Offset, Rect}, - shape::{self, Renderer}, - util::Pager, +use crate::{ + translations::TR, + ui::{ + component::{Component, Event, EventCtx}, + geometry::{Alignment2D, Offset, Rect}, + shape::{self, Renderer}, + util::{animation_disabled, Pager}, + }, }; use super::{ button::{Button, ButtonContent, ButtonMsg}, - theme, ButtonStyleSheet, + theme, ButtonStyleSheet, HoldToConfirmAnim, }; /// Component for control buttons in the bottom of the screen. @@ -25,7 +28,8 @@ pub struct ActionBar { // Storage of original button content for paginated component left_original: Option<(ButtonContent, ButtonStyleSheet)>, right_original: Option<(ButtonContent, ButtonStyleSheet)>, - // TODO: animation + /// Hold to confirm animation + htc_anim: Option, } pub enum ActionBarMsg { @@ -129,6 +133,15 @@ impl ActionBar { _ => (None, None), }; + let htc_anim = right_button + .long_press() + .filter(|_| !animation_disabled()) + .map(|dur| { + HoldToConfirmAnim::new() + .with_duration(dur) + .with_header_overlay(TR::instructions__continue_holding.into()) + }); + Self { mode, right_button, @@ -137,8 +150,57 @@ impl ActionBar { left_short: false, left_original, right_original, + htc_anim, } } + + /// Handle right button at the last page, this includes: + /// - Single button mode + /// - Double button mode at single page component + /// - Double button mode at last page of paginated component + /// The function takes care about triggering the correct action to + /// HoldToConfirm or returning the correct message out of the ActionBar. + fn right_button_at_last_page( + &mut self, + ctx: &mut EventCtx, + msg: ButtonMsg, + ) -> Option { + let is_hold = self.right_button.long_press().is_some(); + match (msg, is_hold) { + (ButtonMsg::Pressed, true) => { + if let Some(htc_anim) = &mut self.htc_anim { + htc_anim.start(); + ctx.request_anim_frame(); + ctx.request_paint(); + ctx.disable_swipe(); + } + } + (ButtonMsg::Clicked, true) => { + if let Some(htc_anim) = &mut self.htc_anim { + htc_anim.stop(); + ctx.request_anim_frame(); + ctx.request_paint(); + ctx.enable_swipe(); + } else { + // Animations disabled, return confirmed + return Some(ActionBarMsg::Confirmed); + } + } + (ButtonMsg::Released, true) => { + if let Some(htc_anim) = &mut self.htc_anim { + htc_anim.stop(); + ctx.request_anim_frame(); + ctx.request_paint(); + ctx.enable_swipe(); + } + } + (ButtonMsg::Clicked, false) | (ButtonMsg::LongPressed, true) => { + return Some(ActionBarMsg::Confirmed); + } + _ => {} + } + None + } } impl Component for ActionBar { @@ -172,60 +234,43 @@ impl Component for ActionBar { } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.htc_anim.event(ctx, event); match &self.mode { Mode::Single => { // Only handle confirm button - if let Some(ButtonMsg::Clicked) = self.right_button.event(ctx, event) { - return Some(ActionBarMsg::Confirmed); + if let Some(msg) = self.right_button.event(ctx, event) { + return self.right_button_at_last_page(ctx, msg); } } Mode::Double { pager } => { if pager.is_single() { // Single page - show back and confirm - if let Some(btn) = &mut self.left_button { - if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { - return Some(ActionBarMsg::Cancelled); - } + if let Some(ButtonMsg::Clicked) = self.left_button.event(ctx, event) { + return Some(ActionBarMsg::Cancelled); } if let Some(msg) = self.right_button.event(ctx, event) { - match (&self.right_button.is_long_press(), msg) { - (true, ButtonMsg::LongPressed) | (false, ButtonMsg::Clicked) => { - return Some(ActionBarMsg::Confirmed); - } - _ => {} - } + return self.right_button_at_last_page(ctx, msg); } } else if pager.is_first() && !pager.is_single() { // First page of multiple - go back and next page - if let Some(btn) = &mut self.left_button { - if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { - return Some(ActionBarMsg::Cancelled); - } + if let Some(ButtonMsg::Clicked) = self.left_button.event(ctx, event) { + return Some(ActionBarMsg::Cancelled); } if let Some(ButtonMsg::Clicked) = self.right_button.event(ctx, event) { return Some(ActionBarMsg::Next); } } else if pager.is_last() && !pager.is_single() { // Last page - enable up button, show confirm - if let Some(btn) = &mut self.left_button { - if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { - return Some(ActionBarMsg::Prev); - } + if let Some(ButtonMsg::Clicked) = self.left_button.event(ctx, event) { + return Some(ActionBarMsg::Prev); } if let Some(msg) = self.right_button.event(ctx, event) { - match (&self.right_button.is_long_press(), msg) { - (true, ButtonMsg::LongPressed) | (false, ButtonMsg::Clicked) => { - return Some(ActionBarMsg::Confirmed); - } - _ => {} - } + return self.right_button_at_last_page(ctx, msg); } } else { // Middle pages - navigations up/down - if let Some(btn) = &mut self.left_button { - if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { - return Some(ActionBarMsg::Prev); - } + if let Some(ButtonMsg::Clicked) = self.left_button.event(ctx, event) { + return Some(ActionBarMsg::Prev); } if let Some(ButtonMsg::Clicked) = self.right_button.event(ctx, event) { return Some(ActionBarMsg::Next); @@ -246,8 +291,12 @@ impl Component for ActionBar { btn.render(target); } self.right_button.render(target); + if let Some(htc_anim) = &self.htc_anim { + htc_anim.render(target); + } } } + #[cfg(feature = "ui_debug")] impl crate::trace::Trace for ActionBar { fn trace(&self, t: &mut dyn crate::trace::Tracer) { diff --git a/core/embed/rust/src/ui/layout_eckhart/component/button.rs b/core/embed/rust/src/ui/layout_eckhart/component/button.rs index 6116d23d72..7f68c24ea1 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs @@ -135,8 +135,8 @@ impl Button { ) } - pub fn is_long_press(&self) -> bool { - self.long_press.is_some() + pub fn long_press(&self) -> Option { + self.long_press } pub fn is_disabled(&self) -> bool { diff --git a/core/embed/rust/src/ui/layout_eckhart/component/header.rs b/core/embed/rust/src/ui/layout_eckhart/component/header.rs index 06965987de..c74ced651d 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/header.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/header.rs @@ -171,7 +171,8 @@ impl Header { ctx.request_paint(); } - /// Calculates the width needed for the left icon, be it a button with icon or just icon + /// Calculates the width needed for the left icon, be it a button with icon + /// or just icon fn left_icon_width(&self) -> i16 { let margin_right: i16 = 16; // [px] if let Some(b) = &self.left_button { diff --git a/core/embed/rust/src/ui/layout_eckhart/component/hint.rs b/core/embed/rust/src/ui/layout_eckhart/component/hint.rs index 28493776eb..3582bfb424 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/hint.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/hint.rs @@ -245,7 +245,6 @@ impl<'a> Instruction<'a> { fn height(&self) -> i16 { let text_area_width = screen().inset(Hint::HINT_INSETS).width() - self.icon_width(); let calculated_height = self.label.text_height(text_area_width); - dbg_print!("Instruction height: {}\n", calculated_height as i16); debug_assert!(calculated_height <= Hint::HEIGHT_MAXIMAL); calculated_height } 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 new file mode 100644 index 0000000000..b25a1dc949 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/hold_to_confirm.rs @@ -0,0 +1,128 @@ +use crate::{ + strutil::TString, + time::{Duration, Stopwatch}, + ui::{ + component::{Component, Event, EventCtx, Never}, + display::Color, + geometry::{Offset, Rect}, + layout_eckhart::{cshape::ScreenBorder, fonts}, + shape::{self, Renderer}, + }, +}; + +use super::{constant, theme, Header}; + +/// 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, + /// Screen border and header overlay color + color: Color, + /// Screen border shape + border: ScreenBorder, + /// Timer for the animation + timer: Stopwatch, + /// Header overlay text shown during the animation + header_overlay: Option>, +} + +impl HoldToConfirmAnim { + pub fn new() -> Self { + let default_color = theme::GREEN_LIME; + Self { + duration: theme::CONFIRM_HOLD_DURATION, + color: default_color, + border: ScreenBorder::new(default_color), + timer: Stopwatch::default(), + header_overlay: None, + } + } + + pub fn with_color(mut self, color: Color) -> Self { + self.color = color; + self.border = ScreenBorder::new(color); + self + } + + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = duration; + self + } + + pub fn with_header_overlay(mut self, header_text: TString<'static>) -> Self { + self.header_overlay = Some(header_text); + self + } + + pub fn start(&mut self) { + self.timer = Stopwatch::new_started(); + } + + pub fn stop(&mut self) { + self.timer = Stopwatch::new_stopped(); + } + + fn is_active(&self) -> bool { + self.timer.is_running_within(self.duration) + } + + fn get_clip(&self) -> Rect { + // TODO: + // 1) there will be some easer function + // 2) the growth of the top bar cannot be done with just one clip + let screen = constant::screen(); + let ratio = self.timer.elapsed() / self.duration; + let clip_height = ((ratio * screen.height() as f32) as i16).clamp(0, screen.height()); + Rect::from_bottom_left_and_size( + screen.bottom_left(), + Offset::new(screen.width(), clip_height), + ) + } +} + +impl Component for HoldToConfirmAnim { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { + if self.is_active() { + ctx.request_anim_frame(); + ctx.request_paint(); + } + }; + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + 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( + constant::screen().top_left(), + Offset::new(constant::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); + }); + } + // growing border + let clip = self.get_clip(); + target.in_clip(clip, &|target| { + self.border.render(target); + }); + } + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs index 5d1764cb94..2407b01f59 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs @@ -4,6 +4,7 @@ mod button; mod error; mod header; mod hint; +mod hold_to_confirm; mod result; mod text_screen; mod vertical_menu; @@ -15,6 +16,7 @@ pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet pub use error::ErrorScreen; pub use header::{Header, HeaderMsg}; pub use hint::Hint; +pub use hold_to_confirm::HoldToConfirmAnim; pub use result::{ResultFooter, ResultScreen, ResultStyle}; pub use text_screen::{AllowedTextContent, TextScreen, TextScreenMsg}; pub use vertical_menu::{VerticalMenu, VerticalMenuMsg, MENU_MAX_ITEMS}; diff --git a/core/embed/rust/src/ui/layout_eckhart/cshape/screen_border.rs b/core/embed/rust/src/ui/layout_eckhart/cshape/screen_border.rs index 910d258636..1f76d3675e 100644 --- a/core/embed/rust/src/ui/layout_eckhart/cshape/screen_border.rs +++ b/core/embed/rust/src/ui/layout_eckhart/cshape/screen_border.rs @@ -19,7 +19,8 @@ impl ScreenBorder { pub fn new(color: Color) -> Self { let screen = constant::screen(); - // Top bar: from the right edge of top-left icon to the left edge of top-right icon. + // Top bar: from the right edge of top-left icon to the left edge of top-right + // icon. let top_bar_rect = Rect { x0: screen.x0 + ICON_BORDER_TL.toif.width(), y0: screen.y0, @@ -27,7 +28,8 @@ impl ScreenBorder { y1: screen.y0 + 2, }; - // Bottom bar: from the right edge of bottom-left icon to the left edge of bottom-right icon. + // Bottom bar: from the right edge of bottom-left icon to the left edge of + // bottom-right icon. let bottom_bar_rect = Rect { x0: screen.x0 + ICON_BORDER_BL.toif.width(), y0: screen.y1 - 2, @@ -35,14 +37,16 @@ impl ScreenBorder { y1: screen.y1, }; - // Left bar: from the bottom edge of top-left icon to the top edge of bottom-left icon. + // Left bar: from the bottom edge of top-left icon to the top edge of + // bottom-left icon. let left_bar_rect = Rect { x0: screen.x0, y0: screen.y0 + ICON_BORDER_TL.toif.height() - 1, x1: screen.x0 + 2, y1: screen.y1 - ICON_BORDER_BL.toif.height(), }; - // Right bar: from the bottom edge of top-right icon to the top edge of bottom-right icon. + // Right bar: from the bottom edge of top-right icon to the top edge of + // bottom-right icon. let right_bar_rect = Rect { x0: screen.x1 - 2, y0: screen.y0 + ICON_BORDER_TR.toif.height() - 1, diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi index cd1c1632f4..9da454080b 100644 --- a/core/mocks/generated/trezorui_api.pyi +++ b/core/mocks/generated/trezorui_api.pyi @@ -634,7 +634,7 @@ def show_success( title: str, button: str, description: str = "", - allow_cancel: bool = True, + allow_cancel: bool = False, time_ms: int = 0, ) -> LayoutObj[UiResult]: """Success modal. No buttons shown when `button` is empty string.""" diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index 8a7a9c7e42..6fb1458c54 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -386,7 +386,7 @@ class TR: inputs__return: str = "RETURN" inputs__show: str = "SHOW" inputs__space: str = "SPACE" - instructions__continue_holding: str = "Continue\nholding" + instructions__continue_holding: str = "" instructions__continue_in_app: str = "Continue in the app" instructions__enter_next_share: str = "Enter next share" instructions__hold_to_confirm: str = "Hold to confirm" diff --git a/core/translations/en.json b/core/translations/en.json index 1ff68b19bf..46bf76799d 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -388,7 +388,7 @@ "inputs__return": "RETURN", "inputs__show": "SHOW", "inputs__space": "SPACE", - "instructions__continue_holding": "Continue\nholding", + "instructions__continue_holding": {"Bolt": "", "Caesar": "", "Delizia": "Continue\nholding", "Eckhart": "Keep holding"}, "instructions__continue_in_app": "Continue in the app", "instructions__enter_next_share": "Enter next share", "instructions__hold_to_confirm": "Hold to confirm",