diff --git a/core/embed/rust/src/ui/animation.rs b/core/embed/rust/src/ui/animation.rs index 5f21404fc..df1c08d5f 100644 --- a/core/embed/rust/src/ui/animation.rs +++ b/core/embed/rust/src/ui/animation.rs @@ -33,7 +33,7 @@ impl Animation { T: Lerp, { let factor = self.elapsed(now) / self.duration; - T::lerp(self.from, self.to, factor) + T::lerp_bounded(self.from, self.to, factor) } /// Seek the animation such that `value` would be the current value. diff --git a/core/embed/rust/src/ui/model_tt/component/button.rs b/core/embed/rust/src/ui/model_tt/component/button.rs index 4a0275437..772e61397 100644 --- a/core/embed/rust/src/ui/model_tt/component/button.rs +++ b/core/embed/rust/src/ui/model_tt/component/button.rs @@ -351,16 +351,15 @@ impl Button { F1: Fn(ButtonMsg) -> Option, T: AsRef, { - const BUTTON_SPACING: i32 = 6; ( GridPlaced::new(left) .with_grid(1, 3) - .with_spacing(BUTTON_SPACING) + .with_spacing(theme::BUTTON_SPACING) .with_row_col(0, 0) .map(left_map), GridPlaced::new(right) .with_grid(1, 3) - .with_spacing(BUTTON_SPACING) + .with_spacing(theme::BUTTON_SPACING) .with_from_to((0, 1), (0, 2)) .map(right_map), ) diff --git a/core/embed/rust/src/ui/model_tt/component/confirm.rs b/core/embed/rust/src/ui/model_tt/component/confirm.rs deleted file mode 100644 index 623e710c9..000000000 --- a/core/embed/rust/src/ui/model_tt/component/confirm.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::{ - time::Instant, - ui::{ - component::{Child, Component, ComponentExt, Event, EventCtx, Pad}, - geometry::Rect, - model_tt::component::DialogLayout, - }, -}; - -use super::{theme, Button, ButtonMsg, Loader, LoaderMsg}; - -pub enum HoldToConfirmMsg { - Content(T), - Confirmed, - Cancelled, -} - -pub struct HoldToConfirm { - loader: Loader, - content: Child, - cancel: Child>, - confirm: Child>, - pad: Pad, -} - -impl HoldToConfirm -where - T: Component, -{ - pub fn new(content: T) -> Self { - Self { - loader: Loader::new(0), - content: Child::new(content), - cancel: Child::new(Button::with_text("Cancel")), - confirm: Child::new(Button::with_text("Hold")), - pad: Pad::with_background(theme::BG), - } - } - - pub fn inner(&self) -> &T { - self.content.inner() - } -} - -impl Component for HoldToConfirm -where - T: Component, -{ - type Msg = HoldToConfirmMsg; - - fn place(&mut self, bounds: Rect) -> Rect { - let layout = DialogLayout::middle(bounds); - self.loader.place(layout.content); - self.content.place(layout.content); - let (left, right) = layout.controls.split_left(layout.controls.size().x); - self.cancel.place(left); - self.confirm.place(right); - bounds - } - - fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { - let now = Instant::now(); - - if let Some(LoaderMsg::ShrunkCompletely) = self.loader.event(ctx, event) { - // Clear the remnants of the loader. - self.pad.clear(); - // Switch it to the initial state, so we stop painting it. - self.loader.reset(); - // Re-draw the whole content tree. - self.content.request_complete_repaint(ctx); - // This can be a result of an animation frame event, we should take - // care to not short-circuit here and deliver the event to the - // content as well. - } - if let Some(msg) = self.content.event(ctx, event) { - return Some(Self::Msg::Content(msg)); - } - if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) { - return Some(Self::Msg::Cancelled); - } - match self.confirm.event(ctx, event) { - Some(ButtonMsg::Pressed) => { - self.loader.start_growing(ctx, now); - self.pad.clear(); // Clear the remnants of the content. - } - Some(ButtonMsg::Released) => { - self.loader.start_shrinking(ctx, now); - } - Some(ButtonMsg::Clicked) => { - if self.loader.is_completely_grown(now) { - self.loader.reset(); - return Some(HoldToConfirmMsg::Confirmed); - } else { - self.loader.start_shrinking(ctx, now); - } - } - _ => {} - } - - None - } - - fn paint(&mut self) { - self.pad.paint(); - if self.loader.is_animating() { - self.loader.paint(); - } else { - self.content.paint(); - } - self.cancel.paint(); - self.confirm.paint(); - } - - fn bounds(&self, sink: &mut dyn FnMut(Rect)) { - sink(self.pad.area); - if self.loader.is_animating() { - self.loader.bounds(sink) - } else { - self.content.bounds(sink) - } - self.cancel.bounds(sink); - self.confirm.bounds(sink); - } -} - -#[cfg(feature = "ui_debug")] -impl crate::trace::Trace for HoldToConfirm -where - T: crate::trace::Trace, -{ - fn trace(&self, d: &mut dyn crate::trace::Tracer) { - d.open("HoldToConfirm"); - self.content.trace(d); - d.close(); - } -} diff --git a/core/embed/rust/src/ui/model_tt/component/hold_to_confirm.rs b/core/embed/rust/src/ui/model_tt/component/hold_to_confirm.rs new file mode 100644 index 000000000..58f5e0f96 --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/hold_to_confirm.rs @@ -0,0 +1,217 @@ +use crate::{ + time::Instant, + ui::{ + component::{Child, Component, ComponentExt, Event, EventCtx, Pad}, + geometry::{Grid, Rect}, + model_tt::component::DialogLayout, + }, +}; + +use super::{theme, Button, ButtonMsg, Loader, LoaderMsg}; + +pub enum HoldToConfirmMsg { + Content(T), + Confirmed, + Cancelled, +} + +pub struct HoldToConfirm { + loader: Loader, + content: Child, + buttons: CancelHold, + pad: Pad, +} + +impl HoldToConfirm +where + T: Component, +{ + pub fn new(content: T) -> Self { + Self { + loader: Loader::new(), + content: Child::new(content), + buttons: CancelHold::new(), + pad: Pad::with_background(theme::BG), + } + } + + pub fn inner(&self) -> &T { + self.content.inner() + } +} + +impl Component for HoldToConfirm +where + T: Component, +{ + type Msg = HoldToConfirmMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let layout = DialogLayout::middle(bounds); + self.pad.place(layout.content); + self.loader.place(layout.content); + self.content.place(layout.content); + self.buttons.place(layout.controls); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(msg) = self.content.event(ctx, event) { + return Some(HoldToConfirmMsg::Content(msg)); + } + let button_msg = match self.buttons.event(ctx, event) { + Some(CancelHoldMsg::Cancelled) => return Some(HoldToConfirmMsg::Cancelled), + Some(CancelHoldMsg::HoldButton(b)) => Some(b), + _ => None, + }; + if handle_hold_event( + ctx, + event, + button_msg, + &mut self.loader, + &mut self.pad, + &mut self.content, + ) { + return Some(HoldToConfirmMsg::Confirmed); + } + None + } + + fn paint(&mut self) { + self.pad.paint(); + if self.loader.is_animating() { + self.loader.paint(); + } else { + self.content.paint(); + } + self.buttons.paint(); + } + + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.pad.area); + if self.loader.is_animating() { + self.loader.bounds(sink) + } else { + self.content.bounds(sink) + } + self.buttons.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for HoldToConfirm +where + T: crate::trace::Trace, +{ + fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.open("HoldToConfirm"); + self.content.trace(d); + d.close(); + } +} + +pub struct CancelHold { + cancel: Button<&'static str>, + hold: Button<&'static str>, +} + +pub enum CancelHoldMsg { + Cancelled, + HoldButton(ButtonMsg), +} + +impl CancelHold { + pub fn new() -> Self { + Self { + cancel: Button::with_icon(theme::ICON_CANCEL), + hold: Button::with_text("HOLD TO CONFIRM").styled(theme::button_confirm()), + } + } +} + +impl Component for CancelHold { + type Msg = CancelHoldMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let grid = Grid::new(bounds, 1, 4).with_spacing(theme::BUTTON_SPACING); + self.cancel.place(grid.row_col(0, 0)); + self.hold + .place(grid.row_col(0, 1).union(grid.row_col(0, 3))); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) { + return Some(CancelHoldMsg::Cancelled); + } + self.hold.event(ctx, event).map(CancelHoldMsg::HoldButton) + } + + fn paint(&mut self) { + self.cancel.paint(); + self.hold.paint(); + } + + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.cancel.bounds(sink); + self.hold.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for CancelHold { + fn trace(&self, d: &mut dyn crate::trace::Tracer) { + d.string("CancelHold") + } +} + +/// Hold-to-confirm logic to be called from event handler of the component that +/// owns `pad`, `loader`, and `content` and a Button. It is expected that the +/// associated button already processed `event` and returned `button_msg`. +/// Returns `true` when the interaction successfully finished. +#[must_use] +pub fn handle_hold_event( + ctx: &mut EventCtx, + event: Event, + button_msg: Option, + loader: &mut Loader, + pad: &mut Pad, + content: &mut T, +) -> bool +where + T: Component, +{ + let now = Instant::now(); + + if let Some(LoaderMsg::ShrunkCompletely) = loader.event(ctx, event) { + // Clear the remnants of the loader. + pad.clear(); + // Switch it to the initial state, so we stop painting it. + loader.reset(); + // Re-draw the whole content tree. + content.request_complete_repaint(ctx); + // This can be a result of an animation frame event, we should take + // care to not short-circuit here and deliver the event to the + // content as well. + } + match button_msg { + Some(ButtonMsg::Pressed) => { + loader.start_growing(ctx, now); + pad.clear(); // Clear the remnants of the content. + } + Some(ButtonMsg::Released) => { + loader.start_shrinking(ctx, now); + } + Some(ButtonMsg::Clicked) => { + if loader.is_completely_grown(now) { + loader.reset(); + return true; + } else { + loader.start_shrinking(ctx, now); + } + } + _ => {} + } + + false +} diff --git a/core/embed/rust/src/ui/model_tt/component/loader.rs b/core/embed/rust/src/ui/model_tt/component/loader.rs index 971e8af07..324d15549 100644 --- a/core/embed/rust/src/ui/model_tt/component/loader.rs +++ b/core/embed/rust/src/ui/model_tt/component/loader.rs @@ -5,6 +5,7 @@ use crate::{ component::{Component, Event, EventCtx}, display::{self, Color}, geometry::{Offset, Rect}, + model_tt::constant, }, }; @@ -32,9 +33,9 @@ pub struct Loader { impl Loader { pub const SIZE: Offset = Offset::new(120, 120); - pub fn new(offset_y: i32) -> Self { + pub fn new() -> Self { Self { - offset_y, + offset_y: 0, state: State::Initial, growing_duration: Duration::from_millis(1000), shrinking_duration: Duration::from_millis(500), @@ -70,13 +71,16 @@ impl Loader { now, ); if let State::Growing(growing) = &self.state { - anim.seek_to_value(display::LOADER_MAX - growing.value(now)); + anim.seek_to_value(display::LOADER_MAX.saturating_sub(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. + // Request anim frame as the animation may not be running, e.g. when already + // grown completely. + ctx.request_anim_frame(); + + // We don't have to wait for the animation frame event with next paint, + // let's do that now. ctx.request_paint(); } @@ -112,8 +116,11 @@ impl Component for Loader { type Msg = LoaderMsg; fn place(&mut self, bounds: Rect) -> Rect { - // TODO: Return the correct size. - bounds + // Current loader API only takes Y-offset relative to screen center, which we + // compute from the bounds center point. + let screen_center = constant::screen().center(); + self.offset_y = screen_center.y - bounds.center().y; + Rect::from_center_and_size(screen_center + Offset::y(self.offset_y), Self::SIZE) } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { @@ -188,7 +195,7 @@ mod tests { #[test] fn loader_yields_expected_progress() { let mut ctx = EventCtx::new(); - let mut l = Loader::new(0); + let mut l = Loader::new(); let t = Instant::now(); assert_eq!(l.progress(t), None); l.start_growing(&mut ctx, t); diff --git a/core/embed/rust/src/ui/model_tt/component/mod.rs b/core/embed/rust/src/ui/model_tt/component/mod.rs index 92d9d6e7f..74dcb3e3f 100644 --- a/core/embed/rust/src/ui/model_tt/component/mod.rs +++ b/core/embed/rust/src/ui/model_tt/component/mod.rs @@ -1,7 +1,7 @@ mod button; -mod confirm; mod dialog; mod frame; +mod hold_to_confirm; mod keyboard; mod loader; mod page; @@ -9,9 +9,9 @@ mod scroll; mod swipe; pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet}; -pub use confirm::{HoldToConfirm, HoldToConfirmMsg}; pub use dialog::{Dialog, DialogLayout, DialogMsg}; pub use frame::Frame; +pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg}; pub use keyboard::{ bip39::Bip39Input, mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg}, @@ -20,7 +20,7 @@ pub use keyboard::{ slip39::Slip39Input, }; pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; -pub use page::SwipePage; +pub use page::{SwipeHoldPage, SwipePage}; pub use scroll::ScrollBar; pub use swipe::{Swipe, SwipeDirection}; diff --git a/core/embed/rust/src/ui/model_tt/component/page.rs b/core/embed/rust/src/ui/model_tt/component/page.rs index 23b3a9ad4..7664ba78d 100644 --- a/core/embed/rust/src/ui/model_tt/component/page.rs +++ b/core/embed/rust/src/ui/model_tt/component/page.rs @@ -1,12 +1,15 @@ use crate::ui::{ component::{ - base::ComponentExt, paginated::PageMsg, Component, Event, EventCtx, Pad, Paginate, + base::ComponentExt, paginated::PageMsg, Component, Event, EventCtx, Label, Pad, Paginate, }, display::{self, Color}, - geometry::{Offset, Rect}, + geometry::Rect, }; -use super::{theme, Button, ScrollBar, Swipe, SwipeDirection}; +use super::{ + hold_to_confirm::{handle_hold_event, CancelHold, CancelHoldMsg}, + theme, Button, Loader, ScrollBar, Swipe, SwipeDirection, +}; pub struct SwipePage { content: T, @@ -14,6 +17,7 @@ pub struct SwipePage { pad: Pad, swipe: Swipe, scrollbar: ScrollBar, + hint: Label<&'static str>, fade: Option, } @@ -30,6 +34,7 @@ where scrollbar: ScrollBar::vertical(), swipe: Swipe::new(), pad: Pad::with_background(background), + hint: Label::centered("SWIPE TO CONTINUE", theme::label_page_hint()), fade: None, } } @@ -53,16 +58,6 @@ where // paint. self.fade = Some(theme::BACKLIGHT_NORMAL); } - - fn paint_hint(&mut self) { - display::text_center( - self.pad.area.bottom_center() - Offset::y(3), - "SWIPE TO CONTINUE", - theme::FONT_BOLD, // FIXME: Figma has this as 14px but bold is 16px - theme::GREY_LIGHT, - theme::BG, - ); - } } impl Component for SwipePage @@ -77,6 +72,7 @@ where let layout = PageLayout::new(bounds); self.pad.place(bounds); self.swipe.place(bounds); + self.hint.place(layout.hint); self.buttons.place(layout.buttons); self.scrollbar.place(layout.scrollbar); @@ -129,6 +125,8 @@ where if let Some(msg) = self.buttons.event(ctx, event) { return Some(PageMsg::Controls(msg)); } + } else { + self.hint.event(ctx, event); } None } @@ -140,7 +138,7 @@ where self.scrollbar.paint(); } if self.scrollbar.has_next_page() { - self.paint_hint(); + self.hint.paint(); } else { self.buttons.paint(); } @@ -156,6 +154,8 @@ where self.content.bounds(sink); if !self.scrollbar.has_next_page() { self.buttons.bounds(sink); + } else { + self.hint.bounds(sink); } } } @@ -181,15 +181,18 @@ pub struct PageLayout { pub content: Rect, pub scrollbar: Rect, pub buttons: Rect, + pub hint: Rect, } impl PageLayout { const BUTTON_SPACE: i32 = 6; const SCROLLBAR_WIDTH: i32 = 10; const SCROLLBAR_SPACE: i32 = 10; + const HINT_OFF: i32 = 19; pub fn new(area: Rect) -> Self { let (content, buttons) = area.split_bottom(Button::<&str>::HEIGHT); + let (_, hint) = area.split_bottom(Self::HINT_OFF); let (content, _space) = content.split_bottom(Self::BUTTON_SPACE); let (buttons, _space) = buttons.split_right(theme::CONTENT_BORDER); let (_space, content) = content.split_left(theme::CONTENT_BORDER); @@ -203,10 +206,103 @@ impl PageLayout { content, scrollbar, buttons, + hint, } } } +pub struct SwipeHoldPage { + inner: SwipePage, + loader: Loader, +} + +impl SwipeHoldPage +where + T: Paginate, + T: Component, +{ + pub fn new(content: T, background: Color) -> Self { + let buttons = CancelHold::new(); + Self { + inner: SwipePage::new(content, buttons, background), + loader: Loader::new(), + } + } +} + +impl Component for SwipeHoldPage +where + T: Paginate, + T: Component, +{ + type Msg = PageMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.inner.place(bounds); + self.loader.place(self.inner.pad.area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let msg = self.inner.event(ctx, event); + let button_msg = match msg { + Some(PageMsg::Content(c)) => return Some(PageMsg::Content(c)), + Some(PageMsg::Controls(CancelHoldMsg::Cancelled)) => { + return Some(PageMsg::Controls(false)) + } + Some(PageMsg::Controls(CancelHoldMsg::HoldButton(b))) => Some(b), + _ => None, + }; + if handle_hold_event( + ctx, + event, + button_msg, + &mut self.loader, + &mut self.inner.pad, + &mut self.inner.content, + ) { + return Some(PageMsg::Controls(true)); + } + None + } + + fn paint(&mut self) { + self.inner.pad.paint(); + if self.loader.is_animating() { + self.loader.paint() + } else { + self.inner.content.paint(); + } + if self.inner.scrollbar.has_pages() { + self.inner.scrollbar.paint(); + } + if self.inner.scrollbar.has_next_page() { + self.inner.hint.paint(); + } else { + self.inner.buttons.paint(); + } + if let Some(val) = self.inner.fade.take() { + // Note that this is blocking and takes some time. + display::fade_backlight(val); + } + } + + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.loader.bounds(sink); + self.inner.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SwipeHoldPage +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.inner.trace(t) + } +} + #[cfg(test)] mod tests { use crate::{ diff --git a/core/embed/rust/src/ui/model_tt/layout.rs b/core/embed/rust/src/ui/model_tt/layout.rs index 2a5f5957d..530f3cd47 100644 --- a/core/embed/rust/src/ui/model_tt/layout.rs +++ b/core/embed/rust/src/ui/model_tt/layout.rs @@ -21,7 +21,7 @@ use super::{ component::{ Bip39Input, Button, ButtonMsg, Dialog, DialogMsg, Frame, HoldToConfirm, HoldToConfirmMsg, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, PassphraseKeyboard, - PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Slip39Input, SwipePage, + PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Slip39Input, SwipeHoldPage, SwipePage, }, theme, }; @@ -116,6 +116,19 @@ where } } +impl ComponentMsgObj for SwipeHoldPage +where + T: Component + Paginate, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PageMsg::Content(_) => Err(Error::TypeError), + PageMsg::Controls(true) => Ok(CONFIRMED.as_obj()), + PageMsg::Controls(false) => Ok(CANCELLED.as_obj()), + } + } +} + extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; diff --git a/core/embed/rust/src/ui/model_tt/theme.rs b/core/embed/rust/src/ui/model_tt/theme.rs index 9366728f2..fb06662c4 100644 --- a/core/embed/rust/src/ui/model_tt/theme.rs +++ b/core/embed/rust/src/ui/model_tt/theme.rs @@ -87,6 +87,14 @@ pub fn label_keyboard_minor() -> LabelStyle { } } +pub fn label_page_hint() -> LabelStyle { + LabelStyle { + font: FONT_BOLD, + text_color: GREY_LIGHT, + background_color: BG, + } +} + pub fn button_default() -> ButtonStyleSheet { ButtonStyleSheet { normal: &ButtonStyle { @@ -285,6 +293,7 @@ impl DefaultTextTheme for TTDefaultText { pub const CONTENT_BORDER: i32 = 5; pub const KEYBOARD_SPACING: i32 = 8; +pub const BUTTON_SPACING: i32 = 6; /// +----------+ /// | 13 |