mirror of
https://github.com/trezor/trezor-firmware.git
synced 2025-07-18 12:38:37 +00:00
feat(core/rust): hold to confirm animation for Model R
This commit is contained in:
parent
ecd6099ca4
commit
d49e78d577
1
core/.changelog.d/2276.added
Normal file
1
core/.changelog.d/2276.added
Normal file
@ -0,0 +1 @@
|
|||||||
|
Hold to confirm animation on Model R
|
@ -115,6 +115,140 @@ pub fn rect_fill_rounded1(r: Rect, fg_color: Color, bg_color: Color) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TextOverlay {
|
||||||
|
colortable: [Color; 16],
|
||||||
|
area: Rect,
|
||||||
|
text: &'static str,
|
||||||
|
font: Font,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextOverlay {
|
||||||
|
pub fn new(bg_color: Color, fg_color: Color, text: &'static str, font: Font) -> Self {
|
||||||
|
let area = Rect::zero();
|
||||||
|
Self {
|
||||||
|
colortable: get_color_table(fg_color, bg_color),
|
||||||
|
area,
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseline relative to the underlying render area
|
||||||
|
pub fn place(&mut self, baseline: Offset) {
|
||||||
|
let text_width = self.font.text_width(self.text);
|
||||||
|
let text_height = self.font.text_height();
|
||||||
|
|
||||||
|
let bl_left = baseline - Offset::x(text_width / 2);
|
||||||
|
let text_area_start = Point::new(0, -text_height) + bl_left;
|
||||||
|
let text_area_end = Point::new(text_width, 0) + bl_left;
|
||||||
|
let area = Rect::new(text_area_start, text_area_end);
|
||||||
|
|
||||||
|
self.area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pixel(&self, underlying: Option<Color>, x: i32, y: i32) -> Option<Color> {
|
||||||
|
let mut overlay_color = None;
|
||||||
|
|
||||||
|
if x >= self.area.x0 && x < self.area.x1 && y >= self.area.y0 && y < self.area.y1 {
|
||||||
|
let mut tot_adv = 0;
|
||||||
|
let x_t = x - self.area.x0;
|
||||||
|
let y_t = y - self.area.y0;
|
||||||
|
|
||||||
|
for c in self.text.chars() {
|
||||||
|
if let Some(g) = self.font.get_glyph(c) {
|
||||||
|
let w = g.get_width();
|
||||||
|
let h = g.get_height();
|
||||||
|
let b_x = g.get_bearing_x();
|
||||||
|
let b_y = g.get_bearing_y();
|
||||||
|
|
||||||
|
if x_t >= (tot_adv + b_x)
|
||||||
|
&& x_t < (tot_adv + b_x + w)
|
||||||
|
&& y_t >= (h - b_y)
|
||||||
|
&& y_t <= (b_y)
|
||||||
|
{
|
||||||
|
//position is for this char
|
||||||
|
let overlay_data = g.get_pixel_data(x_t - tot_adv - b_x, y_t - (h - b_y));
|
||||||
|
|
||||||
|
if overlay_data > 0 {
|
||||||
|
if let Some(u) = underlying {
|
||||||
|
overlay_color = Some(interpolate_colors(
|
||||||
|
self.colortable[15],
|
||||||
|
u,
|
||||||
|
overlay_data as u16,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
overlay_color = Some(self.colortable[overlay_data as usize]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tot_adv += g.get_advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay_color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_with_text_and_fill(
|
||||||
|
r: Rect,
|
||||||
|
overlay: Option<TextOverlay>,
|
||||||
|
fg_color: Color,
|
||||||
|
bg_color: Color,
|
||||||
|
fill_from: i32,
|
||||||
|
fill_to: i32,
|
||||||
|
) {
|
||||||
|
let clamped = clamp_coords(r.top_left(), r.size());
|
||||||
|
|
||||||
|
set_window(clamped);
|
||||||
|
|
||||||
|
for y_c in clamped.y0..clamped.y1 {
|
||||||
|
for x_c in clamped.x0..clamped.x1 {
|
||||||
|
let y = y_c - r.y0;
|
||||||
|
let x = x_c - r.x0;
|
||||||
|
|
||||||
|
let filled =
|
||||||
|
(x >= fill_from && fill_from >= 0 && (x <= fill_to || fill_to < fill_from))
|
||||||
|
|| (x < fill_to && fill_to >= 0);
|
||||||
|
|
||||||
|
let border = x == 0 || x == (r.width() - 1) || y == 0 || y == (r.height() - 1);
|
||||||
|
|
||||||
|
let corner = (y == r.height() - 2 || y == 1) && x == 1
|
||||||
|
|| (x == r.width() - 2 && y == 1)
|
||||||
|
|| (x == r.width() - 2 && y == r.height() - 2);
|
||||||
|
|
||||||
|
let corner_out = !corner
|
||||||
|
&& (((y > r.height() - 3 || y < 2) && x < 2)
|
||||||
|
|| ((x > r.width() - 3) && y < 2)
|
||||||
|
|| (x > r.width() - 3 && y > r.height() - 3));
|
||||||
|
|
||||||
|
let underlying_color = if (border || corner || filled) && !corner_out {
|
||||||
|
fg_color
|
||||||
|
} else {
|
||||||
|
bg_color
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut overlay_color = None;
|
||||||
|
if let Some(o) = overlay {
|
||||||
|
overlay_color = o.get_pixel(None, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut final_color = underlying_color;
|
||||||
|
|
||||||
|
if let Some(overlay) = overlay_color {
|
||||||
|
if overlay == fg_color {
|
||||||
|
final_color = underlying_color.negate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pixeldata(final_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pixeldata_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
// Used on T1 only.
|
// Used on T1 only.
|
||||||
pub fn dotted_line(start: Point, width: i32, color: Color) {
|
pub fn dotted_line(start: Point, width: i32, color: Color) {
|
||||||
for x in (start.x..width).step_by(2) {
|
for x in (start.x..width).step_by(2) {
|
||||||
|
90
core/embed/rust/src/ui/model_tr/component/confirm.rs
Normal file
90
core/embed/rust/src/ui/model_tr/component/confirm.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
use crate::{
|
||||||
|
time::Instant,
|
||||||
|
ui::{
|
||||||
|
component::{Component, Event, EventCtx},
|
||||||
|
event::ButtonEvent,
|
||||||
|
geometry::{Point, Rect},
|
||||||
|
model_tr::component::{loader::Loader, ButtonPos, LoaderMsg, LoaderStyleSheet},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub enum HoldToConfirmMsg {
|
||||||
|
Confirmed,
|
||||||
|
FailedToConfirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HoldToConfirm {
|
||||||
|
area: Rect,
|
||||||
|
pos: ButtonPos,
|
||||||
|
loader: Loader,
|
||||||
|
baseline: Point,
|
||||||
|
text_width: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HoldToConfirm {
|
||||||
|
pub fn new(pos: ButtonPos, text: &'static str, styles: LoaderStyleSheet) -> Self {
|
||||||
|
let text_width = styles.normal.font.text_width(text.as_ref());
|
||||||
|
Self {
|
||||||
|
area: Rect::zero(),
|
||||||
|
pos,
|
||||||
|
loader: Loader::new(text, styles),
|
||||||
|
baseline: Point::zero(),
|
||||||
|
text_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placement(&mut self, area: Rect, pos: ButtonPos) -> Rect {
|
||||||
|
let button_width = self.text_width + 7;
|
||||||
|
match pos {
|
||||||
|
ButtonPos::Left => area.split_left(button_width).0,
|
||||||
|
ButtonPos::Right => area.split_right(button_width).1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for HoldToConfirm {
|
||||||
|
type Msg = HoldToConfirmMsg;
|
||||||
|
|
||||||
|
fn place(&mut self, bounds: Rect) -> Rect {
|
||||||
|
let loader_area = self.placement(bounds, self.pos);
|
||||||
|
self.loader.place(loader_area)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||||
|
match event {
|
||||||
|
Event::Button(ButtonEvent::ButtonPressed(which)) if self.pos.hit(&which) => {
|
||||||
|
self.loader.start_growing(ctx, Instant::now());
|
||||||
|
}
|
||||||
|
Event::Button(ButtonEvent::ButtonReleased(which)) if self.pos.hit(&which) => {
|
||||||
|
if self.loader.is_animating() {
|
||||||
|
self.loader.start_shrinking(ctx, Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = self.loader.event(ctx, event);
|
||||||
|
|
||||||
|
if let Some(LoaderMsg::GrownCompletely) = msg {
|
||||||
|
return Some(HoldToConfirmMsg::Confirmed);
|
||||||
|
}
|
||||||
|
if let Some(LoaderMsg::ShrunkCompletely) = msg {
|
||||||
|
return Some(HoldToConfirmMsg::FailedToConfirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self) {
|
||||||
|
self.loader.paint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ui_debug")]
|
||||||
|
impl crate::trace::Trace for HoldToConfirm {
|
||||||
|
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
|
||||||
|
d.open("HoldToConfirm");
|
||||||
|
self.loader.trace(d);
|
||||||
|
d.close();
|
||||||
|
}
|
||||||
|
}
|
206
core/embed/rust/src/ui/model_tr/component/loader.rs
Normal file
206
core/embed/rust/src/ui/model_tr/component/loader.rs
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
use crate::{
|
||||||
|
time::{Duration, Instant},
|
||||||
|
ui::{
|
||||||
|
animation::Animation,
|
||||||
|
component::{Component, Event, EventCtx},
|
||||||
|
display::{self, Color, Font},
|
||||||
|
geometry::{Offset, Rect},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub enum LoaderMsg {
|
||||||
|
GrownCompletely,
|
||||||
|
ShrunkCompletely,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
Initial,
|
||||||
|
Growing(Animation<u16>),
|
||||||
|
Shrinking(Animation<u16>),
|
||||||
|
Grown,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Loader {
|
||||||
|
area: Rect,
|
||||||
|
state: State,
|
||||||
|
growing_duration: Duration,
|
||||||
|
shrinking_duration: Duration,
|
||||||
|
text: display::TextOverlay,
|
||||||
|
styles: LoaderStyleSheet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader {
|
||||||
|
pub const SIZE: Offset = Offset::new(120, 120);
|
||||||
|
|
||||||
|
pub fn new(text: &'static str, styles: LoaderStyleSheet) -> Self {
|
||||||
|
let overlay = display::TextOverlay::new(
|
||||||
|
styles.normal.bg_color,
|
||||||
|
styles.normal.fg_color,
|
||||||
|
text,
|
||||||
|
styles.normal.font,
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
area: Rect::zero(),
|
||||||
|
state: State::Initial,
|
||||||
|
growing_duration: Duration::from_millis(1000),
|
||||||
|
shrinking_duration: Duration::from_millis(500),
|
||||||
|
text: overlay,
|
||||||
|
styles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_growing(&mut self, ctx: &mut EventCtx, now: Instant) {
|
||||||
|
let mut anim = Animation::new(
|
||||||
|
display::LOADER_MIN,
|
||||||
|
display::LOADER_MAX,
|
||||||
|
self.growing_duration,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if let State::Shrinking(shrinking) = &self.state {
|
||||||
|
anim.seek_to_value(shrinking.value(now));
|
||||||
|
}
|
||||||
|
self.state = State::Growing(anim);
|
||||||
|
|
||||||
|
// The animation is starting, request an animation frame event.
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
|
||||||
|
// We don't have to wait for the animation frame event with the first paint,
|
||||||
|
// let's do that now.
|
||||||
|
ctx.request_paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_shrinking(&mut self, ctx: &mut EventCtx, now: Instant) {
|
||||||
|
let mut anim = Animation::new(
|
||||||
|
display::LOADER_MAX,
|
||||||
|
display::LOADER_MIN,
|
||||||
|
self.shrinking_duration,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if let State::Growing(growing) = &self.state {
|
||||||
|
anim.seek_to_value(display::LOADER_MAX - growing.value(now));
|
||||||
|
}
|
||||||
|
self.state = State::Shrinking(anim);
|
||||||
|
|
||||||
|
// The animation should be already progressing at this point, so we don't need
|
||||||
|
// to request another animation frames, but we should request to get painted
|
||||||
|
// after this event pass.
|
||||||
|
ctx.request_paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.state = State::Initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn animation(&self) -> Option<&Animation<u16>> {
|
||||||
|
match &self.state {
|
||||||
|
State::Initial => None,
|
||||||
|
State::Grown => None,
|
||||||
|
State::Growing(a) | State::Shrinking(a) => Some(a),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self, now: Instant) -> Option<u16> {
|
||||||
|
self.animation().map(|a| a.value(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_animating(&self) -> bool {
|
||||||
|
self.animation().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_completely_grown(&self, now: Instant) -> bool {
|
||||||
|
//TODO consider fixing overflow in animation?
|
||||||
|
self.progress(now).unwrap() >= display::LOADER_MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_completely_shrunk(&self, now: Instant) -> bool {
|
||||||
|
matches!(self.progress(now), Some(display::LOADER_MIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paint_loader(&mut self, style: &LoaderStyle, done: i32) {
|
||||||
|
let invert_from = ((self.area.width() + 1) * done) / (display::LOADER_MAX as i32);
|
||||||
|
|
||||||
|
display::bar_with_text_and_fill(
|
||||||
|
self.area,
|
||||||
|
Some(self.text),
|
||||||
|
style.fg_color,
|
||||||
|
style.bg_color,
|
||||||
|
-1,
|
||||||
|
invert_from,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for Loader {
|
||||||
|
type Msg = LoaderMsg;
|
||||||
|
|
||||||
|
fn place(&mut self, bounds: Rect) -> Rect {
|
||||||
|
self.area = bounds;
|
||||||
|
let baseline = Offset::new(bounds.width() / 2 + 1, bounds.height() - 1);
|
||||||
|
self.text.place(baseline);
|
||||||
|
self.area
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
|
||||||
|
if self.is_animating() {
|
||||||
|
// We have something to paint, so request to be painted in the next pass.
|
||||||
|
ctx.request_paint();
|
||||||
|
|
||||||
|
if self.is_completely_grown(now) {
|
||||||
|
self.state = State::Grown;
|
||||||
|
return Some(LoaderMsg::GrownCompletely);
|
||||||
|
} else if self.is_completely_shrunk(now) {
|
||||||
|
self.state = State::Initial;
|
||||||
|
return Some(LoaderMsg::ShrunkCompletely);
|
||||||
|
} else {
|
||||||
|
// There is further progress in the animation, request an animation frame event.
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self) {
|
||||||
|
// TODO: Consider passing the current instant along with the event -- that way,
|
||||||
|
// we could synchronize painting across the component tree. Also could be useful
|
||||||
|
// in automated tests.
|
||||||
|
// In practice, taking the current instant here is more precise in case some
|
||||||
|
// other component in the tree takes a long time to draw.
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
if let State::Initial = self.state {
|
||||||
|
self.paint_loader(self.styles.normal, 0);
|
||||||
|
} else if let State::Grown = self.state {
|
||||||
|
self.paint_loader(self.styles.normal, display::LOADER_MAX as i32);
|
||||||
|
} else {
|
||||||
|
let progress = self.progress(now);
|
||||||
|
if let Some(done) = progress {
|
||||||
|
self.paint_loader(self.styles.normal, done as i32);
|
||||||
|
} else {
|
||||||
|
self.paint_loader(self.styles.normal, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoaderStyleSheet {
|
||||||
|
pub normal: &'static LoaderStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoaderStyle {
|
||||||
|
pub font: Font,
|
||||||
|
pub fg_color: Color,
|
||||||
|
pub bg_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ui_debug")]
|
||||||
|
impl crate::trace::Trace for Loader {
|
||||||
|
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
|
||||||
|
d.open("Loader");
|
||||||
|
d.close();
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,15 @@
|
|||||||
mod button;
|
mod button;
|
||||||
|
mod confirm;
|
||||||
mod dialog;
|
mod dialog;
|
||||||
mod frame;
|
mod frame;
|
||||||
|
mod loader;
|
||||||
mod page;
|
mod page;
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
|
|
||||||
pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet};
|
pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet};
|
||||||
|
pub use confirm::{HoldToConfirm, HoldToConfirmMsg};
|
||||||
pub use dialog::{Dialog, DialogMsg};
|
pub use dialog::{Dialog, DialogMsg};
|
||||||
pub use frame::Frame;
|
pub use frame::Frame;
|
||||||
|
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
|
||||||
pub use page::ButtonPage;
|
pub use page::ButtonPage;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
component::text::layout::DefaultTextTheme,
|
component::text::layout::DefaultTextTheme,
|
||||||
display::{Color, Font},
|
display::{Color, Font},
|
||||||
|
model_tr::component::{LoaderStyle, LoaderStyleSheet},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::component::{ButtonStyle, ButtonStyleSheet};
|
use super::component::{ButtonStyle, ButtonStyleSheet};
|
||||||
@ -48,6 +49,16 @@ pub fn button_cancel() -> ButtonStyleSheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn loader_default() -> LoaderStyleSheet {
|
||||||
|
LoaderStyleSheet {
|
||||||
|
normal: &LoaderStyle {
|
||||||
|
font: FONT_NORMAL,
|
||||||
|
fg_color: FG,
|
||||||
|
bg_color: BG,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TRDefaultText;
|
pub struct TRDefaultText;
|
||||||
|
|
||||||
impl DefaultTextTheme for TRDefaultText {
|
impl DefaultTextTheme for TRDefaultText {
|
||||||
|
Loading…
Reference in New Issue
Block a user