From e1252e9ee9f9a2415f29a4e47d15b80ad34f1f1b Mon Sep 17 00:00:00 2001 From: grdddj Date: Thu, 13 Apr 2023 15:26:19 +0200 Subject: [PATCH] TR-core/rust: implement Coinjoin progress screen --- core/embed/rust/src/ui/display/mod.rs | 90 ++++++++++++----- .../model_tr/component/coinjoin_progress.rs | 83 ++++++++++++++++ .../rust/src/ui/model_tr/component/mod.rs | 2 + .../src/ui/model_tr/component/share_words.rs | 19 ++-- core/embed/rust/src/ui/model_tr/layout.rs | 98 +++++++++++-------- core/src/trezor/ui/layouts/tr/homescreen.py | 7 +- tests/click_tests/reset.py | 8 +- tests/common.py | 8 +- 8 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 core/embed/rust/src/ui/model_tr/component/coinjoin_progress.rs diff --git a/core/embed/rust/src/ui/display/mod.rs b/core/embed/rust/src/ui/display/mod.rs index d90a81f0c8..430c6bde62 100644 --- a/core/embed/rust/src/ui/display/mod.rs +++ b/core/embed/rust/src/ui/display/mod.rs @@ -7,9 +7,11 @@ pub mod toif; use heapless::String; +use core::fmt::Write; + use super::{ constant, - geometry::{Offset, Point, Rect}, + geometry::{Alignment, Offset, Point, Rect}, }; #[cfg(feature = "dma2d")] use crate::trezorhal::{ @@ -853,50 +855,86 @@ pub fn paint_point(point: &Point, color: Color) { } /// Draws longer multiline texts inside an area. -/// Does not add any characters on the line boundaries. +/// Splits lines on word boundaries/whitespace. +/// When a word is too long to fit one line, splitting +/// it on multiple lines with "-" at the line-ends. /// /// If it fits, returns the rest of the area. /// If it does not fit, returns `None`. -pub fn text_multiline( +pub fn text_multiline_split_words( area: Rect, text: &str, font: Font, fg_color: Color, bg_color: Color, + alignment: Alignment, ) -> Option { let line_height = font.line_height(); - let characters_overall = text.chars().count(); let mut taken_from_top = 0; - let mut characters_drawn = 0; + let mut chars_processed = 0; + + let mut text_iter = text.split_whitespace(); + let mut word_from_prev_line = None; + 'lines: loop { - let baseline = area.top_left() + Offset::y(line_height + taken_from_top); - if !area.contains(baseline) { + let baseline_left = area.top_left() + Offset::y(line_height + taken_from_top); + if !area.contains(baseline_left) { // 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) { - characters_drawn += 1; - if character == '\n' { - // The line is forced to end. - break 'characters; - } - unwrap!(line_text.push(character)); - } else { - // No more characters to draw. - break 'characters; + let mut line_text: String<100> = String::new(); + + 'words: while let Some(word) = word_from_prev_line.take().or_else(|| text_iter.next()) { + let prev_line_text_len = line_text.len(); + if !line_text.is_empty() { + // Putting spaces in between words. + unwrap!(line_text.push(' ')); } - if font.text_width(&line_text) > area.width() { - // Cannot fit on the line anymore. - line_text.pop(); - characters_drawn -= 1; - break 'characters; + if write!(&mut line_text, "{}", word).is_err() { + // We have a word/line longer than 100 chars. + // Add just 50 characters, that is enough for this line. + unwrap!(write!(&mut line_text, "{}", &word[..50])); + } + + if font.text_width(&line_text) <= area.width() { + chars_processed += word.chars().count() + 1; + } else { + // The word does not fit on the line anymore. + // Word can be longer than the whole line - in that case splitting it to more + // lines + if prev_line_text_len == 0 { + for (idx, _) in word.char_indices() { + if font.text_width(&word[..idx]) > area.width() { + let split_idx = idx - 1; + let chars_fitting_this_line = split_idx - 1; // accounting for the hyphen we will add + line_text = String::from(&word[..chars_fitting_this_line]); + unwrap!(line_text.push('-')); + chars_processed += chars_fitting_this_line; + word_from_prev_line = Some(&word[chars_fitting_this_line..]); + break; + } + } + } else { + line_text.truncate(prev_line_text_len); + word_from_prev_line = Some(word); + } + break 'words; + } + } + + match alignment { + Alignment::Start => text_left(baseline_left, &line_text, font, fg_color, bg_color), + Alignment::Center => { + let baseline_center = baseline_left + Offset::x(area.width() / 2); + text_center(baseline_center, &line_text, font, fg_color, bg_color) + } + Alignment::End => { + let baseline_right = baseline_left + Offset::x(area.width()); + text_right(baseline_right, &line_text, font, fg_color, bg_color) } } - text_left(baseline, &line_text, font, fg_color, bg_color); taken_from_top += line_height; - if characters_drawn == characters_overall { + if chars_processed >= text.chars().count() { // No more lines to draw. break 'lines; } diff --git a/core/embed/rust/src/ui/model_tr/component/coinjoin_progress.rs b/core/embed/rust/src/ui/model_tr/component/coinjoin_progress.rs new file mode 100644 index 0000000000..3d3a8f9979 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/coinjoin_progress.rs @@ -0,0 +1,83 @@ +use crate::{ + micropython::buffer::StrBuffer, + ui::{ + component::{base::Never, Component, Event, EventCtx}, + display::{text_multiline_split_words, Font}, + geometry::{Alignment, Rect}, + model_tr::theme, + }, +}; + +const HEADER: &str = "COINJOIN IN PROGRESS"; +const FOOTER: &str = "Don't disconnect your Trezor"; + +pub struct CoinJoinProgress { + text: StrBuffer, + area: Rect, +} + +impl CoinJoinProgress { + pub fn new(text: StrBuffer, _indeterminate: bool) -> Self { + Self { + text, + area: Rect::zero(), + } + } +} + +impl Component for CoinJoinProgress { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + bounds + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + // Trying to paint all three parts into the area, stopping if any of them + // doesn't fit. + let mut possible_rest = text_multiline_split_words( + self.area, + HEADER, + Font::NORMAL, + theme::FG, + theme::BG, + Alignment::Center, + ); + if let Some(rest) = possible_rest { + possible_rest = text_multiline_split_words( + rest, + self.text.as_ref(), + Font::MONO, + theme::FG, + theme::BG, + Alignment::Center, + ); + } else { + return; + } + if let Some(rest) = possible_rest { + text_multiline_split_words( + rest, + FOOTER, + Font::BOLD, + theme::FG, + theme::BG, + Alignment::Center, + ); + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for CoinJoinProgress { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("CoinJoinProgress"); + t.string(self.text.as_ref()); + 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 index 1b2c15aa67..86f3b181d6 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -2,6 +2,7 @@ mod address_details; mod button; mod button_controller; mod changing_text; +mod coinjoin_progress; mod common; mod flow; mod flow_pages; @@ -34,6 +35,7 @@ pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg}; pub use button_controller::{ButtonController, ButtonControllerMsg}; pub use changing_text::ChangingTextLine; +pub use coinjoin_progress::CoinJoinProgress; pub use flow::{Flow, FlowMsg}; pub use flow_pages::{FlowPages, Page}; pub use frame::{Frame, ScrollableContent, ScrollableFrame}; diff --git a/core/embed/rust/src/ui/model_tr/component/share_words.rs b/core/embed/rust/src/ui/model_tr/component/share_words.rs index 4615b6cd05..7ca6659baf 100644 --- a/core/embed/rust/src/ui/model_tr/component/share_words.rs +++ b/core/embed/rust/src/ui/model_tr/component/share_words.rs @@ -2,8 +2,8 @@ use crate::{ micropython::buffer::StrBuffer, ui::{ component::{Component, Event, EventCtx, Never, Paginate}, - display::{text_multiline, Font}, - geometry::{Offset, Rect}, + display::{text_multiline_split_words, Font}, + geometry::{Alignment, Offset, Rect}, model_tr::theme, }, }; @@ -77,40 +77,42 @@ impl ShareWords { 50, "Write all ", inttostr!(self.share_words.len() as u8), - "\nwords in order on\nrecovery seed card." + " words in order on recovery seed card." ) } /// Display the first page with user information. fn paint_entry_page(&mut self) { - text_multiline( + text_multiline_split_words( self.area.split_top(15).1, &self.get_first_text(), Font::BOLD, theme::FG, theme::BG, + Alignment::Start, ); } fn get_second_text(&self) -> String<50> { - build_string!(50, "Do NOT make\ndigital copies!") + build_string!(50, "Do NOT make digital copies!") } /// Display the second page with user information. fn paint_second_page(&mut self) { - text_multiline( + text_multiline_split_words( self.area.split_top(15).1, &self.get_second_text(), Font::MONO, theme::FG, theme::BG, + Alignment::Start, ); } fn get_final_text(&self) -> String<50> { build_string!( 50, - "I wrote down all\n", + "I wrote down all ", inttostr!(self.share_words.len() as u8), " words in order." ) @@ -118,12 +120,13 @@ impl ShareWords { /// Display the final page with user confirmation. fn paint_final_page(&mut self) { - text_multiline( + text_multiline_split_words( self.area.split_top(12).1, &self.get_final_text(), Font::MONO, theme::FG, theme::BG, + Alignment::Start, ); } diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index 7f68f24885..55638cbb43 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -44,8 +44,8 @@ use crate::{ use super::{ component::{ AddressDetails, AddressDetailsMsg, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, - CancelInfoConfirmMsg, Flow, FlowMsg, FlowPages, Frame, Homescreen, HomescreenMsg, - Lockscreen, NoBtnDialog, NoBtnDialogMsg, NumberInput, NumberInputMsg, Page, + CancelInfoConfirmMsg, CoinJoinProgress, Flow, FlowMsg, FlowPages, Frame, Homescreen, + HomescreenMsg, Lockscreen, NoBtnDialog, NoBtnDialogMsg, NumberInput, NumberInputMsg, Page, PassphraseEntry, PassphraseEntryMsg, PinEntry, PinEntryMsg, Progress, ShareWords, ShowMore, SimpleChoice, SimpleChoiceMsg, WelcomeScreen, WordlistEntry, WordlistEntryMsg, WordlistType, @@ -132,6 +132,19 @@ impl ComponentMsgObj for PinEntry { } } +// Clippy complains about conflicting implementations +#[cfg(not(feature = "clippy"))] +impl ComponentMsgObj for (Timeout, T) +where + T: Component, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + TimeoutMsg::TimedOut => Ok(CANCELLED.as_obj()), + } + } +} + impl ComponentMsgObj for AddressDetails { fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { match msg { @@ -140,6 +153,12 @@ impl ComponentMsgObj for AddressDetails { } } +impl ComponentMsgObj for CoinJoinProgress { + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!(); + } +} + impl ComponentMsgObj for NumberInput { fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { match msg { @@ -900,9 +919,9 @@ extern "C" fn new_confirm_coinjoin(n_args: usize, args: *const Obj, kwargs: *mut let max_feerate: StrBuffer = kwargs.get(Qstr::MP_QSTR_max_feerate)?.try_into()?; let paragraphs = Paragraphs::new([ - Paragraph::new(&theme::TEXT_BOLD, "Maximum rounds:".into()), + Paragraph::new(&theme::TEXT_BOLD, "Max rounds".into()), Paragraph::new(&theme::TEXT_MONO, max_rounds), - Paragraph::new(&theme::TEXT_BOLD, "Maximum mining fee:".into()).no_break(), + Paragraph::new(&theme::TEXT_BOLD, "Max mining fee".into()).no_break(), Paragraph::new(&theme::TEXT_MONO, max_feerate), ]); @@ -1138,6 +1157,29 @@ extern "C" fn new_show_progress(n_args: usize, args: *const Obj, kwargs: *mut Ma unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } +extern "C" fn new_show_progress_coinjoin(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 indeterminate: bool = kwargs.get_or(Qstr::MP_QSTR_indeterminate, false)?; + let time_ms: u32 = kwargs.get_or(Qstr::MP_QSTR_time_ms, 0)?; + let skip_first_paint: bool = kwargs.get_or(Qstr::MP_QSTR_skip_first_paint, false)?; + + // The second type parameter is actually not used in `new()` but we need to + // provide it. + let progress = CoinJoinProgress::new(title, indeterminate); + let obj = if time_ms > 0 && indeterminate { + let timeout = Timeout::new(time_ms); + LayoutObj::new((timeout, progress.map(|_msg| None)))? + } else { + LayoutObj::new(progress)? + }; + if skip_first_paint { + obj.skip_first_paint(); + } + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let label: StrBuffer = kwargs.get(Qstr::MP_QSTR_label)?.try_into()?; @@ -1171,33 +1213,6 @@ extern "C" fn new_show_lockscreen(n_args: usize, args: *const Obj, kwargs: *mut unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } -extern "C" fn new_show_busyscreen(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 description: StrBuffer = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; - let time_ms: u32 = kwargs.get(Qstr::MP_QSTR_time_ms)?.try_into()?; - let skip_first_paint: bool = kwargs.get(Qstr::MP_QSTR_skip_first_paint)?.try_into()?; - - let content = Paragraphs::new([ - Paragraph::new(&theme::TEXT_BOLD, title), - Paragraph::new(&theme::TEXT_MONO, description), - ]); - - let obj = LayoutObj::new(NoBtnDialog::new( - content, - Timeout::new(time_ms).map(|msg| { - (matches!(msg, TimeoutMsg::TimedOut)).then(|| CancelConfirmMsg::Confirmed) - }), - ))?; - - if skip_first_paint { - obj.skip_first_paint(); - } - Ok(obj.into()) - }; - unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } -} - extern "C" fn draw_welcome_screen() -> Obj { // No need of util::try_or_raise, this does not allocate let mut screen = WelcomeScreen::new(); @@ -1510,6 +1525,17 @@ pub static mp_module_trezorui2: Module = obj_module! { /// make sure the initial description has at least that amount of lines.""" Qstr::MP_QSTR_show_progress => obj_fn_kw!(0, new_show_progress).as_obj(), + /// def show_progress_coinjoin( + /// *, + /// title: str, + /// indeterminate: bool = False, + /// time_ms: int = 0, + /// skip_first_paint: bool = False, + /// ) -> object: + /// """Show progress loader for coinjoin. Returns CANCELLED after a specified time when + /// time_ms timeout is passed.""" + Qstr::MP_QSTR_show_progress_coinjoin => obj_fn_kw!(0, new_show_progress_coinjoin).as_obj(), + /// def show_homescreen( /// *, /// label: str, @@ -1530,16 +1556,6 @@ pub static mp_module_trezorui2: Module = obj_module! { /// """Homescreen for locked device.""" Qstr::MP_QSTR_show_lockscreen => obj_fn_kw!(0, new_show_lockscreen).as_obj(), - /// def show_busyscreen( - /// *, - /// title: str, - /// description: str, - /// time_ms: int, - /// skip_first_paint: bool, - /// ) -> CANCELLED: - /// """Homescreen used for indicating coinjoin in progress.""" - Qstr::MP_QSTR_show_busyscreen => obj_fn_kw!(0, new_show_busyscreen).as_obj(), - /// def draw_welcome_screen() -> None: /// """Show logo icon with the model name at the bottom and return.""" Qstr::MP_QSTR_draw_welcome_screen => obj_fn_0!(draw_welcome_screen).as_obj(), diff --git a/core/src/trezor/ui/layouts/tr/homescreen.py b/core/src/trezor/ui/layouts/tr/homescreen.py index f00cb3bef6..8da0cc9216 100644 --- a/core/src/trezor/ui/layouts/tr/homescreen.py +++ b/core/src/trezor/ui/layouts/tr/homescreen.py @@ -114,10 +114,9 @@ class Busyscreen(HomescreenBase): def __init__(self, delay_ms: int) -> None: skip = storage_cache.homescreen_shown is self.RENDER_INDICATOR super().__init__( - # TODO: remove show_busyscreen in favor of show_progress_coinjoin - layout=trezorui2.show_busyscreen( - title="PLEASE WAIT", - description="Coinjoin in progress.\n\nDo not disconnect your Trezor.", + layout=trezorui2.show_progress_coinjoin( + title="Waiting for others", + indeterminate=True, time_ms=delay_ms, skip_first_paint=skip, ) diff --git a/tests/click_tests/reset.py b/tests/click_tests/reset.py index 8d0adb2520..de668bcce8 100644 --- a/tests/click_tests/reset.py +++ b/tests/click_tests/reset.py @@ -99,9 +99,11 @@ def read_words(debug: "DebugLink", backup_type: messages.BackupType) -> list[str else: assert layout.text_content().startswith("RECOVERY SEED") - # Swiping through all the page and loading the words - for _ in range(layout.page_count() - 1): - words.extend(layout.seed_words()) + # Swiping through all the pages and loading the words + for i in range(layout.page_count() - 1): + # In model R, first two pages are just informational + if not (debug.model == "R" and i < 2): + words.extend(layout.seed_words()) layout = debug.input(swipe=messages.DebugSwipeDirection.UP, wait=True) assert layout is not None if debug.model == "T": diff --git a/tests/common.py b/tests/common.py index ca6ddc2ef3..659a173c77 100644 --- a/tests/common.py +++ b/tests/common.py @@ -341,10 +341,12 @@ def read_and_confirm_mnemonic_tr( mnemonic: list[str] = [] br = yield assert br.pages is not None - for _ in range(br.pages - 1): + for i in range(br.pages - 1): layout = debug.wait_layout() - words = layout.seed_words() - mnemonic.extend(words) + # First two pages have just instructions + if i > 1: + words = layout.seed_words() + mnemonic.extend(words) debug.press_right() debug.press_right()