1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-11-26 09:28:13 +00:00

feat(core): add hold-to-confirm animation to mercury UI

[no changelog]
This commit is contained in:
tychovrahe 2024-05-05 14:06:31 +02:00 committed by TychoVrahe
parent 1f01150e7c
commit 67fe334dc2
11 changed files with 460 additions and 52 deletions

View File

@ -67,18 +67,6 @@ impl Div<Duration> for Duration {
}
}
impl From<f32> for Duration {
fn from(value: f32) -> Self {
Self::from_millis((value * 1000.0) as u32)
}
}
impl From<Duration> for f32 {
fn from(value: Duration) -> Self {
value.to_millis() as f32 / 1000.0
}
}
/* Instants can wrap around and we want them to be comparable even after
* wrapping around. This works by setting a maximum allowable difference
* between two Instants to half the range. In checked_add and checked_sub, we
@ -163,6 +151,7 @@ impl Ord for Instant {
/// A stopwatch is a utility designed for measuring the amount of time
/// that elapses between its start and stop points. It can be used in various
/// situations - animation timing, event timing, testing and debugging.
#[derive(Clone)]
pub enum Stopwatch {
Stopped(Duration),
Running(Instant),
@ -214,7 +203,7 @@ impl Stopwatch {
}
}
/// Returns `true` if the stopwatch is currenly running.
/// Returns `true` if the stopwatch is currently running.
pub fn is_running(&self) -> bool {
matches!(*self, Self::Running(_))
}

View File

@ -104,6 +104,11 @@ impl<'a> Label<'a> {
};
Rect::from_bottom_left_and_size(baseline, Offset::new(width, height))
}
pub fn render_with_alpha<'s>(&self, target: &mut impl Renderer<'s>, alpha: u8) {
self.text
.map(|c| self.layout.render_text_with_alpha(c, target, alpha));
}
}
impl Component for Label<'_> {

View File

@ -239,10 +239,19 @@ impl TextLayout {
/// Draw as much text as possible on the current screen.
pub fn render_text2<'s>(&self, text: &str, target: &mut impl Renderer<'s>) -> LayoutFit {
self.render_text_with_alpha(text, target, 255)
}
/// Draw as much text as possible on the current screen.
pub fn render_text_with_alpha<'s>(
&self,
text: &str,
target: &mut impl Renderer<'s>,
alpha: u8,
) -> LayoutFit {
self.layout_text(
text,
&mut self.initial_cursor(),
&mut TextRenderer2::new(target),
&mut TextRenderer2::new(target).with_alpha(alpha),
)
}
@ -541,16 +550,29 @@ impl LayoutSink for TextRenderer {
}
}
pub struct TextRenderer2<'a, 's, R>(pub &'a mut R, core::marker::PhantomData<&'s ()>)
pub struct TextRenderer2<'a, 's, R>
where
R: Renderer<'s>;
R: Renderer<'s>,
{
pub renderer: &'a mut R,
pd: core::marker::PhantomData<&'s ()>,
alpha: u8,
}
impl<'a, 's, R> TextRenderer2<'a, 's, R>
where
R: Renderer<'s>,
{
pub fn new(target: &'a mut R) -> Self {
Self(target, core::marker::PhantomData)
Self {
renderer: target,
pd: core::marker::PhantomData,
alpha: 255,
}
}
pub fn with_alpha(self, alpha: u8) -> Self {
Self { alpha, ..self }
}
}
@ -562,14 +584,16 @@ where
shape::Text::new(cursor, text)
.with_font(layout.style.text_font)
.with_fg(layout.style.text_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
}
fn hyphen(&mut self, cursor: Point, layout: &TextLayout) {
shape::Text::new(cursor, "-")
.with_font(layout.style.text_font)
.with_fg(layout.style.hyphen_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
}
fn ellipsis(&mut self, cursor: Point, layout: &TextLayout) {
@ -578,12 +602,14 @@ where
shape::ToifImage::new(bottom_left, icon.toif)
.with_align(Alignment2D::BOTTOM_LEFT)
.with_fg(layout.style.ellipsis_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
} else {
shape::Text::new(cursor, ELLIPSIS)
.with_font(layout.style.text_font)
.with_fg(layout.style.ellipsis_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
}
}
@ -592,12 +618,14 @@ where
shape::ToifImage::new(cursor, icon.toif)
.with_align(Alignment2D::BOTTOM_LEFT)
.with_fg(layout.style.ellipsis_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
} else {
shape::Text::new(cursor, ELLIPSIS)
.with_font(layout.style.text_font)
.with_fg(layout.style.ellipsis_color)
.render(self.0);
.with_alpha(self.alpha)
.render(self.renderer);
}
}
}

View File

@ -35,6 +35,7 @@ pub struct Button {
state: State,
long_press: Option<Duration>,
long_timer: Option<TimerToken>,
haptic: bool,
}
impl Button {
@ -54,6 +55,7 @@ impl Button {
state: State::Initial,
long_press: None,
long_timer: None,
haptic: true,
}
}
@ -102,6 +104,11 @@ impl Button {
self
}
pub fn without_haptics(mut self) -> Self {
self.haptic = false;
self
}
pub fn enable_if(&mut self, ctx: &mut EventCtx, enabled: bool) {
if enabled {
self.enable(ctx);
@ -182,7 +189,12 @@ impl Button {
}
}
pub fn render_background<'s>(&self, target: &mut impl Renderer<'s>, style: &ButtonStyle) {
pub fn render_background<'s>(
&self,
target: &mut impl Renderer<'s>,
style: &ButtonStyle,
alpha: u8,
) {
match &self.content {
ButtonContent::IconBlend(_, _, _) => {}
_ => {
@ -192,11 +204,13 @@ impl Button {
.with_radius(self.radius.unwrap() as i16)
.with_thickness(2)
.with_fg(style.button_color)
.with_alpha(alpha)
.render(target);
} else {
shape::Bar::new(self.area)
.with_bg(style.button_color)
.with_fg(style.button_color)
.with_alpha(alpha)
.render(target);
}
}
@ -238,7 +252,12 @@ impl Button {
}
}
pub fn render_content<'s>(&self, target: &mut impl Renderer<'s>, style: &ButtonStyle) {
pub fn render_content<'s>(
&self,
target: &mut impl Renderer<'s>,
style: &ButtonStyle,
alpha: u8,
) {
match &self.content {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
@ -255,6 +274,7 @@ impl Button {
.with_font(style.font)
.with_fg(style.text_color)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
});
}
@ -262,24 +282,40 @@ impl Button {
shape::ToifImage::new(self.area.center(), icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.icon_color)
.with_alpha(alpha)
.render(target);
}
ButtonContent::IconAndText(child) => {
child.render(target, self.area, self.style(), Self::BASELINE_OFFSET);
child.render(
target,
self.area,
self.style(),
Self::BASELINE_OFFSET,
alpha,
);
}
ButtonContent::IconBlend(bg, fg, offset) => {
shape::Bar::new(self.area)
.with_bg(style.background_color)
.with_alpha(alpha)
.render(target);
shape::ToifImage::new(self.area.top_left(), bg.toif)
.with_fg(style.button_color)
.with_alpha(alpha)
.render(target);
shape::ToifImage::new(self.area.top_left() + *offset, fg.toif)
.with_fg(style.icon_color)
.with_alpha(alpha)
.render(target);
}
}
}
pub fn render_with_alpha<'s>(&self, target: &mut impl Renderer<'s>, alpha: u8) {
let style = self.style();
self.render_background(target, style, alpha);
self.render_content(target, style, alpha);
}
}
impl Component for Button {
@ -307,7 +343,9 @@ impl Component for Button {
// Touch started in our area, transform to `Pressed` state.
if touch_area.contains(pos) {
#[cfg(feature = "haptic")]
play(HapticEffect::ButtonPress);
if self.haptic {
play(HapticEffect::ButtonPress);
}
self.set(ctx, State::Pressed);
if let Some(duration) = self.long_press {
self.long_timer = Some(ctx.request_timer(duration));
@ -351,7 +389,9 @@ impl Component for Button {
self.long_timer = None;
if matches!(self.state, State::Pressed) {
#[cfg(feature = "haptic")]
play(HapticEffect::ButtonPress);
if self.haptic {
play(HapticEffect::ButtonPress);
}
self.set(ctx, State::Initial);
return Some(ButtonMsg::LongPressed);
}
@ -370,8 +410,8 @@ impl Component for Button {
fn render<'s>(&self, target: &mut impl Renderer<'s>) {
let style = self.style();
self.render_background(target, style);
self.render_content(target, style);
self.render_background(target, style, 0xFF);
self.render_content(target, style, 0xFF);
}
#[cfg(feature = "ui_bounds")]
@ -605,6 +645,7 @@ impl IconText {
area: Rect,
style: &ButtonStyle,
baseline_offset: Offset,
alpha: u8,
) {
let width = self.text.map(|t| style.font.text_width(t));
@ -635,6 +676,7 @@ impl IconText {
shape::Text::new(text_pos, t)
.with_font(style.font)
.with_fg(style.text_color)
.with_alpha(alpha)
.render(target)
});
}
@ -643,6 +685,7 @@ impl IconText {
shape::ToifImage::new(icon_pos, self.icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.icon_color)
.with_alpha(alpha)
.render(target);
}
}

View File

@ -6,13 +6,13 @@ use crate::{
},
display::Icon,
geometry::{Alignment, Insets, Rect},
model_mercury::theme::TITLE_HEIGHT,
shape::Renderer,
},
};
use super::{theme, Button, ButtonMsg, ButtonStyleSheet, CancelInfoConfirmMsg, Footer};
const TITLE_HEIGHT: i16 = 42;
const BUTTON_EXPAND_BORDER: i16 = 32;
#[derive(Clone)]
@ -24,6 +24,7 @@ pub struct Frame<T> {
button_msg: CancelInfoConfirmMsg,
content: Child<T>,
footer: Option<Footer<'static>>,
overlapping_content: bool,
}
pub enum FrameMsg<T> {
@ -46,6 +47,7 @@ where
button_msg: CancelInfoConfirmMsg::Cancelled,
content: Child::new(content),
footer: None,
overlapping_content: false,
}
}
@ -188,7 +190,11 @@ where
footer.place(footer_area);
content_area = remaining;
}
self.content.place(content_area);
if self.overlapping_content {
self.content.place(bounds);
} else {
self.content.place(content_area);
}
bounds
}
@ -209,15 +215,15 @@ where
self.title.paint();
self.subtitle.paint();
self.button.paint();
self.content.paint();
self.footer.paint();
self.content.paint();
}
fn render<'s>(&self, target: &mut impl Renderer<'s>) {
self.title.render(target);
self.subtitle.render(target);
self.button.render(target);
self.content.render(target);
self.footer.render(target);
self.content.render(target);
}
#[cfg(feature = "ui_bounds")]
@ -225,8 +231,8 @@ where
self.title.bounds(sink);
self.subtitle.bounds(sink);
self.button.bounds(sink);
self.content.bounds(sink);
self.footer.bounds(sink);
self.content.bounds(sink);
}
}

View File

@ -0,0 +1,331 @@
use crate::{
time::Duration,
translations::TR,
ui::{
component::{Component, Event, EventCtx},
display::Color,
geometry::{Alignment2D, Offset, Rect},
lerp::Lerp,
shape,
shape::Renderer,
},
};
use super::{theme, Button, ButtonContent, ButtonMsg};
#[cfg(feature = "haptic")]
use crate::trezorhal::haptic::{self, HapticEffect};
use crate::{
time::Stopwatch,
ui::{
component::Label,
constant::screen,
geometry::{Alignment, Point},
model_mercury::theme::TITLE_HEIGHT,
},
};
use pareen;
#[derive(Default, Clone)]
struct HoldToConfirmAnim {
pub timer: Stopwatch,
}
impl HoldToConfirmAnim {
const DURATION_MS: u32 = 2200;
pub fn is_active(&self) -> bool {
self.timer
.is_running_within(Duration::from_millis(Self::DURATION_MS))
}
pub fn eval(&self) -> f32 {
self.timer.elapsed().to_millis() as f32 / 1000.0
}
pub fn get_parent_cover_opacity(&self, t: f32) -> u8 {
let parent_cover_opacity = pareen::constant(0.0).seq_ease_in_out(
0.0,
easer::functions::Cubic,
0.2,
pareen::constant(1.0),
);
u8::lerp(0, 255, parent_cover_opacity.eval(t))
}
pub fn get_header_opacity(&self, t: f32) -> u8 {
let header_opacity = pareen::constant(0.0).seq_ease_out(
0.1,
easer::functions::Cubic,
0.3,
pareen::constant(1.0),
);
u8::lerp(0, 255, header_opacity.eval(t))
}
pub fn get_header_opacity2(&self, t: f32) -> u8 {
let header_opacity2 = pareen::constant(1.0).seq_ease_in(
2.0,
easer::functions::Cubic,
0.2,
pareen::constant(0.0),
);
u8::lerp(0, 255, header_opacity2.eval(t))
}
pub fn get_circle_opacity(&self, t: f32) -> u8 {
let circle_opacity = pareen::constant(1.0).seq_ease_out(
0.1,
easer::functions::Cubic,
0.1,
pareen::constant(0.0),
);
u8::lerp(0, 255, circle_opacity.eval(t))
}
pub fn get_pad_color(&self, t: f32) -> Color {
let pad_color = pareen::constant(0.0).seq_ease_in_out(
0.1,
easer::functions::Cubic,
1.9,
pareen::constant(1.0),
);
Color::lerp(theme::GREY_EXTRA_DARK, theme::GREEN, pad_color.eval(t))
}
pub fn get_circle_max_height(&self, t: f32) -> i16 {
let circle_max_height = pareen::constant(0.0).seq_ease_in(
0.1,
easer::functions::Cubic,
1.5,
pareen::constant(1.0),
);
i16::lerp(266, 0, circle_max_height.eval(t))
}
pub fn get_circle_radius(&self, t: f32) -> i16 {
let circle_radius = pareen::constant(0.0).seq_ease_in(
1.6,
easer::functions::Cubic,
0.6,
pareen::constant(1.0),
);
i16::lerp(0, 100, circle_radius.eval(t))
}
pub fn get_haptic(&self, t: f32) -> u8 {
let haptic = pareen::constant(0.0).seq_ease_in(
0.0,
easer::functions::Linear,
Self::DURATION_MS as f32 / 1000.0,
pareen::constant(1.0),
);
u8::lerp(0, 20, haptic.eval(t))
}
pub fn start(&mut self) {
self.timer.start();
}
pub fn reset(&mut self) {
self.timer = Stopwatch::new_stopped();
}
}
/// Component requesting a hold to confirm action from a user. Most typically
/// embedded as a content of a Frame.
#[derive(Clone)]
pub struct HoldToConfirm {
title: Label<'static>,
area: Rect,
button: Button,
circle_color: Color,
circle_pad_color: Color,
circle_inner_color: Color,
anim: HoldToConfirmAnim,
}
#[derive(Clone)]
enum DismissType {
Tap,
Hold,
}
impl HoldToConfirm {
pub fn new() -> Self {
let button = Button::new(ButtonContent::Empty)
.styled(theme::button_default())
.with_long_press(Duration::from_millis(2200))
.without_haptics();
Self {
title: Label::new(
TR::instructions__continue_holding.into(),
Alignment::Start,
theme::label_title_main(),
)
.vertically_centered(),
area: Rect::zero(),
circle_color: theme::GREEN,
circle_pad_color: theme::GREY_EXTRA_DARK,
circle_inner_color: theme::GREEN_LIGHT,
button,
anim: HoldToConfirmAnim::default(),
}
}
}
impl Component for HoldToConfirm {
type Msg = ();
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.button.place(Rect::snap(
self.area.center(),
Offset::uniform(80),
Alignment2D::CENTER,
));
self.title.place(screen().split_top(TITLE_HEIGHT).0);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let btn_msg = self.button.event(ctx, event);
match btn_msg {
Some(ButtonMsg::Pressed) => {
self.anim.start();
ctx.request_anim_frame();
ctx.request_paint();
}
Some(ButtonMsg::Released) => {
self.anim.reset();
ctx.request_anim_frame();
ctx.request_paint();
}
Some(ButtonMsg::Clicked) => {
self.anim.reset();
ctx.request_anim_frame();
ctx.request_paint();
}
Some(ButtonMsg::LongPressed) => {
#[cfg(feature = "haptic")]
haptic::play(HapticEffect::HoldToConfirm);
return Some(());
}
_ => (),
}
if self.anim.is_active() {
ctx.request_anim_frame();
ctx.request_paint();
}
None
}
fn paint(&mut self) {
unimplemented!()
}
fn render<'s>(&self, target: &mut impl Renderer<'s>) {
let elapsed = self.anim.eval();
shape::Bar::new(screen())
.with_fg(theme::BLACK)
.with_bg(theme::BLACK)
.with_alpha(self.anim.get_parent_cover_opacity(elapsed))
.render(target);
let center = self.area.center();
const PROGRESS_CIRCLE_RADIUS: i16 = 88;
const PAD_RADIUS: i16 = 70;
const CIRCLE_RADIUS: i16 = 50;
const INNER_CIRCLE_RADIUS: i16 = 40;
const CIRCLE_THICKNESS: i16 = 2;
if self.anim.get_parent_cover_opacity(elapsed) == 255 {
shape::Circle::new(center, PROGRESS_CIRCLE_RADIUS)
.with_fg(self.circle_inner_color)
.with_bg(theme::BLACK)
.with_thickness(CIRCLE_THICKNESS)
.render(target);
shape::Bar::new(Rect::new(
Point::zero(),
Point::new(screen().width(), self.anim.get_circle_max_height(elapsed)),
))
.with_fg(theme::BLACK)
.with_bg(theme::BLACK)
.render(target);
}
let title_alpha = if self.anim.get_header_opacity2(elapsed) < 255 {
self.anim.get_header_opacity2(elapsed)
} else {
self.anim.get_header_opacity(elapsed)
};
self.title.render_with_alpha(target, title_alpha);
let pad_color = self.anim.get_pad_color(elapsed);
let circle_alpha = self.anim.get_circle_opacity(elapsed);
shape::Circle::new(center, PAD_RADIUS)
.with_fg(pad_color)
.with_bg(pad_color)
.render(target);
shape::Circle::new(center, CIRCLE_RADIUS)
.with_fg(self.circle_color)
.with_bg(self.circle_pad_color)
.with_thickness(CIRCLE_THICKNESS)
.with_alpha(circle_alpha)
.render(target);
shape::Circle::new(center, CIRCLE_RADIUS - CIRCLE_THICKNESS)
.with_fg(self.circle_pad_color)
.with_bg(self.circle_pad_color)
.with_thickness(CIRCLE_RADIUS - CIRCLE_THICKNESS - INNER_CIRCLE_RADIUS)
.with_alpha(circle_alpha)
.render(target);
shape::Circle::new(center, INNER_CIRCLE_RADIUS)
.with_fg(self.circle_inner_color)
.with_bg(theme::BLACK)
.with_thickness(CIRCLE_THICKNESS)
.with_alpha(circle_alpha)
.render(target);
shape::ToifImage::new(center, theme::ICON_SIGN.toif)
.with_fg(theme::GREY)
.with_alpha(circle_alpha)
.with_align(Alignment2D::CENTER)
.render(target);
shape::Circle::new(center, self.anim.get_circle_radius(elapsed))
.with_fg(theme::BLACK)
.render(target);
#[cfg(feature = "haptic")]
{
let hap = self.anim.get_haptic(elapsed);
if hap > 0 {
haptic::play_custom(hap as i8, 100);
}
}
}
}
#[cfg(feature = "micropython")]
impl crate::ui::flow::Swipable<()> for HoldToConfirm {}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for HoldToConfirm {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("StatusScreen");
t.child("button", &self.button);
}
}

View File

@ -12,6 +12,9 @@ mod vertical_menu;
mod fido_icons;
mod error;
mod frame;
#[cfg(feature = "translations")]
mod hold_to_confirm;
#[cfg(feature = "micropython")]
mod homescreen;
#[cfg(feature = "translations")]
@ -48,6 +51,8 @@ pub use error::ErrorScreen;
pub use fido::{FidoConfirm, FidoMsg};
pub use footer::Footer;
pub use frame::{Frame, FrameMsg};
#[cfg(feature = "translations")]
pub use hold_to_confirm::HoldToConfirm;
#[cfg(feature = "micropython")]
pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg, Lockscreen};
#[cfg(feature = "translations")]

View File

@ -133,7 +133,8 @@ impl Component for PromptScreen {
.with_thickness(2)
.render(target);
});
self.button.render_content(target, self.button.style());
self.button
.render_content(target, self.button.style(), 0xff);
}
}

View File

@ -1,5 +1,6 @@
use crate::{
error,
micropython::{map::Map, obj::Obj, util},
strutil::TString,
translations::TR,
ui::{
@ -9,11 +10,12 @@ use crate::{
ButtonRequestExt, ComponentExt, SwipeDirection,
},
flow::{base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeFlow, SwipePage},
layout::obj::LayoutObj,
},
};
use super::super::{
component::{Frame, FrameMsg, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg},
component::{Frame, FrameMsg, HoldToConfirm, VerticalMenu, VerticalMenuChoiceMsg},
theme,
};
@ -60,11 +62,6 @@ impl FlowState for ConfirmResetCreate {
}
}
use crate::{
micropython::{map::Map, obj::Obj, util},
ui::layout::obj::LayoutObj,
};
#[allow(clippy::not_unsafe_ptr_arg_deref)]
pub extern "C" fn new_confirm_reset_create(
n_args: usize,
@ -100,16 +97,16 @@ impl ConfirmResetCreate {
FrameMsg::Button(_) => Some(FlowMsg::Cancelled),
});
let content_confirm = Frame::left_aligned(
TR::reset__title_create_wallet.into(),
PromptScreen::new_hold_to_confirm(),
)
.with_footer(TR::instructions__hold_to_confirm.into(), None)
.map(|msg| match msg {
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
_ => Some(FlowMsg::Cancelled),
})
.one_button_request(ButtonRequestCode::ResetDevice.with_type("confirm_setup_device"));
let content_confirm =
Frame::left_aligned(TR::reset__title_create_wallet.into(), HoldToConfirm::new())
.with_footer(TR::instructions__hold_to_confirm.into(), None)
.map(|msg| match msg {
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
_ => Some(FlowMsg::Cancelled),
})
.one_button_request(
ButtonRequestCode::ResetDevice.with_type("confirm_setup_device"),
);
let store = flow_store()
.add(content_intro)?

View File

@ -794,6 +794,7 @@ pub const TEXT_CHECKLIST_DONE: TextStyle = TextStyle::new(Font::SUB, GREY, BG, G
/// the header. [px]
pub const SPACING: i16 = 2;
pub const TITLE_HEIGHT: i16 = 42;
pub const CONTENT_BORDER: i16 = 0;
pub const BUTTON_HEIGHT: i16 = 62;
pub const BUTTON_WIDTH: i16 = 78;

View File

@ -388,6 +388,8 @@
"instructions__swipe_up": "Swipe up",
"instructions__tap_to_confirm": "Tap to confirm",
"instructions__tap_to_start": "Tap to start",
"instructions__hold_to_confirm": "Hold to confirm",
"instructions__continue_holding": "Continue\nholding",
"joint__title": "Joint transaction",
"joint__to_the_total_amount": "To the total amount:",
"joint__you_are_contributing": "You are contributing:",