diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index d988df6778..00d3253535 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -203,6 +203,9 @@ static void _librust_qstrs(void) { MP_QSTR_fingerprint; MP_QSTR_firmware_update__title; MP_QSTR_firmware_update__title_fingerprint; + MP_QSTR_flow; + MP_QSTR_flow_get_address; + MP_QSTR_get_address; MP_QSTR_get_language; MP_QSTR_hold; MP_QSTR_hold_danger; diff --git a/core/embed/rust/src/ui/animation.rs b/core/embed/rust/src/ui/animation.rs index 7469491398..66039faba8 100644 --- a/core/embed/rust/src/ui/animation.rs +++ b/core/embed/rust/src/ui/animation.rs @@ -59,4 +59,8 @@ impl Animation { panic!("offset is too large"); } } + + pub fn finished(&self, now: Instant) -> bool { + self.elapsed(now) >= self.duration + } } diff --git a/core/embed/rust/src/ui/component/base.rs b/core/embed/rust/src/ui/component/base.rs index b82f120762..2680af17bb 100644 --- a/core/embed/rust/src/ui/component/base.rs +++ b/core/embed/rust/src/ui/component/base.rs @@ -73,6 +73,7 @@ pub trait Component { /// dirty flag for it. Any mutation of `T` has to happen through the `mutate` /// accessor, `T` can then request a paint call to be scheduled later by calling /// `EventCtx::request_paint` in its `event` pass. +#[derive(Clone)] pub struct Child { component: T, marked_for_paint: bool, diff --git a/core/embed/rust/src/ui/component/image.rs b/core/embed/rust/src/ui/component/image.rs index 959ccf2c49..4a3fe34ee1 100644 --- a/core/embed/rust/src/ui/component/image.rs +++ b/core/embed/rust/src/ui/component/image.rs @@ -72,6 +72,7 @@ impl crate::trace::Trace for Image { } } +#[derive(Clone)] pub struct BlendedImage { bg: Icon, fg: Icon, diff --git a/core/embed/rust/src/ui/component/label.rs b/core/embed/rust/src/ui/component/label.rs index de554b3a21..f78612de24 100644 --- a/core/embed/rust/src/ui/component/label.rs +++ b/core/embed/rust/src/ui/component/label.rs @@ -7,6 +7,7 @@ use crate::ui::{ use super::{text::TextStyle, TextLayout}; +#[derive(Clone)] pub struct Label { text: T, layout: TextLayout, diff --git a/core/embed/rust/src/ui/component/qr_code.rs b/core/embed/rust/src/ui/component/qr_code.rs index 6b13e116d8..ca069e194f 100644 --- a/core/embed/rust/src/ui/component/qr_code.rs +++ b/core/embed/rust/src/ui/component/qr_code.rs @@ -25,6 +25,7 @@ const CORNER_RADIUS: u8 = 4; const DARK: Color = Color::rgb(0, 0, 0); const LIGHT: Color = Color::rgb(0xff, 0xff, 0xff); +#[derive(Clone)] pub struct Qr { text: String, border: i16, @@ -189,3 +190,5 @@ impl crate::trace::Trace for Qr { t.string("text", self.text.as_str().into()); } } + +impl crate::ui::flow::Swipable for Qr {} diff --git a/core/embed/rust/src/ui/component/text/paragraphs.rs b/core/embed/rust/src/ui/component/text/paragraphs.rs index 3a1fc08847..83c6bf36b8 100644 --- a/core/embed/rust/src/ui/component/text/paragraphs.rs +++ b/core/embed/rust/src/ui/component/text/paragraphs.rs @@ -50,6 +50,7 @@ pub trait ParagraphSource { } } +#[derive(Clone)] pub struct Paragraphs { area: Rect, placement: LinearPlacement, @@ -346,6 +347,7 @@ impl Paragraph { } } +#[derive(Clone)] struct TextLayoutProxy { offset: PageOffset, bounds: Rect, diff --git a/core/embed/rust/src/ui/component/timeout.rs b/core/embed/rust/src/ui/component/timeout.rs index 6da62e7255..8aac3ba8dd 100644 --- a/core/embed/rust/src/ui/component/timeout.rs +++ b/core/embed/rust/src/ui/component/timeout.rs @@ -7,6 +7,7 @@ use crate::{ }, }; +#[derive(Clone)] pub struct Timeout { time_ms: u32, timer: Option, diff --git a/core/embed/rust/src/ui/flow/base.rs b/core/embed/rust/src/ui/flow/base.rs new file mode 100644 index 0000000000..a01f8f0bc0 --- /dev/null +++ b/core/embed/rust/src/ui/flow/base.rs @@ -0,0 +1,95 @@ +use crate::ui::{component::EventCtx, geometry::Offset}; +use num_traits::ToPrimitive; + +#[derive(Copy, Clone)] +pub enum SwipeDirection { + Up, + Down, + Left, + Right, +} + +impl SwipeDirection { + pub fn as_offset(self, size: Offset) -> Offset { + match self { + SwipeDirection::Up => Offset::y(-size.y), + SwipeDirection::Down => Offset::y(size.y), + SwipeDirection::Left => Offset::x(-size.x), + SwipeDirection::Right => Offset::x(size.x), + } + } +} + +/// Component must implement this trait in order to be part of swipe-based flow. +/// The process of receiving a swipe is two-step, because in order to render the +/// transition animation Flow makes a copy of the pre-swipe state of the +/// component to render it along with the post-swipe state. +pub trait Swipable { + /// Return true if component can handle swipe in a given direction. + fn can_swipe(&self, _direction: SwipeDirection) -> bool { + false + } + + /// Make component react to swipe event. Only called if component returned + /// true in the previous function. + fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {} +} + +/// Component::Msg for component parts of a flow. Converting results of +/// different screens to a shared type makes things easier to work with. +/// +/// Also currently the type for message emitted by Flow::event to +/// micropython. They don't need to be the same. +#[derive(Copy, Clone)] +pub enum FlowMsg { + Confirmed, + Cancelled, + Info, + Choice(usize), +} + +/// Composable event handler result. +#[derive(Copy, Clone)] +pub enum Decision { + /// Do nothing, continue with processing next handler. + Nothing, + + /// Initiate transition to another state, end event processing. + /// NOTE: it might make sense to include Option here + Goto(Q, SwipeDirection), + + /// Yield a message to the caller of the flow (i.e. micropython), end event + /// processing. + Return(FlowMsg), +} + +impl Decision { + pub fn or_else(self, func: impl FnOnce() -> Self) -> Self { + match self { + Decision::Nothing => func(), + _ => self, + } + } +} + +/// Encodes the flow logic as a set of states, and transitions between them +/// triggered by events and swipes. +pub trait FlowState +where + Self: Sized + Copy + PartialEq + Eq + ToPrimitive, +{ + /// There needs to be a mapping from states to indices of the FlowStore + /// array. Default implementation works for states that are enums, the + /// FlowStore has to have number of elements equal to number of states. + fn index(&self) -> usize { + unwrap!(self.to_usize()) + } + + /// What to do when user swipes on screen and current component doesn't + /// respond to swipe of that direction. + fn handle_swipe(&self, direction: SwipeDirection) -> Decision; + + /// What to do when the current component emits a message in response to an + /// event. + fn handle_event(&self, msg: FlowMsg) -> Decision; +} diff --git a/core/embed/rust/src/ui/flow/flow.rs b/core/embed/rust/src/ui/flow/flow.rs new file mode 100644 index 0000000000..5811880f2c --- /dev/null +++ b/core/embed/rust/src/ui/flow/flow.rs @@ -0,0 +1,202 @@ +use crate::{ + error, + time::{Duration, Instant}, + ui::{ + animation::Animation, + component::{Component, Event, EventCtx}, + flow::{base::Decision, swipe::Swipe, FlowMsg, FlowState, FlowStore, SwipeDirection}, + geometry::{Offset, Rect}, + shape::Renderer, + }, +}; + +const ANIMATION_DURATION: Duration = Duration::from_millis(333); + +/// Given a state enum and a corresponding FlowStore, create a Component that +/// implements a swipe navigation between the states with animated transitions. +/// +/// If a swipe is detected: +/// - currently active component is asked to handle the event, +/// - if it can't then FlowState::handle_swipe is consulted. +pub struct Flow { + /// Current state. + state: Q, + /// FlowStore with all screens/components. + store: S, + /// `Some` when state transition animation is in progress. + transition: Option>, + /// Swipe detector. + swipe: Swipe, + /// Animation parameter. + anim_offset: Offset, +} + +struct Transition { + prev_state: Q, + animation: Animation, + direction: SwipeDirection, +} + +impl Flow { + pub fn new(init: Q, store: S) -> Result { + Ok(Self { + state: init, + store, + transition: None, + swipe: Swipe::new().down().up().left().right(), + anim_offset: Offset::zero(), + }) + } + + fn goto(&mut self, ctx: &mut EventCtx, direction: SwipeDirection, state: Q) { + self.transition = Some(Transition { + prev_state: self.state, + animation: Animation::new( + Offset::zero(), + direction.as_offset(self.anim_offset), + ANIMATION_DURATION, + Instant::now(), + ), + direction, + }); + self.state = state; + ctx.request_anim_frame(); + ctx.request_paint() + } + + fn render_state<'s>(&'s self, state: Q, target: &mut impl Renderer<'s>) { + self.store.render(state.index(), target) + } + + fn render_transition<'s>(&'s self, transition: &Transition, target: &mut impl Renderer<'s>) { + let off = transition.animation.value(Instant::now()); + + if self.state == transition.prev_state { + target.with_origin(off, &|target| { + self.store.render_cloned(target); + }); + } else { + target.with_origin(off, &|target| { + self.render_state(transition.prev_state, target); + }); + } + target.with_origin( + off - transition.direction.as_offset(self.anim_offset), + &|target| { + self.render_state(self.state, target); + }, + ); + } + + fn handle_transition(&mut self, ctx: &mut EventCtx) { + if let Some(transition) = &self.transition { + if transition.animation.finished(Instant::now()) { + self.transition = None; + unwrap!(self.store.clone(None)); // Free the clone. + + let msg = self.store.event(self.state.index(), ctx, Event::Attach); + assert!(msg.is_none()) + } else { + ctx.request_anim_frame(); + } + ctx.request_paint(); + } + } + + fn handle_swipe_child(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) -> Decision { + let i = self.state.index(); + if self.store.map_swipable(i, |s| s.can_swipe(direction)) { + // Before handling the swipe we make a copy of the original state so that we + // can render both states in the transition animation. + unwrap!(self.store.clone(Some(i))); + self.store.map_swipable(i, |s| s.swiped(ctx, direction)); + Decision::Goto(self.state, direction) + } else { + Decision::Nothing + } + } + + fn handle_event_child(&mut self, ctx: &mut EventCtx, event: Event) -> Decision { + let msg = self.store.event(self.state.index(), ctx, event); + if let Some(msg) = msg { + self.state.handle_event(msg) + } else { + Decision::Nothing + } + } +} + +impl Component for Flow { + type Msg = FlowMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // Save screen size for slide animation. Once we have reasonable constants trait + // this can be set in the constructor. + self.anim_offset = bounds.size(); + + self.swipe.place(bounds); + self.store.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // TODO: are there any events we want to send to all? timers perhaps? + if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { + self.handle_transition(ctx); + } + // Ignore events while transition is running. + if self.transition.is_some() { + return None; + } + + let mut decision = Decision::Nothing; + if let Some(direction) = self.swipe.event(ctx, event) { + decision = self + .handle_swipe_child(ctx, direction) + .or_else(|| self.state.handle_swipe(direction)); + } + decision = decision.or_else(|| self.handle_event_child(ctx, event)); + + match decision { + Decision::Nothing => None, + Decision::Goto(next_state, direction) => { + self.goto(ctx, direction, next_state); + None + } + Decision::Return(msg) => Some(msg), + } + } + + fn paint(&mut self) {} + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + if let Some(transition) = &self.transition { + self.render_transition(transition, target) + } else { + self.render_state(self.state, target) + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Flow { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.store.trace(self.state.index(), t) + } +} + +#[cfg(feature = "micropython")] +impl crate::ui::layout::obj::ComponentMsgObj for Flow { + fn msg_try_into_obj( + &self, + msg: Self::Msg, + ) -> Result { + match msg { + FlowMsg::Confirmed => Ok(crate::ui::layout::result::CONFIRMED.as_obj()), + FlowMsg::Cancelled => Ok(crate::ui::layout::result::CANCELLED.as_obj()), + FlowMsg::Info => Ok(crate::ui::layout::result::INFO.as_obj()), + FlowMsg::Choice(i) => { + Ok((crate::ui::layout::result::CONFIRMED.as_obj(), i.try_into()?).try_into()?) + } + } + } +} diff --git a/core/embed/rust/src/ui/flow/mod.rs b/core/embed/rust/src/ui/flow/mod.rs new file mode 100644 index 0000000000..be55fe0fbb --- /dev/null +++ b/core/embed/rust/src/ui/flow/mod.rs @@ -0,0 +1,10 @@ +pub mod base; +mod flow; +pub mod page; +mod store; +mod swipe; + +pub use base::{FlowMsg, FlowState, Swipable, SwipeDirection}; +pub use flow::Flow; +pub use page::{IgnoreSwipe, SwipePage}; +pub use store::{flow_store, FlowStore}; diff --git a/core/embed/rust/src/ui/flow/page.rs b/core/embed/rust/src/ui/flow/page.rs new file mode 100644 index 0000000000..0aef969725 --- /dev/null +++ b/core/embed/rust/src/ui/flow/page.rs @@ -0,0 +1,131 @@ +use crate::ui::{ + component::{Component, Event, EventCtx, Paginate}, + flow::base::{Swipable, SwipeDirection}, + geometry::{Axis, Rect}, + shape::Renderer, +}; + +/// Allows any implementor of `Paginate` to be part of `Swipable` UI flow. +#[derive(Clone)] +pub struct SwipePage { + inner: T, + axis: Axis, + pages: usize, + current: usize, +} + +impl SwipePage { + pub fn vertical(inner: T) -> Self { + Self { + inner, + axis: Axis::Vertical, + pages: 1, + current: 0, + } + } +} + +impl Component for SwipePage { + type Msg = T::Msg; + + fn place(&mut self, bounds: Rect) -> Rect { + let result = self.inner.place(bounds); + self.pages = self.inner.page_count(); + result + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let msg = self.inner.event(ctx, event); + msg + } + + fn paint(&mut self) { + self.inner.paint() + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.inner.render(target) + } +} + +impl Swipable for SwipePage { + fn can_swipe(&self, direction: SwipeDirection) -> bool { + match (self.axis, direction) { + (Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => false, + (Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => false, + (_, SwipeDirection::Left | SwipeDirection::Up) => self.current + 1 < self.pages, + (_, SwipeDirection::Right | SwipeDirection::Down) => self.current > 0, + } + } + + fn swiped(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) { + match (self.axis, direction) { + (Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => return, + (Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => return, + (_, SwipeDirection::Left | SwipeDirection::Up) => { + self.current = (self.current + 1).min(self.pages - 1); + } + (_, SwipeDirection::Right | SwipeDirection::Down) => { + self.current = self.current.saturating_sub(1); + } + } + self.inner.change_page(self.current); + ctx.request_paint(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SwipePage +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.inner.trace(t) + } +} + +/// Make any component swipable by ignoring all swipe events. +pub struct IgnoreSwipe(T); + +impl IgnoreSwipe { + pub fn new(inner: T) -> Self { + IgnoreSwipe(inner) + } +} + +impl Component for IgnoreSwipe { + type Msg = T::Msg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.0.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.0.event(ctx, event) + } + + fn paint(&mut self) { + self.0.paint() + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.0.render(target) + } +} + +impl Swipable for IgnoreSwipe { + fn can_swipe(&self, _direction: SwipeDirection) -> bool { + false + } + fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {} +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for IgnoreSwipe +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + self.0.trace(t) + } +} diff --git a/core/embed/rust/src/ui/flow/store.rs b/core/embed/rust/src/ui/flow/store.rs new file mode 100644 index 0000000000..993170154b --- /dev/null +++ b/core/embed/rust/src/ui/flow/store.rs @@ -0,0 +1,219 @@ +use crate::{ + error, + maybe_trace::MaybeTrace, + ui::{ + component::{Component, Event, EventCtx}, + flow::base::{FlowMsg, Swipable}, + geometry::Rect, + shape::Renderer, + }, +}; + +use crate::micropython::gc::Gc; + +/// `FlowStore` is essentially `Vec>` except that +/// `trait Component` is not object-safe so it ends up being a kind of +/// recursively-defined tuple. +/// +/// Additionally the store makes it possible to make a clone of one of its +/// items, in order to make it possible to render transition animations. +pub trait FlowStore { + /// Call `Component::place` on all elements. + fn place(&mut self, bounds: Rect) -> Rect; + + /// Call `Component::event` on i-th element, if it emits a message it is + /// converted to `FlowMsg` using a function. + fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option; + + /// Call `Component::render` on i-th element. + fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>); + + #[cfg(feature = "ui_debug")] + /// Call `Trace::trace` on i-th element. + fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer); + + /// Forward `Swipable` methods to i-th element. + fn map_swipable(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T; + + /// Make a clone of i-th element, or free all clones if None is given. + fn clone(&mut self, i: Option) -> Result<(), error::Error>; + + /// Call `Component::render` on the cloned element. + fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>); + + /// Add a Component to the end of a `FlowStore`. + fn add( + self, + elem: E, + func: fn(E::Msg) -> Option, + ) -> Result + where + Self: Sized; +} + +/// Create new empty flow store. +pub fn flow_store() -> impl FlowStore { + FlowEmpty {} +} + +/// Terminating element of a recursive structure. +struct FlowEmpty; + +// Methods that take an index panic because it's always out of bounds. +impl FlowStore for FlowEmpty { + fn place(&mut self, bounds: Rect) -> Rect { + bounds + } + + fn event(&mut self, _i: usize, _ctx: &mut EventCtx, _event: Event) -> Option { + panic!() + } + + fn render<'s>(&'s self, _i: usize, _target: &mut impl Renderer<'s>) { + panic!() + } + + #[cfg(feature = "ui_debug")] + fn trace(&self, _i: usize, _t: &mut dyn crate::trace::Tracer) { + panic!() + } + + fn map_swipable(&mut self, _i: usize, _func: impl FnOnce(&mut dyn Swipable) -> T) -> T { + panic!() + } + + fn clone(&mut self, _i: Option) -> Result<(), error::Error> { + Ok(()) + } + fn render_cloned<'s>(&'s self, _target: &mut impl Renderer<'s>) {} + + fn add( + self, + elem: E, + func: fn(E::Msg) -> Option, + ) -> Result + where + Self: Sized, + { + Ok(FlowComponent { + elem: Gc::new(elem)?, + func, + cloned: None, + next: Self, + }) + } +} + +struct FlowComponent { + /// Component allocated on micropython heap. + pub elem: Gc, + + /// Clone. + pub cloned: Option>, + + /// Function to convert message to `FlowMsg`. + pub func: fn(E::Msg) -> Option, + + /// Nested FlowStore. + pub next: P, +} + +impl FlowComponent { + fn as_ref(&self) -> &E { + &self.elem + } + + fn as_mut(&mut self) -> &mut E { + // SAFETY: micropython can only access this object through LayoutObj which wraps + // us in a RefCell which guarantees uniqueness + unsafe { Gc::as_mut(&mut self.elem) } + } +} + +impl FlowStore for FlowComponent +where + E: Component + MaybeTrace + Swipable + Clone, + P: FlowStore, +{ + fn place(&mut self, bounds: Rect) -> Rect { + self.as_mut().place(bounds); + self.next.place(bounds); + bounds + } + + fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option { + if i == 0 { + let msg = self.as_mut().event(ctx, event); + msg.and_then(self.func) + } else { + self.next.event(i - 1, ctx, event) + } + } + + fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>) { + if i == 0 { + self.as_ref().render(target) + } else { + self.next.render(i - 1, target) + } + } + + #[cfg(feature = "ui_debug")] + fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer) { + if i == 0 { + self.as_ref().trace(t) + } else { + self.next.trace(i - 1, t) + } + } + + fn map_swipable(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T { + if i == 0 { + func(self.as_mut()) + } else { + self.next.map_swipable(i - 1, func) + } + } + + fn clone(&mut self, i: Option) -> Result<(), error::Error> { + match i { + None => { + // FIXME: how to ensure the allocation is returned? + self.cloned = None; + self.next.clone(None)? + } + Some(0) => { + self.cloned = Some(Gc::new(self.as_ref().clone())?); + self.next.clone(None)? + } + Some(i) => { + self.cloned = None; + self.next.clone(Some(i - 1))? + } + } + Ok(()) + } + + fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>) { + if let Some(cloned) = &self.cloned { + cloned.render(target) + } + self.next.render_cloned(target); + } + + fn add( + self, + elem: F, + func: fn(F::Msg) -> Option, + ) -> Result + where + Self: Sized, + { + Ok(FlowComponent { + elem: self.elem, + func: self.func, + cloned: None, + next: self.next.add(elem, func)?, + }) + } +} diff --git a/core/embed/rust/src/ui/flow/swipe.rs b/core/embed/rust/src/ui/flow/swipe.rs new file mode 100644 index 0000000000..1d4d6989c5 --- /dev/null +++ b/core/embed/rust/src/ui/flow/swipe.rs @@ -0,0 +1,144 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + event::TouchEvent, + flow::base::SwipeDirection, + geometry::{Point, Rect}, + shape::Renderer, +}; + +/// Copy of `model_tt/component/swipe.rs` but without the backlight handling. +pub struct Swipe { + pub area: Rect, + pub allow_up: bool, + pub allow_down: bool, + pub allow_left: bool, + pub allow_right: bool, + + origin: Option, +} + +impl Swipe { + const DISTANCE: i32 = 120; + const THRESHOLD: f32 = 0.2; + + pub fn new() -> Self { + Self { + area: Rect::zero(), + allow_up: false, + allow_down: false, + allow_left: false, + allow_right: false, + origin: None, + } + } + + pub fn vertical() -> Self { + Self::new().up().down() + } + + pub fn horizontal() -> Self { + Self::new().left().right() + } + + pub fn up(mut self) -> Self { + self.allow_up = true; + self + } + + pub fn down(mut self) -> Self { + self.allow_down = true; + self + } + + pub fn left(mut self) -> Self { + self.allow_left = true; + self + } + + pub fn right(mut self) -> Self { + self.allow_right = true; + self + } + + fn is_active(&self) -> bool { + self.allow_up || self.allow_down || self.allow_left || self.allow_right + } + + fn ratio(&self, dist: i16) -> f32 { + (dist as f32 / Self::DISTANCE as f32).min(1.0) + } +} + +impl Component for Swipe { + type Msg = SwipeDirection; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.area + } + + fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option { + if !self.is_active() { + return None; + } + match (event, self.origin) { + (Event::Touch(TouchEvent::TouchStart(pos)), _) if self.area.contains(pos) => { + // Mark the starting position of this touch. + self.origin.replace(pos); + } + (Event::Touch(TouchEvent::TouchMove(pos)), Some(origin)) => { + // Consider our allowed directions and the touch distance and modify the display + // backlight accordingly. + let ofs = pos - origin; + let abs = ofs.abs(); + if abs.x > abs.y && (self.allow_left || self.allow_right) { + // Horizontal direction. + if (ofs.x < 0 && self.allow_left) || (ofs.x > 0 && self.allow_right) { + // self.backlight(self.ratio(abs.x)); + } + } else if abs.x < abs.y && (self.allow_up || self.allow_down) { + // Vertical direction. + if (ofs.y < 0 && self.allow_up) || (ofs.y > 0 && self.allow_down) { + // self.backlight(self.ratio(abs.y)); + } + }; + } + (Event::Touch(TouchEvent::TouchEnd(pos)), Some(origin)) => { + // Touch interaction is over, reset the position. + self.origin.take(); + + // Compare the touch distance with our allowed directions and determine if it + // constitutes a valid swipe. + let ofs = pos - origin; + let abs = ofs.abs(); + if abs.x > abs.y && (self.allow_left || self.allow_right) { + // Horizontal direction. + if self.ratio(abs.x) >= Self::THRESHOLD { + if ofs.x < 0 && self.allow_left { + return Some(SwipeDirection::Left); + } else if ofs.x > 0 && self.allow_right { + return Some(SwipeDirection::Right); + } + } + } else if abs.x < abs.y && (self.allow_up || self.allow_down) { + // Vertical direction. + if self.ratio(abs.y) >= Self::THRESHOLD { + if ofs.y < 0 && self.allow_up { + return Some(SwipeDirection::Up); + } else if ofs.y > 0 && self.allow_down { + return Some(SwipeDirection::Down); + } + } + }; + } + _ => { + // Do nothing. + } + } + None + } + + fn paint(&mut self) {} + + fn render<'s>(&'s self, _target: &mut impl Renderer<'s>) {} +} diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index ec2be3ba3d..bca2e523c0 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -134,6 +134,12 @@ impl From for Offset { } } +impl Lerp for Offset { + fn lerp(a: Self, b: Self, t: f32) -> Self { + Offset::new(i16::lerp(a.x, b.x, t), i16::lerp(a.y, b.y, t)) + } +} + /// A point in 2D space defined by the the `x` and `y` coordinate. Relative /// coordinates, vectors, and offsets are represented by the `Offset` type. #[derive(Copy, Clone, PartialEq, Eq)] diff --git a/core/embed/rust/src/ui/mod.rs b/core/embed/rust/src/ui/mod.rs index fabd40ef2a..83742af66c 100644 --- a/core/embed/rust/src/ui/mod.rs +++ b/core/embed/rust/src/ui/mod.rs @@ -6,6 +6,8 @@ pub mod component; pub mod constant; pub mod display; pub mod event; +#[cfg(feature = "micropython")] +pub mod flow; pub mod geometry; pub mod lerp; pub mod screens; @@ -23,12 +25,19 @@ pub mod model_tr; pub mod model_tt; #[cfg(all( - feature = "model_t1", + feature = "model_mercury", + not(feature = "model_t1"), not(feature = "model_tr"), not(feature = "model_tt") ))] +pub use model_mercury as model; +#[cfg(all( + feature = "model_t1", + not(feature = "model_tr"), + not(feature = "model_tt"), +))] pub use model_t1 as model; -#[cfg(all(feature = "model_tr", not(feature = "model_tt")))] +#[cfg(all(feature = "model_tr", not(feature = "model_tt"),))] pub use model_tr as model; #[cfg(feature = "model_tt")] pub use model_tt as model; diff --git a/core/embed/rust/src/ui/model_mercury/component/address_details.rs b/core/embed/rust/src/ui/model_mercury/component/address_details.rs index 01c08df034..385423b5f0 100644 --- a/core/embed/rust/src/ui/model_mercury/component/address_details.rs +++ b/core/embed/rust/src/ui/model_mercury/component/address_details.rs @@ -97,7 +97,7 @@ where let mut dummy_ctx = EventCtx::new(); self.xpub_view .update_title(&mut dummy_ctx, self.xpubs[i].0.clone()); - self.xpub_view.update_content(&mut dummy_ctx, |p| { + self.xpub_view.update_content(&mut dummy_ctx, |_ctx, p| { p.inner_mut().update(self.xpubs[i].1.clone()); let npages = p.page_count(); p.change_page(page); diff --git a/core/embed/rust/src/ui/model_mercury/component/button.rs b/core/embed/rust/src/ui/model_mercury/component/button.rs index a3c8f6a339..6b548975c8 100644 --- a/core/embed/rust/src/ui/model_mercury/component/button.rs +++ b/core/embed/rust/src/ui/model_mercury/component/button.rs @@ -23,6 +23,7 @@ pub enum ButtonMsg { LongPressed, } +#[derive(Clone)] pub struct Button { area: Rect, touch_expand: Option, @@ -400,7 +401,7 @@ where } } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone)] enum State { Initial, Pressed, @@ -408,7 +409,7 @@ enum State { Disabled, } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone)] pub enum ButtonContent { Empty, Text(T), @@ -533,6 +534,7 @@ impl Button { } } +#[derive(Copy, Clone)] pub enum CancelConfirmMsg { Cancelled, Confirmed, @@ -555,7 +557,7 @@ pub enum SelectWordMsg { Selected(usize), } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone)] pub struct IconText { text: T, icon: Icon, @@ -618,7 +620,7 @@ where } } pub fn render<'s>( - & self, + &self, target: &mut impl Renderer<'s>, area: Rect, style: &ButtonStyle, diff --git a/core/embed/rust/src/ui/model_mercury/component/dialog.rs b/core/embed/rust/src/ui/model_mercury/component/dialog.rs index 49c344ca85..2e4a65b0f0 100644 --- a/core/embed/rust/src/ui/model_mercury/component/dialog.rs +++ b/core/embed/rust/src/ui/model_mercury/component/dialog.rs @@ -97,6 +97,7 @@ where } } +#[derive(Clone)] pub struct IconDialog { image: Child, paragraphs: Paragraphs>, @@ -231,3 +232,5 @@ where t.child("controls", &self.controls); } } + +impl crate::ui::flow::Swipable for IconDialog {} diff --git a/core/embed/rust/src/ui/model_mercury/component/frame.rs b/core/embed/rust/src/ui/model_mercury/component/frame.rs index fe81276ecc..41b6af167f 100644 --- a/core/embed/rust/src/ui/model_mercury/component/frame.rs +++ b/core/embed/rust/src/ui/model_mercury/component/frame.rs @@ -12,6 +12,7 @@ use crate::ui::{ const TITLE_HEIGHT: i16 = 42; const TITLE_SPACE: i16 = 2; +#[derive(Clone)] pub struct Frame { border: Insets, title: Child>, @@ -107,10 +108,10 @@ where pub fn update_content(&mut self, ctx: &mut EventCtx, update_fn: F) -> R where - F: Fn(&mut T) -> R, + F: Fn(&mut EventCtx, &mut T) -> R, { self.content.mutate(ctx, |ctx, c| { - let res = update_fn(c); + let res = update_fn(ctx, c); c.request_complete_repaint(ctx); res }) @@ -163,7 +164,7 @@ where fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { self.title.render(target); - self.subtitle.render(target); + // self.subtitle.render(target); // FIXME crashes! self.button.render(target); self.content.render(target); } @@ -195,3 +196,17 @@ where } } } + +impl crate::ui::flow::Swipable for Frame +where + T: Component + crate::ui::flow::Swipable, + U: AsRef, +{ + fn can_swipe(&self, direction: crate::ui::flow::SwipeDirection) -> bool { + self.inner().can_swipe(direction) + } + + fn swiped(&mut self, ctx: &mut EventCtx, direction: crate::ui::flow::SwipeDirection) { + self.update_content(ctx, |ctx, inner| inner.swiped(ctx, direction)) + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/swipe.rs b/core/embed/rust/src/ui/model_mercury/component/swipe.rs index b4cab1d2ef..b4244e38c3 100644 --- a/core/embed/rust/src/ui/model_mercury/component/swipe.rs +++ b/core/embed/rust/src/ui/model_mercury/component/swipe.rs @@ -8,12 +8,7 @@ use crate::ui::{ use super::theme; -pub enum SwipeDirection { - Up, - Down, - Left, - Right, -} +pub use crate::ui::flow::SwipeDirection; pub struct Swipe { pub area: Rect, diff --git a/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs b/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs index 575819b702..a95131caba 100644 --- a/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs +++ b/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs @@ -30,6 +30,7 @@ const MENU_SEP_HEIGHT: i16 = 2; type VerticalMenuButtons = Vec, N_ITEMS>; type AreasForSeparators = Vec; +#[derive(Clone)] pub struct VerticalMenu { area: Rect, /// buttons placed vertically from top to bottom @@ -154,3 +155,5 @@ where }); } } + +impl crate::ui::flow::Swipable for VerticalMenu {} diff --git a/core/embed/rust/src/ui/model_mercury/flow/get_address.rs b/core/embed/rust/src/ui/model_mercury/flow/get_address.rs new file mode 100644 index 0000000000..e3c55a1c2e --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/flow/get_address.rs @@ -0,0 +1,185 @@ +use crate::{ + error, + ui::{ + component::{ + image::BlendedImage, + text::paragraphs::{Paragraph, Paragraphs}, + Qr, Timeout, + }, + flow::{ + base::Decision, flow_store, Flow, FlowMsg, FlowState, FlowStore, SwipeDirection, + SwipePage, + }, + }, +}; + +use super::super::{ + component::{Frame, FrameMsg, IconDialog, VerticalMenu, VerticalMenuChoiceMsg}, + theme, +}; + +const LONGSTRING: &'static str = "https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo"; + +#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)] +pub enum GetAddress { + Address, + Menu, + QrCode, + AccountInfo, + Cancel, + Success, +} + +impl FlowState for GetAddress { + fn handle_swipe(&self, direction: SwipeDirection) -> Decision { + match (self, direction) { + (GetAddress::Address, SwipeDirection::Left) => { + Decision::Goto(GetAddress::Menu, direction) + } + (GetAddress::Address, SwipeDirection::Up) => { + Decision::Goto(GetAddress::Success, direction) + } + (GetAddress::Menu, SwipeDirection::Right) => { + Decision::Goto(GetAddress::Address, direction) + } + (GetAddress::QrCode, SwipeDirection::Right) => { + Decision::Goto(GetAddress::Menu, direction) + } + (GetAddress::AccountInfo, SwipeDirection::Right) => { + Decision::Goto(GetAddress::Menu, direction) + } + (GetAddress::Cancel, SwipeDirection::Up) => Decision::Return(FlowMsg::Cancelled), + _ => Decision::Nothing, + } + } + + fn handle_event(&self, msg: FlowMsg) -> Decision { + match (self, msg) { + (GetAddress::Address, FlowMsg::Info) => { + Decision::Goto(GetAddress::Menu, SwipeDirection::Left) + } + + (GetAddress::Menu, FlowMsg::Choice(0)) => { + Decision::Goto(GetAddress::QrCode, SwipeDirection::Left) + } + + (GetAddress::Menu, FlowMsg::Choice(1)) => { + Decision::Goto(GetAddress::AccountInfo, SwipeDirection::Left) + } + + (GetAddress::Menu, FlowMsg::Choice(2)) => { + Decision::Goto(GetAddress::Cancel, SwipeDirection::Left) + } + + (GetAddress::Menu, FlowMsg::Cancelled) => { + Decision::Goto(GetAddress::Address, SwipeDirection::Right) + } + + (GetAddress::QrCode, FlowMsg::Cancelled) => { + Decision::Goto(GetAddress::Menu, SwipeDirection::Right) + } + + (GetAddress::AccountInfo, FlowMsg::Cancelled) => { + Decision::Goto(GetAddress::Menu, SwipeDirection::Right) + } + + (GetAddress::Cancel, FlowMsg::Cancelled) => { + Decision::Goto(GetAddress::Menu, SwipeDirection::Right) + } + + (GetAddress::Success, _) => Decision::Return(FlowMsg::Confirmed), + _ => Decision::Nothing, + } + } +} + +use crate::{ + micropython::{buffer::StrBuffer, map::Map, obj::Obj, util}, + ui::layout::obj::LayoutObj, +}; + +pub extern "C" fn new_get_address(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, GetAddress::new) } +} + +impl GetAddress { + fn new(args: &[Obj], kwargs: &Map) -> Result { + // Result, error::Error> { + let store = flow_store() + .add( + Frame::left_aligned( + "Receive", + SwipePage::vertical(Paragraphs::new(Paragraph::new( + &theme::TEXT_MONO, + StrBuffer::from(LONGSTRING), + ))), + ) + .with_subtitle("address") + .with_info_button(), + |msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info), + )? + .add( + Frame::left_aligned( + "", + VerticalMenu::context_menu([ + ("Address QR code", theme::ICON_QR_CODE), + ("Account info", theme::ICON_CHEVRON_RIGHT), + ("Cancel transaction", theme::ICON_CANCEL), + ]), + ) + .with_cancel_button(), + |msg| match msg { + FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => { + Some(FlowMsg::Choice(i)) + } + FrameMsg::Button(_) => Some(FlowMsg::Cancelled), + }, + )? + .add( + Frame::left_aligned( + "Receive address", + Qr::new("https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo", true)?, + ) + .with_cancel_button(), + |msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled), + )? + .add( + Frame::left_aligned( + "Account info", + SwipePage::vertical(Paragraphs::new(Paragraph::new( + &theme::TEXT_NORMAL, + StrBuffer::from("taproot xp"), + ))), + ) + .with_cancel_button(), + |msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled), + )? + .add( + Frame::left_aligned( + "Cancel receive", + SwipePage::vertical(Paragraphs::new(Paragraph::new( + &theme::TEXT_NORMAL, + StrBuffer::from("O rly?"), + ))), + ) + .with_cancel_button(), + |msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled), + )? + .add( + IconDialog::new( + BlendedImage::new( + theme::IMAGE_BG_CIRCLE, + theme::IMAGE_FG_WARN, + theme::SUCCESS_COLOR, + theme::FG, + theme::BG, + ), + StrBuffer::from("Confirmed"), + Timeout::new(100), + ), + |_| Some(FlowMsg::Confirmed), + )?; + let res = Flow::new(GetAddress::Address, store)?; + Ok(LayoutObj::new(res)?.into()) + } +} diff --git a/core/embed/rust/src/ui/model_mercury/flow/mod.rs b/core/embed/rust/src/ui/model_mercury/flow/mod.rs new file mode 100644 index 0000000000..be27c0c4c1 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/flow/mod.rs @@ -0,0 +1,3 @@ +pub mod get_address; + +pub use get_address::GetAddress; diff --git a/core/embed/rust/src/ui/model_mercury/layout.rs b/core/embed/rust/src/ui/model_mercury/layout.rs index d217ca171a..b23302146f 100644 --- a/core/embed/rust/src/ui/model_mercury/layout.rs +++ b/core/embed/rust/src/ui/model_mercury/layout.rs @@ -55,7 +55,7 @@ use super::{ SelectWordCount, SelectWordCountMsg, SelectWordMsg, SimplePage, Slip39Input, VerticalMenu, VerticalMenuChoiceMsg, }, - theme, + flow, theme, }; impl TryFrom for Obj { @@ -2195,6 +2195,10 @@ pub static mp_module_trezorui2: Module = obj_module! { /// def show_wait_text(message: str, /) -> LayoutObj[None]: /// """Show single-line text in the middle of the screen.""" Qstr::MP_QSTR_show_wait_text => obj_fn_1!(new_show_wait_text).as_obj(), + + /// def flow_get_address() -> LayoutObj[UiResult]: + /// """Get address / receive funds." + Qstr::MP_QSTR_flow_get_address => obj_fn_kw!(0, flow::get_address::new_get_address).as_obj(), }; #[cfg(test)] diff --git a/core/embed/rust/src/ui/model_mercury/mod.rs b/core/embed/rust/src/ui/model_mercury/mod.rs index 529781d27e..2229cf3e1c 100644 --- a/core/embed/rust/src/ui/model_mercury/mod.rs +++ b/core/embed/rust/src/ui/model_mercury/mod.rs @@ -4,6 +4,7 @@ pub mod component; pub mod constant; pub mod theme; +pub mod flow; #[cfg(feature = "micropython")] pub mod layout; pub mod screens; diff --git a/core/mocks/generated/trezorui2.pyi b/core/mocks/generated/trezorui2.pyi index a1241a7b1d..b87f63f79a 100644 --- a/core/mocks/generated/trezorui2.pyi +++ b/core/mocks/generated/trezorui2.pyi @@ -70,6 +70,11 @@ def check_homescreen_format(data: bytes) -> bool: """Check homescreen format and dimensions.""" +# rust/src/ui/model_mercury/layout.rs +def flow_get_address() -> LayoutObj[UiResult]: + """Get address / receive funds." + + # rust/src/ui/model_mercury/layout.rs def confirm_action( *, diff --git a/core/src/apps/base.py b/core/src/apps/base.py index 4ba716ce48..066a8238fd 100644 --- a/core/src/apps/base.py +++ b/core/src/apps/base.py @@ -240,9 +240,9 @@ async def handle_EndSession(msg: EndSession) -> Success: async def handle_Ping(msg: Ping) -> Success: if msg.button_protection: from trezor.enums import ButtonRequestType as B - from trezor.ui.layouts import confirm_action + from trezor.ui.layouts.mercury import flow_demo - await confirm_action("ping", TR.words__confirm, "ping", br_code=B.ProtectCall) + await flow_demo() return Success(message=msg.message) diff --git a/core/src/trezor/ui/layouts/mercury/__init__.py b/core/src/trezor/ui/layouts/mercury/__init__.py index 636481ea65..2550c6b9cc 100644 --- a/core/src/trezor/ui/layouts/mercury/__init__.py +++ b/core/src/trezor/ui/layouts/mercury/__init__.py @@ -278,6 +278,17 @@ async def confirm_action( ) +async def flow_demo() -> None: + await raise_if_not_confirmed( + interact( + RustLayout(trezorui2.flow_get_address()), + "get_address", + BR_TYPE_OTHER, + ), + ActionCancelled, + ) + + async def confirm_single( br_type: str, title: str,