diff --git a/core/embed/rust/Cargo.toml b/core/embed/rust/Cargo.toml index f7d598b9b..a7432a627 100644 --- a/core/embed/rust/Cargo.toml +++ b/core/embed/rust/Cargo.toml @@ -10,6 +10,7 @@ default = ["model_tt"] bitcoin_only = [] model_tt = [] model_t1 = [] +model_tr = [] ui = [] ui_debug = [] test = ["cc", "glob"] diff --git a/core/embed/rust/src/ui/component/base.rs b/core/embed/rust/src/ui/component/base.rs index 7accad4e7..c71c5d881 100644 --- a/core/embed/rust/src/ui/component/base.rs +++ b/core/embed/rust/src/ui/component/base.rs @@ -2,15 +2,16 @@ use core::mem; use heapless::Vec; -#[cfg(feature = "model_t1")] -use crate::ui::model_t1::event::ButtonEvent; -#[cfg(feature = "model_tt")] -use crate::ui::model_tt::event::TouchEvent; use crate::{ time::Duration, ui::{component::Map, geometry::Rect}, }; +#[cfg(any(feature = "model_t1", feature = "model_tr"))] +use crate::ui::event::ButtonEvent; +#[cfg(feature = "model_tt")] +use crate::ui::event::TouchEvent; + /// Type used by components that do not return any messages. /// /// Alternative to the yet-unstable `!`-type. @@ -218,7 +219,7 @@ where #[derive(Copy, Clone, PartialEq, Eq)] pub enum Event { - #[cfg(feature = "model_t1")] + #[cfg(any(feature = "model_t1", feature = "model_tr"))] Button(ButtonEvent), #[cfg(feature = "model_tt")] Touch(TouchEvent), diff --git a/core/embed/rust/src/ui/component/text/iter.rs b/core/embed/rust/src/ui/component/text/iter.rs index 5d148b899..d876beb2a 100644 --- a/core/embed/rust/src/ui/component/text/iter.rs +++ b/core/embed/rust/src/ui/component/text/iter.rs @@ -1,8 +1,4 @@ -use crate::ui::{ - component::{text::layout::Op, LineBreaking}, - display::Font, - geometry::Offset, -}; +use crate::ui::{component::LineBreaking, display::Font}; use core::iter; #[derive(Copy, Clone, Eq, PartialEq, Debug)] diff --git a/core/embed/rust/src/ui/display.rs b/core/embed/rust/src/ui/display.rs index e128a492a..bbf1ce385 100644 --- a/core/embed/rust/src/ui/display.rs +++ b/core/embed/rust/src/ui/display.rs @@ -1,7 +1,9 @@ use crate::{micropython::time, time::Duration, trezorhal::display}; -#[cfg(not(feature = "model_tt"))] +#[cfg(feature = "model_t1")] use crate::ui::model_t1::constant; +#[cfg(feature = "model_tr")] +use crate::ui::model_tr::constant; #[cfg(feature = "model_tt")] use crate::ui::model_tt::constant; diff --git a/core/embed/rust/src/ui/event.rs b/core/embed/rust/src/ui/event.rs new file mode 100644 index 000000000..0700335a8 --- /dev/null +++ b/core/embed/rust/src/ui/event.rs @@ -0,0 +1,55 @@ +use crate::{error, ui::geometry::Point}; +use core::convert::TryInto; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum PhysicalButton { + Left, + Right, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ButtonEvent { + ButtonPressed(PhysicalButton), + ButtonReleased(PhysicalButton), +} + +impl ButtonEvent { + pub fn new(event: u32, button: u32) -> Result { + let button = match button { + 0 => PhysicalButton::Left, + 1 => PhysicalButton::Right, + _ => return Err(error::Error::OutOfRange), + }; + let result = match event { + 1 => Self::ButtonPressed(button), + 2 => Self::ButtonReleased(button), + _ => return Err(error::Error::OutOfRange), + }; + Ok(result) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum TouchEvent { + /// A person has started touching the screen at given absolute coordinates. + /// `TouchMove` will usually follow, and `TouchEnd` should finish the + /// interaction. + TouchStart(Point), + /// Touch has moved into a different point on the screen. + TouchMove(Point), + /// Touch has ended at a point on the screen. + TouchEnd(Point), +} + +impl TouchEvent { + pub fn new(event: u32, x: u32, y: u32) -> Result { + let point = Point::new(x.try_into()?, y.try_into()?); + let result = match event { + 1 => Self::TouchStart(point), + 2 => Self::TouchMove(point), + 4 => Self::TouchEnd(point), + _ => return Err(error::Error::OutOfRange), + }; + Ok(result) + } +} diff --git a/core/embed/rust/src/ui/layout/obj.rs b/core/embed/rust/src/ui/layout/obj.rs index 545320fa4..756f9a86f 100644 --- a/core/embed/rust/src/ui/layout/obj.rs +++ b/core/embed/rust/src/ui/layout/obj.rs @@ -20,14 +20,15 @@ use crate::{ util, }; +#[cfg(any(feature = "model_t1", feature = "model_tr"))] +use crate::ui::event::ButtonEvent; #[cfg(feature = "model_tt")] -use crate::ui::model_tt::event::TouchEvent; +use crate::ui::event::TouchEvent; #[cfg(feature = "model_t1")] -use crate::ui::model_t1::event::ButtonEvent; - -#[cfg(not(feature = "model_tt"))] use crate::ui::model_t1::constant; +#[cfg(feature = "model_tr")] +use crate::ui::model_tr::constant; #[cfg(feature = "model_tt")] use crate::ui::model_tt::constant; @@ -371,12 +372,12 @@ extern "C" fn ui_layout_touch_event(n_args: usize, args: *const Obj) -> Obj { unsafe { util::try_with_args_and_kwargs(n_args, args, &Map::EMPTY, block) } } -#[cfg(not(feature = "model_tt"))] +#[cfg(any(feature = "model_t1", feature = "model_tr"))] extern "C" fn ui_layout_touch_event(_n_args: usize, _args: *const Obj) -> Obj { Obj::const_none() } -#[cfg(feature = "model_t1")] +#[cfg(any(feature = "model_t1", feature = "model_tr"))] extern "C" fn ui_layout_button_event(n_args: usize, args: *const Obj) -> Obj { let block = |args: &[Obj], _kwargs: &Map| { if args.len() != 3 { @@ -390,7 +391,7 @@ extern "C" fn ui_layout_button_event(n_args: usize, args: *const Obj) -> Obj { unsafe { util::try_with_args_and_kwargs(n_args, args, &Map::EMPTY, block) } } -#[cfg(not(feature = "model_t1"))] +#[cfg(feature = "model_tt")] extern "C" fn ui_layout_button_event(_n_args: usize, _args: *const Obj) -> Obj { Obj::const_none() } diff --git a/core/embed/rust/src/ui/mod.rs b/core/embed/rust/src/ui/mod.rs index 13c3885df..8fe91294d 100644 --- a/core/embed/rust/src/ui/mod.rs +++ b/core/embed/rust/src/ui/mod.rs @@ -4,10 +4,13 @@ pub mod macros; pub mod animation; pub mod component; pub mod display; +pub mod event; pub mod geometry; pub mod layout; #[cfg(feature = "model_t1")] pub mod model_t1; +#[cfg(feature = "model_tr")] +pub mod model_tr; #[cfg(feature = "model_tt")] pub mod model_tt; diff --git a/core/embed/rust/src/ui/model_t1/component/button.rs b/core/embed/rust/src/ui/model_t1/component/button.rs index 3febad2ef..3f3a725b1 100644 --- a/core/embed/rust/src/ui/model_t1/component/button.rs +++ b/core/embed/rust/src/ui/model_t1/component/button.rs @@ -1,13 +1,11 @@ use crate::ui::{ component::{Component, Event, EventCtx}, display::{self, Color, Font}, + event::{ButtonEvent, PhysicalButton}, geometry::{Offset, Point, Rect}, }; -use super::{ - event::{ButtonEvent, T1Button}, - theme, -}; +use super::theme; pub enum ButtonMsg { Clicked, @@ -20,10 +18,10 @@ pub enum ButtonPos { } impl ButtonPos { - fn hit(&self, b: &T1Button) -> bool { + fn hit(&self, b: &PhysicalButton) -> bool { matches!( (self, b), - (Self::Left, T1Button::Left) | (Self::Right, T1Button::Right) + (Self::Left, PhysicalButton::Left) | (Self::Right, PhysicalButton::Right) ) } } diff --git a/core/embed/rust/src/ui/model_t1/component/mod.rs b/core/embed/rust/src/ui/model_t1/component/mod.rs index ec817cc8e..dc8c10edb 100644 --- a/core/embed/rust/src/ui/model_t1/component/mod.rs +++ b/core/embed/rust/src/ui/model_t1/component/mod.rs @@ -3,7 +3,7 @@ mod dialog; mod frame; mod page; -use super::{event, theme}; +use super::theme; pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet}; pub use dialog::{Dialog, DialogMsg}; diff --git a/core/embed/rust/src/ui/model_t1/event.rs b/core/embed/rust/src/ui/model_t1/event.rs deleted file mode 100644 index 49ad7501e..000000000 --- a/core/embed/rust/src/ui/model_t1/event.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::error; - -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum T1Button { - Left, - Right, -} - -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum ButtonEvent { - ButtonPressed(T1Button), - ButtonReleased(T1Button), -} - -impl ButtonEvent { - pub fn new(event: u32, button: u32) -> Result { - let button = match button { - 0 => T1Button::Left, - 1 => T1Button::Right, - _ => return Err(error::Error::OutOfRange), - }; - let result = match event { - 1 => Self::ButtonPressed(button), - 2 => Self::ButtonReleased(button), - _ => return Err(error::Error::OutOfRange), - }; - Ok(result) - } -} diff --git a/core/embed/rust/src/ui/model_t1/mod.rs b/core/embed/rust/src/ui/model_t1/mod.rs index 43d96a0f7..c4e099002 100644 --- a/core/embed/rust/src/ui/model_t1/mod.rs +++ b/core/embed/rust/src/ui/model_t1/mod.rs @@ -1,5 +1,4 @@ pub mod component; pub mod constant; -pub mod event; pub mod layout; pub mod theme; diff --git a/core/embed/rust/src/ui/model_tr/component/button.rs b/core/embed/rust/src/ui/model_tr/component/button.rs new file mode 100644 index 000000000..3f3a725b1 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/button.rs @@ -0,0 +1,190 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + display::{self, Color, Font}, + event::{ButtonEvent, PhysicalButton}, + geometry::{Offset, Point, Rect}, +}; + +use super::theme; + +pub enum ButtonMsg { + Clicked, +} + +#[derive(Copy, Clone)] +pub enum ButtonPos { + Left, + Right, +} + +impl ButtonPos { + fn hit(&self, b: &PhysicalButton) -> bool { + matches!( + (self, b), + (Self::Left, PhysicalButton::Left) | (Self::Right, PhysicalButton::Right) + ) + } +} + +pub struct Button { + area: Rect, + pos: ButtonPos, + baseline: Point, + content: ButtonContent, + styles: ButtonStyleSheet, + state: State, +} + +impl> Button { + pub fn new(pos: ButtonPos, content: ButtonContent, styles: ButtonStyleSheet) -> Self { + Self { + pos, + content, + styles, + baseline: Point::zero(), + area: Rect::zero(), + state: State::Released, + } + } + + pub fn with_text(pos: ButtonPos, text: T, styles: ButtonStyleSheet) -> Self { + Self::new(pos, ButtonContent::Text(text), styles) + } + + pub fn with_icon(pos: ButtonPos, image: &'static [u8], styles: ButtonStyleSheet) -> Self { + Self::new(pos, ButtonContent::Icon(image), styles) + } + + pub fn content(&self) -> &ButtonContent { + &self.content + } + + fn style(&self) -> &ButtonStyle { + match self.state { + State::Released => self.styles.normal, + State::Pressed => self.styles.active, + } + } + + fn set(&mut self, ctx: &mut EventCtx, state: State) { + if self.state != state { + self.state = state; + ctx.request_paint(); + } + } + + fn placement( + area: Rect, + pos: ButtonPos, + content: &ButtonContent, + styles: &ButtonStyleSheet, + ) -> (Rect, Point) { + let border_width = if styles.normal.border_horiz { 2 } else { 0 }; + let content_width = match content { + ButtonContent::Text(text) => styles.normal.font.text_width(text.as_ref()) - 1, + ButtonContent::Icon(_icon) => todo!(), + }; + let button_width = content_width + 2 * border_width; + let area = match pos { + ButtonPos::Left => area.split_left(button_width).0, + ButtonPos::Right => area.split_right(button_width).1, + }; + + let start_of_baseline = area.bottom_left() + Offset::new(border_width, -2); + + return (area, start_of_baseline); + } +} + +impl Component for Button +where + T: AsRef, +{ + type Msg = ButtonMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let (area, baseline) = Self::placement(bounds, self.pos, &self.content, &self.styles); + self.area = area; + self.baseline = baseline; + self.area + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + match event { + Event::Button(ButtonEvent::ButtonPressed(which)) if self.pos.hit(&which) => { + self.set(ctx, State::Pressed); + } + Event::Button(ButtonEvent::ButtonReleased(which)) if self.pos.hit(&which) => { + if matches!(self.state, State::Pressed) { + self.set(ctx, State::Released); + return Some(ButtonMsg::Clicked); + } + } + _ => {} + }; + None + } + + fn paint(&mut self) { + let style = self.style(); + + match &self.content { + ButtonContent::Text(text) => { + let background_color = style.text_color.neg(); + if style.border_horiz { + display::rect_fill_rounded1(self.area, background_color, theme::BG); + } else { + display::rect_fill(self.area, background_color) + } + + display::text( + self.baseline, + text.as_ref(), + style.font, + style.text_color, + background_color, + ); + } + ButtonContent::Icon(_image) => { + todo!(); + } + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Button +where + T: AsRef + crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Button"); + match &self.content { + ButtonContent::Text(text) => t.field("text", text), + ButtonContent::Icon(_) => t.symbol("icon"), + } + t.close(); + } +} + +#[derive(PartialEq, Eq)] +enum State { + Released, + Pressed, +} + +pub enum ButtonContent { + Text(T), + Icon(&'static [u8]), +} + +pub struct ButtonStyleSheet { + pub normal: &'static ButtonStyle, + pub active: &'static ButtonStyle, +} + +pub struct ButtonStyle { + pub font: Font, + pub text_color: Color, + pub border_horiz: bool, +} diff --git a/core/embed/rust/src/ui/model_tr/component/dialog.rs b/core/embed/rust/src/ui/model_tr/component/dialog.rs new file mode 100644 index 000000000..5f1dd5a3c --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/dialog.rs @@ -0,0 +1,96 @@ +use super::{ + button::{Button, ButtonMsg::Clicked}, + theme, +}; +use crate::ui::{ + component::{Child, Component, Event, EventCtx}, + geometry::Rect, +}; + +pub enum DialogMsg { + Content(T), + LeftClicked, + RightClicked, +} + +pub struct Dialog { + content: Child, + left_btn: Option>>, + right_btn: Option>>, +} + +impl Dialog +where + T: Component, + U: AsRef, +{ + pub fn new(content: T, left: Option>, right: Option>) -> Self { + Self { + content: Child::new(content), + left_btn: left.map(Child::new), + right_btn: right.map(Child::new), + } + } + + pub fn inner(&self) -> &T { + self.content.inner() + } +} + +impl Component for Dialog +where + T: Component, + U: AsRef, +{ + type Msg = DialogMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let button_height = theme::FONT_BOLD.line_height() + 2; + let (content_area, button_area) = bounds.split_bottom(button_height); + self.content.place(content_area); + self.left_btn.as_mut().map(|b| b.place(button_area)); + self.right_btn.as_mut().map(|b| b.place(button_area)); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(msg) = self.content.event(ctx, event) { + Some(DialogMsg::Content(msg)) + } else if let Some(Clicked) = self.left_btn.as_mut().and_then(|b| b.event(ctx, event)) { + Some(DialogMsg::LeftClicked) + } else if let Some(Clicked) = self.right_btn.as_mut().and_then(|b| b.event(ctx, event)) { + Some(DialogMsg::RightClicked) + } else { + None + } + } + + fn paint(&mut self) { + self.content.paint(); + if let Some(b) = self.left_btn.as_mut() { + b.paint(); + } + if let Some(b) = self.right_btn.as_mut() { + b.paint(); + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Dialog +where + T: crate::trace::Trace, + U: crate::trace::Trace + AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Dialog"); + t.field("content", &self.content); + if let Some(label) = &self.left_btn { + t.field("left", label); + } + if let Some(label) = &self.right_btn { + t.field("right", label); + } + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/component/frame.rs b/core/embed/rust/src/ui/model_tr/component/frame.rs new file mode 100644 index 000000000..f26447526 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/frame.rs @@ -0,0 +1,79 @@ +use super::theme; +use crate::ui::{ + component::{Child, Component, Event, EventCtx}, + display, + geometry::{Insets, Offset, Rect}, +}; + +pub struct Frame { + area: Rect, + title: U, + content: Child, +} + +impl Frame +where + T: Component, + U: AsRef, +{ + pub fn new(title: U, content: T) -> Self { + Self { + title, + area: Rect::zero(), + content: Child::new(content), + } + } + + pub fn inner(&self) -> &T { + self.content.inner() + } +} + +impl Component for Frame +where + T: Component, + U: AsRef, +{ + type Msg = T::Msg; + + fn place(&mut self, bounds: Rect) -> Rect { + const TITLE_SPACE: i32 = 4; + + let (title_area, content_area) = bounds.split_top(theme::FONT_BOLD.line_height()); + let content_area = content_area.inset(Insets::top(TITLE_SPACE)); + + self.area = title_area; + self.content.place(content_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.content.event(ctx, event) + } + + fn paint(&mut self) { + display::text( + self.area.bottom_left() - Offset::y(2), + self.title.as_ref(), + theme::FONT_BOLD, + theme::FG, + theme::BG, + ); + display::dotted_line(self.area.bottom_left(), self.area.width(), theme::FG); + self.content.paint(); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Frame +where + T: crate::trace::Trace, + U: crate::trace::Trace + AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("Frame"); + t.field("title", &self.title); + t.field("content", &self.content); + t.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 new file mode 100644 index 000000000..dc8c10edb --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -0,0 +1,11 @@ +mod button; +mod dialog; +mod frame; +mod page; + +use super::theme; + +pub use button::{Button, ButtonContent, ButtonMsg, ButtonPos, ButtonStyle, ButtonStyleSheet}; +pub use dialog::{Dialog, DialogMsg}; +pub use frame::Frame; +pub use page::ButtonPage; diff --git a/core/embed/rust/src/ui/model_tr/component/page.rs b/core/embed/rust/src/ui/model_tr/component/page.rs new file mode 100644 index 000000000..cf9598e72 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/page.rs @@ -0,0 +1,226 @@ +use crate::ui::{ + component::{Component, ComponentExt, Event, EventCtx, Never, Pad, PageMsg, Paginate}, + display::{self, Color}, + geometry::{Insets, Offset, Point, Rect}, +}; + +use super::{theme, Button, ButtonMsg, ButtonPos}; + +pub struct ButtonPage { + content: T, + scrollbar: ScrollBar, + pad: Pad, + prev: Button<&'static str>, + next: Button<&'static str>, + cancel: Button<&'static str>, + confirm: Button<&'static str>, +} + +impl ButtonPage +where + T: Paginate, + T: Component, +{ + pub fn new(content: T, background: Color) -> Self { + Self { + content, + scrollbar: ScrollBar::vertical(), + pad: Pad::with_background(background), + prev: Button::with_text(ButtonPos::Left, "BACK", theme::button_cancel()), + next: Button::with_text(ButtonPos::Right, "NEXT", theme::button_default()), + cancel: Button::with_text(ButtonPos::Left, "CANCEL", theme::button_cancel()), + confirm: Button::with_text(ButtonPos::Right, "CONFIRM", theme::button_default()), + } + } + + fn change_page(&mut self, ctx: &mut EventCtx, page: usize) { + // Change the page in the content, clear the background under it and make sure + // it gets completely repainted. + self.content.change_page(page); + self.content.request_complete_repaint(ctx); + self.pad.clear(); + } +} + +impl Component for ButtonPage +where + T: Component, + T: Paginate, +{ + type Msg = PageMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let button_height = theme::FONT_BOLD.line_height() + 2; + let (content_area, button_area) = bounds.split_bottom(button_height); + let (content_area, scrollbar_area) = content_area.split_right(ScrollBar::WIDTH); + let content_area = content_area.inset(Insets::top(1)); + self.pad.place(bounds); + self.content.place(content_area); + let page_count = self.content.page_count(); + self.scrollbar.set_count_and_active_page(page_count, 0); + self.scrollbar.place(scrollbar_area); + self.prev.place(button_area); + self.next.place(button_area); + self.cancel.place(button_area); + self.confirm.place(button_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.scrollbar.has_previous_page() { + if let Some(ButtonMsg::Clicked) = self.prev.event(ctx, event) { + // Scroll up. + self.scrollbar.go_to_previous_page(); + self.change_page(ctx, self.scrollbar.active_page); + return None; + } + } else if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) { + return Some(PageMsg::Controls(false)); + } + + if self.scrollbar.has_next_page() { + if let Some(ButtonMsg::Clicked) = self.next.event(ctx, event) { + // Scroll down. + self.scrollbar.go_to_next_page(); + self.change_page(ctx, self.scrollbar.active_page); + return None; + } + } else if let Some(ButtonMsg::Clicked) = self.confirm.event(ctx, event) { + return Some(PageMsg::Controls(true)); + } + + if let Some(msg) = self.content.event(ctx, event) { + return Some(PageMsg::Content(msg)); + } + None + } + + fn paint(&mut self) { + self.pad.paint(); + self.content.paint(); + self.scrollbar.paint(); + if self.scrollbar.has_previous_page() { + self.prev.paint(); + } else { + self.cancel.paint(); + } + if self.scrollbar.has_next_page() { + self.next.paint(); + } else { + self.confirm.paint(); + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonPage +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ButtonPage"); + t.field("active_page", &self.scrollbar.active_page); + t.field("page_count", &self.scrollbar.page_count); + t.field("content", &self.content); + t.close(); + } +} + +pub struct ScrollBar { + area: Rect, + page_count: usize, + active_page: usize, +} + +impl ScrollBar { + pub const WIDTH: i32 = 8; + pub const DOT_SIZE: Offset = Offset::new(4, 4); + pub const DOT_INTERVAL: i32 = 6; + + pub fn vertical() -> Self { + Self { + area: Rect::zero(), + page_count: 0, + active_page: 0, + } + } + + pub fn set_count_and_active_page(&mut self, page_count: usize, active_page: usize) { + self.page_count = page_count; + self.active_page = active_page; + } + + pub fn has_next_page(&self) -> bool { + self.active_page < self.page_count - 1 + } + + pub fn has_previous_page(&self) -> bool { + self.active_page > 0 + } + + pub fn go_to_next_page(&mut self) { + self.active_page = self.active_page.saturating_add(1).min(self.page_count - 1); + } + + pub fn go_to_previous_page(&mut self) { + self.active_page = self.active_page.saturating_sub(1); + } + + fn paint_dot(&self, active: bool, top_left: Point) { + let sides = [ + Rect::from_top_left_and_size(top_left + Offset::x(1), Offset::new(2, 1)), + Rect::from_top_left_and_size(top_left + Offset::y(1), Offset::new(1, 2)), + Rect::from_top_left_and_size( + top_left + Offset::new(1, Self::DOT_SIZE.y - 1), + Offset::new(2, 1), + ), + Rect::from_top_left_and_size( + top_left + Offset::new(Self::DOT_SIZE.x - 1, 1), + Offset::new(1, 2), + ), + ]; + for side in sides { + display::rect_fill(side, theme::FG) + } + if active { + display::rect_fill( + Rect::from_top_left_and_size(top_left, Self::DOT_SIZE).inset(Insets::uniform(1)), + theme::FG, + ) + } + } +} + +impl Component for ScrollBar { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.area + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + let count = self.page_count as i32; + let interval = { + let available_height = self.area.height(); + let naive_height = count * Self::DOT_INTERVAL; + if naive_height > available_height { + available_height / count + } else { + Self::DOT_INTERVAL + } + }; + let mut dot = Point::new( + self.area.center().x - Self::DOT_SIZE.x / 2, + self.area.center().y - (count / 2) * interval, + ); + for i in 0..self.page_count { + self.paint_dot(i == self.active_page, dot); + dot.y += interval + } + } +} diff --git a/core/embed/rust/src/ui/model_tr/constant.rs b/core/embed/rust/src/ui/model_tr/constant.rs new file mode 100644 index 000000000..1e01a9d84 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/constant.rs @@ -0,0 +1,13 @@ +use crate::ui::geometry::{Offset, Point, Rect}; + +pub const WIDTH: i32 = 128; +pub const HEIGHT: i32 = 128; +pub const LINE_SPACE: i32 = 1; + +pub const fn size() -> Offset { + Offset::new(WIDTH, HEIGHT) +} + +pub const fn screen() -> Rect { + Rect::from_top_left_and_size(Point::zero(), size()) +} diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs new file mode 100644 index 000000000..757d34d00 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -0,0 +1,237 @@ +use core::convert::TryInto; + +use crate::{ + error::Error, + micropython::{buffer::StrBuffer, map::Map, module::Module, obj::Obj, qstr::Qstr}, + ui::{ + component::{ + base::Component, + paginated::{PageMsg, Paginate}, + text::paragraphs::Paragraphs, + FormattedText, + }, + layout::{ + obj::{ComponentMsgObj, LayoutObj}, + result::{CANCELLED, CONFIRMED}, + }, + }, + util, +}; + +use super::{ + component::{Button, ButtonPage, ButtonPos, Frame}, + theme, +}; + +impl ComponentMsgObj for ButtonPage +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()), + } + } +} + +impl ComponentMsgObj for Frame +where + T: ComponentMsgObj, + U: AsRef, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + self.inner().msg_try_into_obj(msg) + } +} + +extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let action: Option = kwargs.get(Qstr::MP_QSTR_action)?.try_into_option()?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + let verb: Option = kwargs.get(Qstr::MP_QSTR_verb)?.try_into_option()?; + let verb_cancel: Option = + kwargs.get(Qstr::MP_QSTR_verb_cancel)?.try_into_option()?; + let reverse: bool = kwargs.get(Qstr::MP_QSTR_reverse)?.try_into()?; + + let format = match (&action, &description, reverse) { + (Some(_), Some(_), false) => "{bold}{action}\n\r{normal}{description}", + (Some(_), Some(_), true) => "{normal}{description}\n\r{bold}{action}", + (Some(_), None, _) => "{bold}{action}", + (None, Some(_), _) => "{normal}{description}", + _ => "", + }; + + let _left = verb_cancel + .map(|label| Button::with_text(ButtonPos::Left, label, theme::button_cancel())); + let _right = + verb.map(|label| Button::with_text(ButtonPos::Right, label, theme::button_default())); + + let obj = LayoutObj::new(Frame::new( + title, + ButtonPage::new( + FormattedText::new::(format) + .with("action", action.unwrap_or_default()) + .with("description", description.unwrap_or_default()), + theme::BG, + ), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_text(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let data: StrBuffer = kwargs.get(Qstr::MP_QSTR_data)?.try_into()?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + + let obj = LayoutObj::new(Frame::new( + title, + ButtonPage::new( + Paragraphs::new() + .add::( + theme::FONT_NORMAL, + description.unwrap_or_default(), + ) + .add::(theme::FONT_BOLD, data), + theme::BG, + ), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +#[no_mangle] +pub static mp_module_trezorui2: Module = obj_module! { + Qstr::MP_QSTR___name__ => Qstr::MP_QSTR_trezorui2.to_obj(), + + /// CONFIRMED: object + Qstr::MP_QSTR_CONFIRMED => CONFIRMED.as_obj(), + + /// CANCELLED: object + Qstr::MP_QSTR_CANCELLED => CANCELLED.as_obj(), + + /// def confirm_action( + /// *, + /// title: str, + /// action: str | None = None, + /// description: str | None = None, + /// verb: str | None = None, + /// verb_cancel: str | None = None, + /// hold: bool | None = None, + /// reverse: bool = False, + /// ) -> object: + /// """Confirm action.""" + Qstr::MP_QSTR_confirm_action => obj_fn_kw!(0, new_confirm_action).as_obj(), + + /// def confirm_text( + /// *, + /// title: str, + /// data: str, + /// description: str | None, + /// ) -> object: + /// """Confirm text.""" + Qstr::MP_QSTR_confirm_text => obj_fn_kw!(0, new_confirm_text).as_obj(), +}; + +#[cfg(test)] +mod tests { + use crate::{ + trace::Trace, + ui::{ + component::Component, + model_tr::{ + component::{Dialog, DialogMsg}, + constant, + }, + }, + }; + + use super::*; + + fn trace(val: &impl Trace) -> String { + let mut t = Vec::new(); + val.trace(&mut t); + String::from_utf8(t).unwrap() + } + + impl ComponentMsgObj for Dialog + where + T: ComponentMsgObj, + U: AsRef, + { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + DialogMsg::Content(c) => self.inner().msg_try_into_obj(c), + DialogMsg::LeftClicked => Ok(CANCELLED.as_obj()), + DialogMsg::RightClicked => Ok(CONFIRMED.as_obj()), + } + } + } + + #[test] + fn trace_example_layout() { + let mut layout = Dialog::new( + FormattedText::new::( + "Testing text layout, with some text, and some more text. And {param}", + ) + .with("param", "parameters!"), + Some(Button::with_text( + ButtonPos::Left, + "Left", + theme::button_cancel(), + )), + Some(Button::with_text( + ButtonPos::Right, + "Right", + theme::button_default(), + )), + ); + layout.place(constant::screen()); + assert_eq!( + trace(&layout), + r#" left: