From 68598f00afbda0c04a40a60a66f3d14b4d40cd10 Mon Sep 17 00:00:00 2001 From: tychovrahe Date: Thu, 2 Jun 2022 21:59:04 +0200 Subject: [PATCH] feat(core/rust): hold to confirm animation for Model R --- core/embed/rust/src/ui/display.rs | 132 ++++++++++++ .../rust/src/ui/model_tr/component/confirm.rs | 90 ++++++++ .../rust/src/ui/model_tr/component/loader.rs | 200 ++++++++++++++++++ .../rust/src/ui/model_tr/component/mod.rs | 4 + core/embed/rust/src/ui/model_tr/theme.rs | 11 + 5 files changed, 437 insertions(+) create mode 100644 core/embed/rust/src/ui/model_tr/component/confirm.rs create mode 100644 core/embed/rust/src/ui/model_tr/component/loader.rs diff --git a/core/embed/rust/src/ui/display.rs b/core/embed/rust/src/ui/display.rs index d6cb41569..1d3d121cb 100644 --- a/core/embed/rust/src/ui/display.rs +++ b/core/embed/rust/src/ui/display.rs @@ -136,6 +136,138 @@ pub fn rect_fill_rounded1(r: Rect, fg_color: Color, bg_color: Color) { } } +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct TextOverlay<'a> { + area: Rect, + text: &'a str, + font: Font, +} + +impl<'a> TextOverlay<'a> { + pub fn new(text: &'a str, font: Font) -> Self { + let area = Rect::zero(); + Self { area, text, font } + } + + pub fn place(&mut self, baseline: Point) { + let text_width = self.font.text_width(self.text); + let text_height = self.font.text_height(); + + let text_area_start = baseline + Offset::new(-(text_width / 2), -text_height); + let text_area_end = baseline + Offset::new(text_width / 2, 0); + let area = Rect::new(text_area_start, text_area_end); + + self.area = area; + } + + pub fn get_pixel(&self, underlying: Color, fg: Color, p: Point) -> Color { + if !self.area.contains(p) { + return underlying; + } + + let mut tot_adv = 0; + + let p_rel = Point::new(p.x - self.area.x0, p.y - self.area.y0); + + for g in self.text.bytes().filter_map(|c| self.font.get_glyph(c)) { + let char_area = Rect::new( + Point::new(tot_adv + g.bearing_x, g.height - g.bearing_y), + Point::new(tot_adv + g.bearing_x + g.width, g.bearing_y), + ); + + tot_adv += g.adv; + + if !char_area.contains(p_rel) { + continue; + } + + let p_inner = p_rel - char_area.top_left(); + let overlay_data = g.get_pixel_data(p_inner); + return Color::lerp(underlying, fg, overlay_data as f32 / 15_f32); + } + + underlying + } +} + +/// Gets a color of a pixel on `p` coordinates of rounded rectangle with corner +/// radius 2 +fn rect_rounded2_get_pixel( + p: Offset, + size: Offset, + colortable: [Color; 16], + fill: bool, + line_width: i32, +) -> Color { + let border = (p.x >= 0 && p.x < line_width) + || ((p.x >= size.x - line_width) && p.x <= (size.x - 1)) + || (p.y >= 0 && p.y < line_width) + || ((p.y >= size.y - line_width) && p.y <= (size.y - 1)); + + let corner_lim = 2 * line_width; + let corner_inner = line_width; + + let corner_all = ((p.x > size.x - (corner_lim + 1)) || p.x < corner_lim) + && (p.y < corner_lim || p.y > size.y - (corner_lim + 1)); + + let corner = corner_all + && (p.y >= corner_inner) + && (p.x >= corner_inner) + && (p.y <= size.y - (corner_inner + 1)) + && (p.x <= size.x - (corner_inner + 1)); + + let corner_out = corner_all && !corner; + + if (border || corner || fill) && !corner_out { + colortable[15] + } else { + colortable[0] + } +} + +/// Draws a rounded rectangle with corner radius 2, partially filled +/// according to `fill_from` and `fill_to` arguments. +/// Optionally draws a text inside the rectangle and adjusts its color to match +/// the fill. The coordinates of the text are specified in the TextOverlay +/// struct. +pub fn bar_with_text_and_fill( + area: Rect, + overlay: Option, + fg_color: Color, + bg_color: Color, + fill_from: i32, + fill_to: i32, +) { + let r = area.translate(get_offset()); + let clamped = r.clamp(constant::screen()); + let colortable = get_color_table(fg_color, bg_color); + + set_window(clamped); + + for y_c in clamped.y0..clamped.y1 { + for x_c in clamped.x0..clamped.x1 { + let p = Point::new(x_c, y_c); + let r_offset = p - r.top_left(); + + let filled = (r_offset.x >= fill_from + && fill_from >= 0 + && (r_offset.x <= fill_to || fill_to < fill_from)) + || (r_offset.x < fill_to && fill_to >= 0); + + let underlying_color = + rect_rounded2_get_pixel(r_offset, r.size(), colortable, filled, 1); + + let final_color = overlay.map_or(underlying_color, |o| { + let text_color = if filled { bg_color } else { fg_color }; + o.get_pixel(underlying_color, text_color, p) + }); + + 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 000000000..5bace0522 --- /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 000000000..c8628c670 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/loader.rs @@ -0,0 +1,200 @@ +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<'static>, + 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(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 { + matches!(self.progress(now), Some(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 = bounds.bottom_center() + Offset::new(1, -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 dc8c10edb..668241870 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 333160913..496f5d6cc 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 {