diff --git a/core/.changelog.d/2276.added b/core/.changelog.d/2276.added new file mode 100644 index 0000000000..998ad7531a --- /dev/null +++ b/core/.changelog.d/2276.added @@ -0,0 +1 @@ +Hold to confirm animation on Model R diff --git a/core/embed/rust/src/ui/display.rs b/core/embed/rust/src/ui/display.rs index eb36e33669..8f4b18d52e 100644 --- a/core/embed/rust/src/ui/display.rs +++ b/core/embed/rust/src/ui/display.rs @@ -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, x: i32, y: i32) -> Option { + 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, + 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. pub fn dotted_line(start: Point, width: i32, color: Color) { for x in (start.x..width).step_by(2) { diff --git a/core/embed/rust/src/ui/model_tr/component/confirm.rs b/core/embed/rust/src/ui/model_tr/component/confirm.rs new file mode 100644 index 0000000000..5bace05222 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/confirm.rs @@ -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 { + 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(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/loader.rs b/core/embed/rust/src/ui/model_tr/component/loader.rs new file mode 100644 index 0000000000..07419198a6 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/loader.rs @@ -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), + Shrinking(Animation), + 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> { + match &self.state { + State::Initial => None, + State::Grown => None, + State::Growing(a) | State::Shrinking(a) => Some(a), + } + } + + pub fn progress(&self, now: Instant) -> Option { + 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 { + 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(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/mod.rs b/core/embed/rust/src/ui/model_tr/component/mod.rs index dc8c10edb5..6682418706 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -1,11 +1,15 @@ mod button; +mod confirm; mod dialog; mod frame; +mod loader; mod page; use super::theme; pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet}; +pub use confirm::{HoldToConfirm, HoldToConfirmMsg}; pub use dialog::{Dialog, DialogMsg}; pub use frame::Frame; +pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; pub use page::ButtonPage; diff --git a/core/embed/rust/src/ui/model_tr/theme.rs b/core/embed/rust/src/ui/model_tr/theme.rs index 333160913a..496f5d6cce 100644 --- a/core/embed/rust/src/ui/model_tr/theme.rs +++ b/core/embed/rust/src/ui/model_tr/theme.rs @@ -1,6 +1,7 @@ use crate::ui::{ component::text::layout::DefaultTextTheme, display::{Color, Font}, + model_tr::component::{LoaderStyle, LoaderStyleSheet}, }; 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; impl DefaultTextTheme for TRDefaultText {