diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 820f5e2e23..fc78c19a9e 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -290,6 +290,7 @@ static void _librust_qstrs(void) { MP_QSTR_instructions__hold_to_exit_tutorial; MP_QSTR_instructions__hold_to_finish_tutorial; MP_QSTR_instructions__hold_to_sign; + MP_QSTR_instructions__keep_holding; MP_QSTR_instructions__learn_more; MP_QSTR_instructions__shares_continue_with_x_template; MP_QSTR_instructions__shares_start_with_1; 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 82a3456bfc..f8182f3f5f 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -1382,6 +1382,7 @@ pub enum TranslatedString { misc__enable_labeling = 973, // "Enable labeling?" #[cfg(feature = "universal_fw")] ethereum__unknown_contract_address_short = 974, // "Unknown contract address." + instructions__keep_holding = 975, // "Keep holding" } impl TranslatedString { @@ -2760,6 +2761,7 @@ impl TranslatedString { Self::misc__enable_labeling => "Enable labeling?", #[cfg(feature = "universal_fw")] Self::ethereum__unknown_contract_address_short => "Unknown contract address.", + Self::instructions__keep_holding => "Keep holding", } } @@ -4137,6 +4139,7 @@ impl TranslatedString { Qstr::MP_QSTR_misc__enable_labeling => Some(Self::misc__enable_labeling), #[cfg(feature = "universal_fw")] Qstr::MP_QSTR_ethereum__unknown_contract_address_short => Some(Self::ethereum__unknown_contract_address_short), + Qstr::MP_QSTR_instructions__keep_holding => Some(Self::instructions__keep_holding), _ => None, } } 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..a3b038858c 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__keep_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 e10e8ae2f6..876ecfbb63 100644 --- a/core/mocks/generated/trezorui_api.pyi +++ b/core/mocks/generated/trezorui_api.pyi @@ -631,7 +631,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 1641d82b02..99590e0309 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -393,6 +393,7 @@ class TR: instructions__hold_to_continue: str = "Hold to continue" instructions__hold_to_exit_tutorial: str = "Hold to exit tutorial" instructions__hold_to_sign: str = "Hold to sign" + instructions__keep_holding: str = "Keep holding" instructions__learn_more: str = "Learn more" instructions__shares_continue_with_x_template: str = "Continue with Share #{0}" instructions__shares_start_with_1: str = "Start with share #1" diff --git a/core/translations/en.json b/core/translations/en.json index 5219f4af03..b00b3531e1 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -395,6 +395,7 @@ "instructions__hold_to_continue": "Hold to continue", "instructions__hold_to_exit_tutorial": "Hold to exit tutorial", "instructions__hold_to_sign": "Hold to sign", + "instructions__keep_holding": "Keep holding", "instructions__learn_more": "Learn more", "instructions__shares_continue_with_x_template": "Continue with Share #{0}", "instructions__shares_start_with_1": "Start with share #1", diff --git a/core/translations/order.json b/core/translations/order.json index 6fee8d6de7..648cdcfe01 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -973,5 +973,6 @@ "971": "instructions__view_all_data", "972": "ethereum__interaction_contract", "973": "misc__enable_labeling", - "974": "ethereum__unknown_contract_address_short" + "974": "ethereum__unknown_contract_address_short", + "975": "instructions__keep_holding" } diff --git a/core/translations/signatures.json b/core/translations/signatures.json index baf55e9e32..f5f26e0279 100644 --- a/core/translations/signatures.json +++ b/core/translations/signatures.json @@ -1,8 +1,8 @@ { "current": { - "merkle_root": "6b7c949ee3a2332eca6a3224cab4f161b7b0ae8e4b8c54591ec50731091b99c9", - "datetime": "2025-02-07T14:30:18.145419", - "commit": "061e71213ea8340874e47eab7d0aec07ec444c1e" + "merkle_root": "43c08e81d71c1c28d77b1650fc96b0dfcd473fde0c922717e5588baeeb581bd3", + "datetime": "2025-02-14T16:12:57.065880", + "commit": "3dabb94653e04856efc89d07c67b7e6f0c587f8c" }, "history": [ {