1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-02-24 21:32:03 +00:00

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
This commit is contained in:
obrusvit 2025-02-14 13:38:01 +01:00 committed by Vít Obrusník
parent 2e43b366f1
commit b26484f5d1
15 changed files with 241 additions and 51 deletions

View File

@ -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;

View File

@ -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()
}

View File

@ -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,
}
}

View File

@ -1,13 +1,16 @@
use crate::ui::{
use crate::{
translations::TR,
ui::{
component::{Component, Event, EventCtx},
geometry::{Alignment2D, Offset, Rect},
shape::{self, Renderer},
util::Pager,
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<HoldToConfirmAnim>,
}
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<ActionBarMsg> {
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,61 +234,44 @@ impl Component for ActionBar {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
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) {
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) {
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) {
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) {
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) {

View File

@ -135,8 +135,8 @@ impl Button {
)
}
pub fn is_long_press(&self) -> bool {
self.long_press.is_some()
pub fn long_press(&self) -> Option<Duration> {
self.long_press
}
pub fn is_disabled(&self) -> bool {

View File

@ -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 {

View File

@ -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
}

View File

@ -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<TString<'static>>,
}
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<Self::Msg> {
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);
});
}
}
}

View File

@ -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};

View File

@ -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,

View File

@ -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."""

View File

@ -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"

View File

@ -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",

View File

@ -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"
}

View File

@ -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": [
{