From cc2bfd9c39da0e2f90cdf3d845b47405306a6dcc Mon Sep 17 00:00:00 2001 From: tychovrahe Date: Thu, 16 Jun 2022 11:40:41 +0200 Subject: [PATCH] feat(core/rust): autoclosing popup for Model R with success animation --- core/embed/rust/src/ui/geometry.rs | 4 + .../rust/src/ui/model_tr/component/mod.rs | 4 + .../src/ui/model_tr/component/result_anim.rs | 149 ++++++++++++++ .../src/ui/model_tr/component/result_popup.rs | 193 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 core/embed/rust/src/ui/model_tr/component/result_anim.rs create mode 100644 core/embed/rust/src/ui/model_tr/component/result_popup.rs diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index 4882ad899..8062d6669 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -376,6 +376,10 @@ impl Insets { pub const fn left(d: i32) -> Self { Self::new(0, 0, 0, d) } + + pub const fn sides(d: i32) -> Self { + Self::new(0, d, 0, d) + } } #[derive(Copy, Clone, PartialEq, Eq)] 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 668241870..8a29709ad 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -4,6 +4,8 @@ mod dialog; mod frame; mod loader; mod page; +mod result_anim; +mod result_popup; use super::theme; @@ -13,3 +15,5 @@ pub use dialog::{Dialog, DialogMsg}; pub use frame::Frame; pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; pub use page::ButtonPage; +pub use result_anim::{ResultAnim, ResultAnimMsg}; +pub use result_popup::{ResultPopup, ResultPopupMsg}; diff --git a/core/embed/rust/src/ui/model_tr/component/result_anim.rs b/core/embed/rust/src/ui/model_tr/component/result_anim.rs new file mode 100644 index 000000000..33a1104b8 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/result_anim.rs @@ -0,0 +1,149 @@ +use crate::{ + time::{Duration, Instant}, + ui::{ + animation::Animation, + component::{Component, Event, EventCtx}, + display, + geometry::Rect, + model_tr::theme, + }, +}; + +pub enum ResultAnimMsg { + FullyGrown, +} + +enum State { + Initial, + Growing(Animation), + Grown, +} + +pub struct ResultAnim { + area: Rect, + state: State, + growing_duration: Duration, + icon: &'static [u8], +} + +impl ResultAnim { + pub fn new(icon: &'static [u8]) -> Self { + Self { + area: Rect::zero(), + state: State::Initial, + growing_duration: Duration::from_millis(2000), + icon, + } + } + + pub fn start_growing(&mut self, ctx: &mut EventCtx, now: Instant) { + let anim = Animation::new( + display::LOADER_MIN, + display::LOADER_MAX, + self.growing_duration, + 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 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) => 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 paint_anim(&mut self, done: i32) { + display::rect_rounded2_partial( + self.area, + theme::FG, + theme::BG, + 100 * done / 1000, + Some((self.icon, theme::FG)), + ); + } +} + +impl Component for ResultAnim { + type Msg = ResultAnimMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + 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 let State::Growing(_) = self.state { + // 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(ResultAnimMsg::FullyGrown); + } 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_anim(0); + } else if let State::Grown = self.state { + self.paint_anim(display::LOADER_MAX as i32); + } else { + let progress = self.progress(now); + if let Some(done) = progress { + self.paint_anim(done as i32); + } else { + self.paint_anim(0); + } + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ResultAnim { + fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.open("ResultAnim"); + d.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/result_popup.rs b/core/embed/rust/src/ui/model_tr/component/result_popup.rs new file mode 100644 index 000000000..eabcd77d5 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/result_popup.rs @@ -0,0 +1,193 @@ +use crate::{ + time::Instant, + ui::{ + component::{ + text::{layout::DefaultTextTheme, paragraphs::Paragraphs}, + Child, Component, ComponentExt, Event, EventCtx, Label, LabelStyle, Pad, + }, + constant::screen, + display::{Color, Font}, + geometry::{Alignment, Insets, LinearPlacement, Point, Rect}, + model_tr::{ + component::{Button, ButtonMsg, ButtonPos, ResultAnim, ResultAnimMsg}, + theme, + theme::{TRDefaultText, FONT_BOLD, FONT_MEDIUM}, + }, + }, +}; + +pub enum ResultPopupMsg { + Confirmed, +} + +pub struct ResultPopup { + area: Rect, + pad: Pad, + result_anim: Child, + headline_baseline: Point, + headline: Option>, + text: Child>, + button: Option>>, + autoclose: bool, +} + +pub struct MessageText; + +impl DefaultTextTheme for MessageText { + const BACKGROUND_COLOR: Color = theme::BG; + const TEXT_FONT: Font = FONT_MEDIUM; + const TEXT_COLOR: Color = theme::FG; + const HYPHEN_FONT: Font = FONT_MEDIUM; + const HYPHEN_COLOR: Color = theme::FG; + const ELLIPSIS_FONT: Font = FONT_MEDIUM; + const ELLIPSIS_COLOR: Color = theme::FG; + + const NORMAL_FONT: Font = FONT_MEDIUM; + const MEDIUM_FONT: Font = theme::FONT_MEDIUM; + const BOLD_FONT: Font = theme::FONT_BOLD; + const MONO_FONT: Font = theme::FONT_MONO; +} + +const ANIM_SIZE: i32 = 18; +const BUTTON_HEIGHT: i32 = 13; +const ANIM_SPACE: i32 = 11; +const ANIM_POS: i32 = 32; +const ANIM_POS_ADJ_HEADLINE: i32 = 10; +const ANIM_POS_ADJ_BUTTON: i32 = 6; + +impl ResultPopup { + pub fn new( + icon: &'static [u8], + text: &'static str, + headline: Option<&'static str>, + button_text: Option<&'static str>, + ) -> Self { + let p1 = Paragraphs::new() + .add::(FONT_MEDIUM, text) + .with_placement(LinearPlacement::vertical().align_at_center()); + + let button = button_text.map(|t| { + Child::new(Button::with_text( + ButtonPos::Right, + t, + theme::button_default(), + )) + }); + + let headline_style = LabelStyle { + background_color: theme::BG, + text_color: theme::FG, + font: FONT_BOLD, + }; + + let mut pad = Pad::with_background(theme::BG); + pad.clear(); + + Self { + area: Rect::zero(), + pad, + result_anim: Child::new(ResultAnim::new(icon)), + headline: headline.map(|a| Label::new(a, Alignment::Center, headline_style)), + headline_baseline: Point::zero(), + text: Child::new(p1), + button, + autoclose: false, + } + } + + // autoclose even if button is used + pub fn autoclose(&mut self) { + self.autoclose = true; + } + + pub fn start(&mut self, ctx: &mut EventCtx) { + self.text.request_complete_repaint(ctx); + self.headline.request_complete_repaint(ctx); + self.button.request_complete_repaint(ctx); + self.result_anim.mutate(ctx, |ctx, c| { + let now = Instant::now(); + c.start_growing(ctx, now); + }); + ctx.request_paint(); + } +} + +impl Component for ResultPopup { + type Msg = ResultPopupMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + + let anim_margins = (screen().width() - ANIM_SIZE) / 2; + let mut anim_adjust = 0; + let mut headline_height = 0; + let mut button_height = 0; + + if let Some(h) = self.headline.as_mut() { + headline_height = h.size().y; + anim_adjust += ANIM_POS_ADJ_HEADLINE; + } + if self.button.is_some() { + button_height = BUTTON_HEIGHT; + anim_adjust += ANIM_POS_ADJ_BUTTON; + } + + let (_, rest) = bounds.split_top(ANIM_POS - anim_adjust); + let (anim, rest) = rest.split_top(ANIM_SIZE); + let (_, rest) = rest.split_top(ANIM_SPACE); + let (headline, rest) = rest.split_top(headline_height); + let (text, buttons) = rest.split_bottom(button_height); + + self.pad.place(bounds); + self.button.place(buttons); + self.headline.place(headline); + self.text.place(text); + self.result_anim + .place(anim.inset(Insets::sides(anim_margins))); + + self.area + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let mut button_confirmed = false; + + self.text.event(ctx, event); + self.headline.event(ctx, event); + + if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) { + button_confirmed = true; + } + + if let Some(ResultAnimMsg::FullyGrown) = self.result_anim.event(ctx, event) { + if self.button.is_none() || self.autoclose { + return Some(ResultPopupMsg::Confirmed); + } + } + + if button_confirmed { + return Some(ResultPopupMsg::Confirmed); + } + + None + } + + fn paint(&mut self) { + self.pad.paint(); + self.text.paint(); + self.button.paint(); + self.headline.paint(); + self.result_anim.paint(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ResultPopup { + fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.open("ResultPopup"); + self.text.trace(d); + self.button.trace(d); + self.headline.trace(d); + self.result_anim.trace(d); + d.close(); + } +}