diff --git a/core/embed/rust/src/ui/component/text/layout.rs b/core/embed/rust/src/ui/component/text/layout.rs index a892113985..23e3f0b9f7 100644 --- a/core/embed/rust/src/ui/component/text/layout.rs +++ b/core/embed/rust/src/ui/component/text/layout.rs @@ -373,7 +373,7 @@ pub struct TextRenderer; impl LayoutSink for TextRenderer { fn text(&mut self, cursor: Point, layout: &TextLayout, text: &str) { - display::text( + display::text_left( cursor, text, layout.style.text_font, @@ -383,7 +383,7 @@ impl LayoutSink for TextRenderer { } fn hyphen(&mut self, cursor: Point, layout: &TextLayout) { - display::text( + display::text_left( cursor, "-", layout.style.text_font, @@ -403,7 +403,7 @@ impl LayoutSink for TextRenderer { layout.style.background_color, ); } else { - display::text( + display::text_left( cursor, "...", layout.style.text_font, diff --git a/core/embed/rust/src/ui/display/mod.rs b/core/embed/rust/src/ui/display/mod.rs index 2f7b609eed..ea916c7a94 100644 --- a/core/embed/rust/src/ui/display/mod.rs +++ b/core/embed/rust/src/ui/display/mod.rs @@ -6,6 +6,8 @@ pub mod loader; #[cfg(feature = "jpeg")] pub mod tjpgd; +use heapless::String; + use super::{ constant, geometry::{Offset, Point, Rect}, @@ -948,11 +950,62 @@ pub fn paint_point(point: &Point, color: Color) { display::bar(point.x, point.y, 1, 1, color.into()); } +/// Display QR code pub fn qrcode(center: Point, data: &str, max_size: u32, case_sensitive: bool) -> Result<(), Error> { qr::render_qrcode(center.x, center.y, data, max_size, case_sensitive) } -pub fn text(baseline: Point, text: &str, font: Font, fg_color: Color, bg_color: Color) { +/// Draws longer multiline texts inside an area. +/// Does not add any characters on the line boundaries. +/// +/// If it fits, returns the rest of the area. +/// If it does not fit, returns `None`. +pub fn text_multiline( + area: Rect, + text: &str, + font: Font, + fg_color: Color, + bg_color: Color, +) -> Option { + let line_height = font.line_height(); + let characters_overall = text.chars().count(); + let mut taken_from_top = 0; + let mut characters_drawn = 0; + 'lines: loop { + let baseline = area.top_left() + Offset::y(line_height + taken_from_top); + if !area.contains(baseline) { + // The whole area was consumed. + return None; + } + let mut line_text: String<50> = String::new(); + 'characters: loop { + if let Some(character) = text.chars().nth(characters_drawn) { + unwrap!(line_text.push(character)); + characters_drawn += 1; + } else { + // No more characters to draw. + break 'characters; + } + if font.text_width(&line_text) > area.width() { + // Cannot fit on the line anymore. + line_text.pop(); + characters_drawn -= 1; + break 'characters; + } + } + text_left(baseline, &line_text, font, fg_color, bg_color); + if characters_drawn == characters_overall { + // No more lines to draw. + break 'lines; + } + taken_from_top += line_height; + } + // Some of the area was unused and is free to draw some further text. + Some(area.split_top(taken_from_top).1) +} + +/// Display text left-alligned to a certain Point +pub fn text_left(baseline: Point, text: &str, font: Font, fg_color: Color, bg_color: Color) { display::text( baseline.x, baseline.y, @@ -963,6 +1016,7 @@ pub fn text(baseline: Point, text: &str, font: Font, fg_color: Color, bg_color: ); } +/// Display text centered around a certain Point pub fn text_center(baseline: Point, text: &str, font: Font, fg_color: Color, bg_color: Color) { let w = font.text_width(text); display::text( @@ -975,6 +1029,7 @@ pub fn text_center(baseline: Point, text: &str, font: Font, fg_color: Color, bg_ ); } +/// Display text right-alligned to a certain Point pub fn text_right(baseline: Point, text: &str, font: Font, fg_color: Color, bg_color: Color) { let w = font.text_width(text); display::text( 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 02d4d6ca73..ea6332b044 100644 --- a/core/embed/rust/src/ui/model_t1/component/button.rs +++ b/core/embed/rust/src/ui/model_t1/component/button.rs @@ -137,7 +137,7 @@ where display::rect_fill(self.area, background_color) } - display::text( + display::text_left( self.baseline, text.as_ref(), style.font, diff --git a/core/embed/rust/src/ui/model_t1/component/frame.rs b/core/embed/rust/src/ui/model_t1/component/frame.rs index ae72d8ab23..28fe85e43d 100644 --- a/core/embed/rust/src/ui/model_t1/component/frame.rs +++ b/core/embed/rust/src/ui/model_t1/component/frame.rs @@ -52,7 +52,7 @@ where } fn paint(&mut self) { - display::text( + display::text_left( self.area.bottom_left() - Offset::y(2), self.title.as_ref(), Font::BOLD, diff --git a/core/embed/rust/src/ui/model_tr/component/button.rs b/core/embed/rust/src/ui/model_tr/component/button.rs index 7a9635067c..9f40a4f772 100644 --- a/core/embed/rust/src/ui/model_tr/component/button.rs +++ b/core/embed/rust/src/ui/model_tr/component/button.rs @@ -249,7 +249,7 @@ where match &self.content { ButtonContent::Text(text) => { - display::text( + display::text_left( self.get_text_baseline(&style), text.as_ref(), style.font, diff --git a/core/embed/rust/src/ui/model_tr/component/common.rs b/core/embed/rust/src/ui/model_tr/component/common.rs index 696a752ebf..6e4506c0d5 100644 --- a/core/embed/rust/src/ui/model_tr/component/common.rs +++ b/core/embed/rust/src/ui/model_tr/component/common.rs @@ -9,12 +9,12 @@ use super::theme; /// Display white text on black background pub fn display>(baseline: Point, text: &T, font: Font) { - display::text(baseline, text.as_ref(), font, theme::FG, theme::BG); + display::text_left(baseline, text.as_ref(), font, theme::FG, theme::BG); } /// Display black text on white background pub fn display_inverse>(baseline: Point, text: &T, font: Font) { - display::text(baseline, text.as_ref(), font, theme::BG, theme::FG); + display::text_left(baseline, text.as_ref(), font, theme::BG, theme::FG); } /// Display white text on black background, diff --git a/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs b/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs index 0aa74a7e84..5d54233c6d 100644 --- a/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs +++ b/core/embed/rust/src/ui/model_tr/component/flow_pages_poc_helpers.rs @@ -500,7 +500,7 @@ impl LayoutSink for TextRenderer { match layout.style.line_alignment { LineAlignment::Left => { - display::text( + display::text_left( cursor, text, layout.style.text_font, @@ -532,7 +532,7 @@ impl LayoutSink for TextRenderer { } fn hyphen(&mut self, cursor: Point, layout: &TextLayout) { - display::text( + display::text_left( cursor, "-", layout.style.text_font, @@ -552,7 +552,7 @@ impl LayoutSink for TextRenderer { layout.style.background_color, ); } else { - display::text( + display::text_left( cursor, "...", layout.style.text_font, diff --git a/core/embed/rust/src/ui/model_tr/component/mod.rs b/core/embed/rust/src/ui/model_tr/component/mod.rs index a54a6d08da..ec0abb2c98 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -15,6 +15,7 @@ mod loader; mod page; mod passphrase; mod pin; +mod qr_code; mod result_anim; mod result_popup; mod scrollbar; @@ -43,6 +44,7 @@ pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; pub use page::ButtonPage; pub use passphrase::{PassphraseEntry, PassphraseEntryMsg}; pub use pin::{PinEntry, PinEntryMsg}; +pub use qr_code::{QRCodePage, QRCodePageMessage}; pub use result_anim::{ResultAnim, ResultAnimMsg}; pub use result_popup::{ResultPopup, ResultPopupMsg}; pub use scrollbar::ScrollBar; diff --git a/core/embed/rust/src/ui/model_tr/component/qr_code.rs b/core/embed/rust/src/ui/model_tr/component/qr_code.rs new file mode 100644 index 0000000000..fc69ab9d12 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/qr_code.rs @@ -0,0 +1,113 @@ +use crate::ui::{ + component::{Child, Component, Event, EventCtx}, + display::{self, Font}, + geometry::{Rect}, +}; + +use super::{theme, ButtonController, ButtonControllerMsg, ButtonLayout, ButtonPos}; + +pub enum QRCodePageMessage { + Confirmed, + Cancelled, +} + +pub struct QRCodePage { + title: T, + title_area: Rect, + qr_code: F, + buttons: Child>, +} + +impl QRCodePage +where + T: AsRef + Clone, +{ + pub fn new(title: T, qr_code: F, btn_layout: ButtonLayout) -> Self { + Self { + title, + title_area: Rect::zero(), + qr_code, + buttons: Child::new(ButtonController::new(btn_layout)), + } + } +} + +impl Component for QRCodePage +where + T: AsRef + Clone, + F: Component, +{ + type Msg = QRCodePageMessage; + + fn place(&mut self, bounds: Rect) -> Rect { + let (content_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT); + let (qr_code_area, title_area) = content_area.split_left(theme::QR_SIDE_MAX); + self.title_area = title_area; + self.qr_code.place(qr_code_area); + self.buttons.place(button_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let button_event = self.buttons.event(ctx, event); + + if let Some(ButtonControllerMsg::Triggered(pos)) = button_event { + match pos { + ButtonPos::Left => { + return Some(QRCodePageMessage::Cancelled); + } + ButtonPos::Right => { + return Some(QRCodePageMessage::Confirmed); + } + _ => {} + } + } + + None + } + + fn paint(&mut self) { + self.qr_code.paint(); + // TODO: add the Label from Suite + display::text_multiline( + self.title_area, + self.title.as_ref(), + Font::MONO, + theme::FG, + theme::BG, + ); + self.buttons.paint(); + } +} + +#[cfg(feature = "ui_debug")] +use super::ButtonAction; +#[cfg(feature = "ui_debug")] +use heapless::String; + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for QRCodePage +where + T: AsRef + Clone, +{ + fn get_btn_action(&self, pos: ButtonPos) -> String<25> { + match pos { + ButtonPos::Left => ButtonAction::Cancel.string(), + ButtonPos::Right => ButtonAction::Confirm.string(), + ButtonPos::Middle => ButtonAction::empty(), + } + } + + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("QRCodePage"); + t.kw_pair("active_page", "0"); + t.kw_pair("page_count", "1"); + self.report_btn_actions(t); + t.content_flag(); + t.string("QR CODE"); + t.string(self.title.as_ref()); + t.content_flag(); + t.field("buttons", &self.buttons); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index f5eda3d354..5a87cd3327 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -18,6 +18,7 @@ use crate::{ component::{ base::{Component, ComponentExt}, paginated::{PageMsg, Paginate}, + painter, text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecLong, Paragraphs, VecExt}, FormattedText, }, @@ -35,7 +36,7 @@ use super::{ component::{ Bip39Entry, Bip39EntryMsg, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, Flow, FlowMsg, FlowPages, Frame, Page, PassphraseEntry, PassphraseEntryMsg, PinEntry, - PinEntryMsg, ShareWords, SimpleChoice, SimpleChoiceMsg, + PinEntryMsg, QRCodePage, QRCodePageMessage, ShareWords, SimpleChoice, SimpleChoiceMsg, }, theme, }; @@ -68,6 +69,19 @@ where } } +impl ComponentMsgObj for QRCodePage +where + T: AsRef + Clone, + F: Component, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + QRCodePageMessage::Confirmed => Ok(CONFIRMED.as_obj()), + QRCodePageMessage::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + impl ComponentMsgObj for PinEntry { fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { match msg { @@ -132,22 +146,26 @@ extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut M // TODO: could be replaced by Flow with one element after it supports pagination let format = match (&action, &description, reverse) { - (Some(_), Some(_), false) => "{Font::bold}{action}\n\r{Font::normal}{description}", - (Some(_), Some(_), true) => "{Font::normal}{description}\n\r{Font::bold}{action}", - (Some(_), None, _) => "{Font::bold}{action}", - (None, Some(_), _) => "{Font::normal}{description}", + (Some(_), Some(_), false) => "{bold}{action}\n\r{mono}{description}", + (Some(_), Some(_), true) => "{mono}{description}\n\r{bold}{action}", + (Some(_), None, _) => "{bold}{action}", + (None, Some(_), _) => "{mono}{description}", _ => "", }; - let verb_cancel = verb_cancel.unwrap_or_default(); - let verb = verb.unwrap_or_default(); - - let cancel_btn = if verb_cancel.len() > 0 { - Some(ButtonDetails::cancel_icon()) + // Left button - icon, text or nothing. + let cancel_btn = if let Some(verb_cancel) = verb_cancel { + if verb_cancel.len() > 0 { + Some(ButtonDetails::text(verb_cancel)) + } else { + Some(ButtonDetails::cancel_icon()) + } } else { None }; + // Right button - text or nothing. + let verb = verb.unwrap_or_default(); let mut confirm_btn = if verb.len() > 0 { Some(ButtonDetails::text(verb)) } else { @@ -350,6 +368,29 @@ extern "C" fn confirm_total(n_args: usize, args: *const Obj, kwargs: *mut Map) - unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } +extern "C" fn show_qr(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()?; + let address: StrBuffer = kwargs.get(Qstr::MP_QSTR_address)?.try_into()?; + let verb_cancel: StrBuffer = kwargs.get(Qstr::MP_QSTR_verb_cancel)?.try_into()?; + let case_sensitive: bool = kwargs.get(Qstr::MP_QSTR_case_sensitive)?.try_into()?; + + let verb: StrBuffer = "CONFIRM".into(); + + let qr_code = painter::qrcode_painter(address, theme::QR_SIDE_MAX as u32, case_sensitive); + + let btn_layout = ButtonLayout::new( + Some(ButtonDetails::text(verb_cancel)), + None, + Some(ButtonDetails::text(verb)), + ); + + let obj = LayoutObj::new(QRCodePage::new(title, qr_code, btn_layout))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + /// General pattern of most tutorial screens. /// (title, text, btn_layout, btn_actions) fn tutorial_screen( @@ -611,6 +652,16 @@ pub static mp_module_trezorui2: Module = obj_module! { /// """Confirm summary of a transaction. Specific for model R.""" Qstr::MP_QSTR_confirm_total_r => obj_fn_kw!(0, confirm_total).as_obj(), + /// def show_qr( + /// *, + /// title: str, + /// address: str, + /// verb_cancel: str, + /// case_sensitive: bool, + /// ) -> object: + /// """Show QR code.""" + Qstr::MP_QSTR_show_qr => obj_fn_kw!(0, show_qr).as_obj(), + /// def tutorial() -> object: /// """Show user how to interact with the device.""" Qstr::MP_QSTR_tutorial => obj_fn_kw!(0, tutorial).as_obj(), diff --git a/core/embed/rust/src/ui/model_tr/theme.rs b/core/embed/rust/src/ui/model_tr/theme.rs index e4395ccce6..4db64101de 100644 --- a/core/embed/rust/src/ui/model_tr/theme.rs +++ b/core/embed/rust/src/ui/model_tr/theme.rs @@ -80,3 +80,6 @@ pub const ICON_WARNING: IconAndName = pub const BUTTON_CONTENT_HEIGHT: i16 = 7; pub const BUTTON_OUTLINE: i16 = 3; pub const BUTTON_HEIGHT: i16 = BUTTON_CONTENT_HEIGHT + 2 * BUTTON_OUTLINE; + +// Full-size QR code. +pub const QR_SIDE_MAX: i16 = 64 - BUTTON_HEIGHT; 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 7b2c802749..4ea7d58692 100644 --- a/core/embed/rust/src/ui/model_tt/component/button.rs +++ b/core/embed/rust/src/ui/model_tt/component/button.rs @@ -189,7 +189,7 @@ impl Button { let start_of_baseline = self.area.center() + Offset::new(-width / 2, height / 2) + Offset::y(Self::BASELINE_OFFSET); - display::text( + display::text_left( start_of_baseline, text, style.font, diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs index a675a794ec..06bd576d32 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs @@ -109,7 +109,7 @@ impl Component for Bip39Input { // Content starts in the left-center point, offset by 16px to the right and 8px // to the bottom. let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8); - display::text( + display::text_left( text_baseline, text, style.font, @@ -120,7 +120,7 @@ impl Component for Bip39Input { // Paint the rest of the suggested dictionary word. if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) { let word_baseline = text_baseline + Offset::new(width, 0); - display::text( + display::text_left( word_baseline, word, style.font, diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs index df0ece4c3f..95c1f3c215 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs @@ -327,7 +327,7 @@ impl Component for Input { let ellipsis_width = style.text_font.text_width(ellipsis); // Drawing the ellipsis and moving the baseline for the rest of the text. - display::text( + display::text_left( text_baseline, ellipsis, style.text_font, @@ -346,7 +346,7 @@ impl Component for Input { &text[text.len() - chars_from_right..] }; - display::text( + display::text_left( text_baseline, text_to_display, style.text_font, diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs index b448a54c78..e462662268 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs @@ -158,7 +158,7 @@ impl Component for Slip39Input { .assert_if_debugging_ui("Text buffer is too small"); } } - display::text( + display::text_left( text_baseline, text.as_str(), style.font, diff --git a/core/src/trezor/ui/layouts/tr/__init__.py b/core/src/trezor/ui/layouts/tr/__init__.py index 7c26998482..b0842c50a7 100644 --- a/core/src/trezor/ui/layouts/tr/__init__.py +++ b/core/src/trezor/ui/layouts/tr/__init__.py @@ -425,7 +425,7 @@ async def get_bool( data: str, description: str | None = None, verb: str | None = "CONFIRM", - verb_cancel: str | None = "CANCEL", + verb_cancel: str | None = "", hold: bool = False, br_code: ButtonRequestType = ButtonRequestType.Other, ) -> bool: @@ -585,16 +585,27 @@ async def confirm_path_warning( ) +def _show_xpub(xpub: str, title: str, cancel: str) -> ui.Layout: + content = RustLayout( + trezorui2.confirm_text( + title=title.upper(), + data=xpub, + # verb_cancel=cancel, + ) + ) + return content + + async def show_xpub( ctx: wire.GenericContext, xpub: str, title: str, cancel: str ) -> None: - return await _placeholder_confirm( - ctx=ctx, - br_type="show_xpub", - title=title.upper(), - data=xpub, - description="", - br_code=ButtonRequestType.PublicKey, + await raise_if_cancelled( + interact( + ctx, + _show_xpub(xpub, title, cancel), + "show_xpub", + ButtonRequestType.PublicKey, + ) ) @@ -611,20 +622,62 @@ async def show_address( address_extra: str | None = None, title_qr: str | None = None, ) -> None: - text = "" + is_multisig = len(xpubs) > 0 + # TODO: replace with confirm_blob + data = address if network: - text += f"{network} network\n" + data += f"\n\n{network}" if address_extra: - text += f"{address_extra}\n" - text += address - return await _placeholder_confirm( - ctx=ctx, - br_type="show_address", - title=title.upper(), - data=text, - description="", - br_code=ButtonRequestType.Address, - ) + data += f"\n\n{address_extra}" + while True: + result = await interact( + ctx, + RustLayout( + trezorui2.confirm_action( + title=title.upper(), + action=data, + description=None, + verb="CONFIRM", + verb_cancel="QR", + reverse=False, + hold=False, + ) + ), + "show_address", + ButtonRequestType.Address, + ) + if result is trezorui2.CONFIRMED: + break + + result = await interact( + ctx, + RustLayout( + trezorui2.show_qr( + address=address if address_qr is None else address_qr, + case_sensitive=case_sensitive, + title=title.upper() if title_qr is None else title_qr.upper(), + verb_cancel="XPUBs" if is_multisig else "ADDRESS", + ) + ), + "show_qr", + ButtonRequestType.Address, + ) + if result is trezorui2.CONFIRMED: + break + + if is_multisig: + for i, xpub in enumerate(xpubs): + cancel = "NEXT" if i < len(xpubs) - 1 else "ADDRESS" + title_xpub = f"XPUB #{i + 1}" + title_xpub += " (yours)" if i == multisig_index else " (cosigner)" + result = await interact( + ctx, + _show_xpub(xpub, title=title_xpub, cancel=cancel), + "show_xpub", + ButtonRequestType.PublicKey, + ) + if result is trezorui2.CONFIRMED: + return def show_pubkey(