diff --git a/core/embed/extmod/rustmods/modtrezorui2.c b/core/embed/extmod/rustmods/modtrezorui2.c index bef848c1f..c2dea4076 100644 --- a/core/embed/extmod/rustmods/modtrezorui2.c +++ b/core/embed/extmod/rustmods/modtrezorui2.c @@ -23,16 +23,37 @@ #include "librust.h" +#if TREZOR_MODEL == T /// def layout_new_example(text: str) -> None: /// """Example layout.""" STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_trezorui2_layout_new_example_obj, ui_layout_new_example); +#elif TREZOR_MODEL == 1 +/// def layout_new_confirm_action( +/// title: str, +/// action: str | None, +/// description: str | None, +/// verb: str | None, +/// verb_cancel: str | None, +/// hold: bool | None, +/// reverse: bool, +/// ) -> int: +/// """Example layout. All arguments must be passed as kwargs.""" +STATIC MP_DEFINE_CONST_FUN_OBJ_KW(mod_trezorui2_layout_new_confirm_action_obj, + 0, ui_layout_new_confirm_action); +#endif STATIC const mp_rom_map_elem_t mp_module_trezorui2_globals_table[] = { +#if TREZOR_MODEL == T {MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_trezorui2)}, {MP_ROM_QSTR(MP_QSTR_layout_new_example), MP_ROM_PTR(&mod_trezorui2_layout_new_example_obj)}, +#elif TREZOR_MODEL == 1 + {MP_ROM_QSTR(MP_QSTR_layout_new_confirm_action), + MP_ROM_PTR(&mod_trezorui2_layout_new_confirm_action_obj)}, +#endif + }; STATIC MP_DEFINE_CONST_DICT(mp_module_trezorui2_globals, diff --git a/core/embed/rust/librust.h b/core/embed/rust/librust.h index 26bd4d2dd..c1e092c8f 100644 --- a/core/embed/rust/librust.h +++ b/core/embed/rust/librust.h @@ -13,6 +13,8 @@ mp_obj_t protobuf_debug_msg_def_type(); #endif mp_obj_t ui_layout_new_example(mp_obj_t); +mp_obj_t ui_layout_new_confirm_action(size_t n_args, const mp_obj_t *args, + mp_map_t *kwargs); #ifdef TREZOR_EMULATOR mp_obj_t ui_debug_layout_type(); diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 5ba63f731..0e37e4b67 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -16,4 +16,11 @@ static void _librust_qstrs(void) { MP_QSTR_timer; MP_QSTR_paint; MP_QSTR_trace; + + MP_QSTR_title; + MP_QSTR_action; + MP_QSTR_description; + MP_QSTR_verb; + MP_QSTR_verb_cancel; + MP_QSTR_reverse; } diff --git a/core/embed/rust/src/micropython/obj.rs b/core/embed/rust/src/micropython/obj.rs index 1fe61e763..7a5b4db85 100644 --- a/core/embed/rust/src/micropython/obj.rs +++ b/core/embed/rust/src/micropython/obj.rs @@ -380,6 +380,23 @@ where } } +impl Obj { + /// Conversion to Rust types with typed `None`. + pub fn try_into_option(self) -> Result, Error> + where + T: TryFrom, + >::Error: Into, + { + if self == Obj::const_none() { + return Ok(None); + } + match self.try_into() { + Ok(x) => Ok(Some(x)), + Err(e) => Err(e.into()), + } + } +} + impl From for Error { fn from(_: TryFromIntError) -> Self { Self::OutOfRange diff --git a/core/embed/rust/src/ui/component/model_t1/mod.rs b/core/embed/rust/src/ui/component/model_t1/mod.rs deleted file mode 100644 index 71f6f2a97..000000000 --- a/core/embed/rust/src/ui/component/model_t1/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod constants; -pub mod theme; - diff --git a/core/embed/rust/src/ui/display.rs b/core/embed/rust/src/ui/display.rs index ad165363d..b25648f91 100644 --- a/core/embed/rust/src/ui/display.rs +++ b/core/embed/rust/src/ui/display.rs @@ -60,6 +60,27 @@ pub fn icon(center: Point, data: &[u8], fg_color: Color, bg_color: Color) { ); } +// Used on T1 only. +pub fn rounded_rect1(r: Rect, fg_color: Color, bg_color: Color) { + display::bar(r.x0, r.y0, r.width(), r.height(), fg_color.into()); + let corners = [ + r.top_left(), + r.top_right() + Offset::new(-1, 0), + r.bottom_right() + Offset::new(-1, -1), + r.bottom_left() + Offset::new(0, -1), + ]; + for p in corners.iter() { + display::bar(p.x, p.y, 1, 1, bg_color.into()); + } +} + +// Used on T1 only. +pub fn dotted_line(start: Point, width: i32, color: Color) { + for x in (start.x..width).step_by(2) { + display::bar(x, start.y, 1, 1, color.into()); + } +} + pub fn text(baseline: Point, text: &[u8], font: Font, fg_color: Color, bg_color: Color) { display::text( baseline.x, @@ -138,6 +159,10 @@ impl Color { pub fn to_u16(self) -> u16 { self.0 } + + pub fn neg(self) -> Self { + Self(!self.0) + } } impl From for Color { diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index 821560b3f..83a89a87e 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -180,6 +180,54 @@ impl Rect { y1: self.y1, } } + + pub fn hsplit(self, height: i32) -> (Self, Self) { + let height = if height.is_positive() { + height + } else { + self.height() + height + }; + + let top = Self { + x0: self.x0, + y0: self.y0, + x1: self.x1, + y1: self.y0 + height, + }; + + let bottom = Self { + x0: self.x0, + y0: top.y0 + height, + x1: self.x1, + y1: self.y1, + }; + + (top, bottom) + } + + pub fn vsplit(self, width: i32) -> (Self, Self) { + let width = if width.is_positive() { + width + } else { + self.width() + width + }; + + let left = Self { + x0: self.x0, + y0: self.y0, + x1: self.x0 + width, + y1: self.y1, + }; + + let right = Self { + x0: left.x0 + width, + y0: self.y0, + x1: self.x1, + y1: self.y1, + }; + + (left, right) + } } #[derive(Copy, Clone, PartialEq, Eq)] diff --git a/core/embed/rust/src/ui/model_t1/component/button.rs b/core/embed/rust/src/ui/model_t1/component/button.rs new file mode 100644 index 000000000..f1f081930 --- /dev/null +++ b/core/embed/rust/src/ui/model_t1/component/button.rs @@ -0,0 +1,193 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + display::{self, Color, Font}, + geometry::{Offset, Point, Rect}, +}; + +use super::{ + event::{ButtonEvent, T1Button}, + theme, +}; + +pub enum ButtonMsg { + Clicked, +} + +#[derive(Copy, Clone)] +pub enum ButtonPos { + Left, + Right, +} + +impl ButtonPos { + fn hit(&self, b: &T1Button) -> bool { + matches!( + (self, b), + (Self::Left, T1Button::Left) | (Self::Right, T1Button::Right) + ) + } +} + +pub struct Button { + area: Rect, + pos: ButtonPos, + baseline: Point, + content: ButtonContent, + styles: ButtonStyleSheet, + state: State, +} + +impl> Button { + pub fn new( + area: Rect, + pos: ButtonPos, + content: ButtonContent, + styles: ButtonStyleSheet, + ) -> Self { + let (area, baseline) = Self::placement(area, pos, &content, &styles); + Self { + area, + pos, + baseline, + content, + styles, + state: State::Released, + } + } + + pub fn with_text(area: Rect, pos: ButtonPos, text: T, styles: ButtonStyleSheet) -> Self { + Self::new(area, pos, ButtonContent::Text(text), styles) + } + + pub fn with_icon( + area: Rect, + pos: ButtonPos, + image: &'static [u8], + styles: ButtonStyleSheet, + ) -> Self { + Self::new(area, 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) => display::text_width(text.as_ref(), styles.normal.font) - 1, + ButtonContent::Icon(_icon) => todo!(), + }; + let button_width = content_width + 2 * border_width; + let area = match pos { + ButtonPos::Left => area.vsplit(button_width).0, + ButtonPos::Right => area.vsplit(-button_width).1, + }; + + let start_of_baseline = area.bottom_left() + Offset::new(border_width, -2); + + return (area, start_of_baseline); + } +} + +impl> Component for Button { + type Msg = ButtonMsg; + + 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::rounded_rect1(self.area, background_color, theme::BG); + } else { + display::rect(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<[u8]> + 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_t1/component/dialog.rs b/core/embed/rust/src/ui/model_t1/component/dialog.rs new file mode 100644 index 000000000..02af53a1b --- /dev/null +++ b/core/embed/rust/src/ui/model_t1/component/dialog.rs @@ -0,0 +1,117 @@ +use super::{ + button::{Button, ButtonMsg::Clicked, ButtonPos}, + theme, +}; +use crate::ui::{ + component::{Child, Component, Event, EventCtx}, + display, + geometry::{Offset, Rect}, +}; + +pub enum DialogMsg { + Content(T), + LeftClicked, + RightClicked, +} + +pub struct Dialog { + header: Option<(U, Rect)>, + content: Child, + left_btn: Option>>, + right_btn: Option>>, +} + +impl> Dialog { + pub fn new( + area: Rect, + content: impl FnOnce(Rect) -> T, + left: Option Button>, + right: Option Button>, + header: Option, + ) -> Self { + let (header_area, content_area, button_area) = Self::areas(area, &header); + let content = Child::new(content(content_area)); + let left_btn = left.map(|f| Child::new(f(button_area, ButtonPos::Left))); + let right_btn = right.map(|f| Child::new(f(button_area, ButtonPos::Right))); + Self { + header: header.zip(header_area), + content, + left_btn, + right_btn, + } + } + + fn paint_header(&self) { + if let Some((title, area)) = &self.header { + display::text( + area.bottom_left() + Offset::new(0, -2), + title.as_ref(), + theme::FONT_BOLD, + theme::FG, + theme::BG, + ); + display::dotted_line(area.bottom_left(), area.width(), theme::FG) + } + } + + fn areas(area: Rect, header: &Option) -> (Option, Rect, Rect) { + const HEADER_SPACE: i32 = 4; + let button_height = theme::FONT_BOLD.line_height() + 2; + let header_height = theme::FONT_BOLD.line_height(); + + let (content_area, button_area) = area.hsplit(-button_height); + if header.is_none() { + (None, content_area, button_area) + } else { + let (header_area, content_area) = content_area.hsplit(header_height); + let (_space, content_area) = content_area.hsplit(HEADER_SPACE); + (Some(header_area), content_area, button_area) + } + } +} + +impl> Component for Dialog { + type Msg = DialogMsg; + + 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.paint_header(); + 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<[u8]>, +{ + 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_t1/component/mod.rs b/core/embed/rust/src/ui/model_t1/component/mod.rs new file mode 100644 index 000000000..9a4eb1129 --- /dev/null +++ b/core/embed/rust/src/ui/model_t1/component/mod.rs @@ -0,0 +1,7 @@ +mod button; +mod dialog; + +use super::{event, 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 index 5676592e5..49ad7501e 100644 --- a/core/embed/rust/src/ui/model_t1/event.rs +++ b/core/embed/rust/src/ui/model_t1/event.rs @@ -13,7 +13,7 @@ pub enum ButtonEvent { } impl ButtonEvent { - pub fn new(event: u32, button: u32, _unused: u32) -> Result { + pub fn new(event: u32, button: u32) -> Result { let button = match button { 0 => T1Button::Left, 1 => T1Button::Right, diff --git a/core/embed/rust/src/ui/model_t1/layout.rs b/core/embed/rust/src/ui/model_t1/layout.rs new file mode 100644 index 000000000..078a43fce --- /dev/null +++ b/core/embed/rust/src/ui/model_t1/layout.rs @@ -0,0 +1,146 @@ +use core::convert::{TryFrom, TryInto}; + +use crate::{ + error::Error, + micropython::{buffer::Buffer, map::Map, obj::Obj, qstr::Qstr}, + ui::{ + component::{Child, Text}, + display, + layout::obj::LayoutObj, + }, + util, +}; + +use super::{ + component::{Button, Dialog, DialogMsg}, + theme, +}; + +impl TryFrom> for Obj +where + Obj: TryFrom, + Error: From<>::Error>, +{ + type Error = Error; + + fn try_from(val: DialogMsg) -> Result { + match val { + DialogMsg::Content(c) => Ok(c.try_into()?), + DialogMsg::LeftClicked => 1.try_into(), + DialogMsg::RightClicked => 2.try_into(), + } + } +} + +#[no_mangle] +extern "C" fn ui_layout_new_confirm_action( + n_args: usize, + args: *const Obj, + kwargs: *const Map, +) -> Obj { + let block = |_args: &[Obj], kwargs: &Map| { + let title: Buffer = 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| |area, pos| Button::with_text(area, pos, label, theme::button_cancel())); + let right = verb + .map(|label| |area, pos| Button::with_text(area, pos, label, theme::button_default())); + + let obj = LayoutObj::new(Child::new(Dialog::new( + display::screen(), + |area| { + Text::new::(area, format) + .with(b"action", action.unwrap_or("".into())) + .with(b"description", description.unwrap_or("".into())) + }, + left, + right, + Some(title), + )))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +#[cfg(test)] +mod tests { + use crate::trace::{Trace, Tracer}; + + use super::*; + + impl Tracer for Vec { + fn bytes(&mut self, b: &[u8]) { + self.extend(b) + } + + fn string(&mut self, s: &str) { + self.extend(s.as_bytes()) + } + + fn symbol(&mut self, name: &str) { + self.extend(name.as_bytes()) + } + + fn open(&mut self, name: &str) { + self.extend(b"<"); + self.extend(name.as_bytes()); + self.extend(b" "); + } + + fn field(&mut self, name: &str, value: &dyn Trace) { + self.extend(name.as_bytes()); + self.extend(b":"); + value.trace(self); + self.extend(b" "); + } + + fn close(&mut self) { + self.extend(b">") + } + } + + fn trace(val: &impl Trace) -> String { + let mut t = Vec::new(); + val.trace(&mut t); + String::from_utf8(t).unwrap() + } + + #[test] + fn trace_example_layout() { + let layout = Child::new(Dialog::new( + display::screen(), + |area| { + Text::new( + area, + "Testing text layout, with some text, and some more text. And {param}", + ) + .with(b"param", b"parameters!") + }, + Some(|area, pos| Button::with_text(area, pos, "Left", theme::button_cancel())), + Some(|area, pos| Button::with_text(area, pos, "Right", theme::button_default())), + None, + )); + assert_eq!( + trace(&layout), + r#" left: