diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index fc78c19a9e0..8e3c111cf06 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -532,6 +532,7 @@ static void _librust_qstrs(void) { MP_QSTR_reset__set_it_to_count_template; MP_QSTR_reset__share_checked_successfully_template; MP_QSTR_reset__share_completed_template; + MP_QSTR_reset__share_words_first; MP_QSTR_reset__share_words_title; MP_QSTR_reset__slip39_checklist_more_info_threshold; MP_QSTR_reset__slip39_checklist_more_info_threshold_example_template; @@ -651,7 +652,7 @@ static void _librust_qstrs(void) { MP_QSTR_show_progress_coinjoin; MP_QSTR_show_remaining_shares; MP_QSTR_show_share_words; - MP_QSTR_show_share_words_delizia; + MP_QSTR_show_share_words_extended; MP_QSTR_show_simple; MP_QSTR_show_success; MP_QSTR_show_wait_text; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index f8182f3f5fb..0605435da53 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -855,7 +855,7 @@ pub enum TranslatedString { reset__recovery_share_title_template = 567, // "Recovery share #{0}" reset__required_number_of_groups = 568, // "The required number of groups for recovery." reset__select_correct_word = 569, // "Select the correct word for each position." - reset__select_word_template = 570, // "Select {0} word" + reset__select_word_template = 570, // {"Bolt": "Select {0} word", "Caesar": "Select {0} word", "Delizia": "Select {0} word", "Eckhart": "Select word #{0} from your wallet backup"} reset__select_word_x_of_y_template = 571, // "Select word {0} of {1}:" reset__set_it_to_count_template = 572, // "Set it to {0} and you will need " reset__share_checked_successfully_template = 573, // "Share #{0} checked successfully." @@ -1339,7 +1339,7 @@ pub enum TranslatedString { reset__repeat_for_all_shares = 938, // "Repeat for all shares." homescreen__settings_subtitle = 939, // "Settings" homescreen__settings_title = 940, // "Homescreen" - reset__the_word_is_repeated = 941, // "The word is repeated" + reset__the_word_is_repeated = 941, // {"Bolt": "The word is repeated", "Caesar": "The word is repeated", "Delizia": "The word is repeated", "Eckhart": "The word appears multiple times in the backup."} tutorial__title_lets_begin = 942, // "Let's begin" tutorial__did_you_know = 943, // "Did you know?" tutorial__first_wallet = 944, // "The Trezor Model One, created in 2013,\nwas the world's first hardware wallet." @@ -1383,6 +1383,7 @@ pub enum TranslatedString { #[cfg(feature = "universal_fw")] ethereum__unknown_contract_address_short = 974, // "Unknown contract address." instructions__keep_holding = 975, // "Keep holding" + reset__share_words_first = 976, // "Write down the first word from the backup." } impl TranslatedString { @@ -2234,7 +2235,14 @@ impl TranslatedString { Self::reset__recovery_share_title_template => "Recovery share #{0}", Self::reset__required_number_of_groups => "The required number of groups for recovery.", Self::reset__select_correct_word => "Select the correct word for each position.", + #[cfg(feature = "layout_bolt")] Self::reset__select_word_template => "Select {0} word", + #[cfg(feature = "layout_caesar")] + Self::reset__select_word_template => "Select {0} word", + #[cfg(feature = "layout_delizia")] + Self::reset__select_word_template => "Select {0} word", + #[cfg(feature = "layout_eckhart")] + Self::reset__select_word_template => "Select word #{0} from your wallet backup", Self::reset__select_word_x_of_y_template => "Select word {0} of {1}:", Self::reset__set_it_to_count_template => "Set it to {0} and you will need ", Self::reset__share_checked_successfully_template => "Share #{0} checked successfully.", @@ -2718,7 +2726,14 @@ impl TranslatedString { Self::reset__repeat_for_all_shares => "Repeat for all shares.", Self::homescreen__settings_subtitle => "Settings", Self::homescreen__settings_title => "Homescreen", + #[cfg(feature = "layout_bolt")] + Self::reset__the_word_is_repeated => "The word is repeated", + #[cfg(feature = "layout_caesar")] + Self::reset__the_word_is_repeated => "The word is repeated", + #[cfg(feature = "layout_delizia")] Self::reset__the_word_is_repeated => "The word is repeated", + #[cfg(feature = "layout_eckhart")] + Self::reset__the_word_is_repeated => "The word appears multiple times in the backup.", Self::tutorial__title_lets_begin => "Let's begin", Self::tutorial__did_you_know => "Did you know?", Self::tutorial__first_wallet => "The Trezor Model One, created in 2013,\nwas the world's first hardware wallet.", @@ -2762,6 +2777,7 @@ impl TranslatedString { #[cfg(feature = "universal_fw")] Self::ethereum__unknown_contract_address_short => "Unknown contract address.", Self::instructions__keep_holding => "Keep holding", + Self::reset__share_words_first => "Write down the first word from the backup.", } } @@ -4140,6 +4156,7 @@ impl TranslatedString { #[cfg(feature = "universal_fw")] Qstr::MP_QSTR_ethereum__unknown_contract_address_short => Some(Self::ethereum__unknown_contract_address_short), Qstr::MP_QSTR_instructions__keep_holding => Some(Self::instructions__keep_holding), + Qstr::MP_QSTR_reset__share_words_first => Some(Self::reset__share_words_first), _ => None, } } diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs index e280a43a58d..c20ede2ac20 100644 --- a/core/embed/rust/src/ui/api/firmware_micropython.rs +++ b/core/embed/rust/src/ui/api/firmware_micropython.rs @@ -878,7 +878,7 @@ extern "C" fn new_show_share_words(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_share_words_delizia( +extern "C" fn new_show_share_words_extended( n_args: usize, args: *const Obj, kwargs: *mut Map, @@ -898,7 +898,7 @@ extern "C" fn new_show_share_words_delizia( let words: Vec = util::iter_into_vec(words)?; - let layout = ModelUI::show_share_words_delizia( + let layout = ModelUI::show_share_words_extended( words, subtitle, instructions, @@ -1589,7 +1589,7 @@ pub static mp_module_trezorui_api: Module = obj_module! { /// """Show mnemonic for backup.""" Qstr::MP_QSTR_show_share_words => obj_fn_kw!(0, new_show_share_words).as_obj(), - /// def show_share_words_delizia( + /// def show_share_words_extended( /// *, /// words: Iterable[str], /// subtitle: str | None, @@ -1599,7 +1599,7 @@ pub static mp_module_trezorui_api: Module = obj_module! { /// ) -> LayoutObj[UiResult]: /// """Show mnemonic for wallet backup preceded by an instruction screen and followed by a /// confirmation screen.""" - Qstr::MP_QSTR_show_share_words_delizia => obj_fn_kw!(0, new_show_share_words_delizia).as_obj(), + Qstr::MP_QSTR_show_share_words_extended => obj_fn_kw!(0, new_show_share_words_extended).as_obj(), /// def show_simple( /// *, diff --git a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs index be68f5d1926..8ab1850100d 100644 --- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs @@ -1013,7 +1013,7 @@ impl FirmwareUI for UIBolt { Ok(layout) } - fn show_share_words_delizia( + fn show_share_words_extended( _words: heapless::Vec, 33>, _subtitle: Option>, _instructions: Obj, diff --git a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs index d6f8393e242..43901b7cab2 100644 --- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs @@ -1133,7 +1133,7 @@ impl FirmwareUI for UICaesar { Ok(layout) } - fn show_share_words_delizia( + fn show_share_words_extended( _words: heapless::Vec, 33>, _subtitle: Option>, _instructions: Obj, diff --git a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs index 80b25adb44d..a392c4affa1 100644 --- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs @@ -996,11 +996,11 @@ impl FirmwareUI for UIDelizia { _title: Option>, ) -> Result { Err::, Error>(Error::ValueError( - c"use show_share_words_delizia instead", + c"use show_share_words_extended instead", )) } - fn show_share_words_delizia( + fn show_share_words_extended( words: heapless::Vec, 33>, subtitle: Option>, instructions: Obj, diff --git a/core/embed/rust/src/ui/layout_eckhart/component/button.rs b/core/embed/rust/src/ui/layout_eckhart/component/button.rs index 7f68c24ea13..4fd60e5f09f 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs @@ -26,6 +26,7 @@ pub struct Button { area: Rect, touch_expand: Option, content: ButtonContent, + content_offset: Offset, styles: ButtonStyleSheet, text_align: Alignment, radius: Option, @@ -36,13 +37,13 @@ pub struct Button { } impl Button { - pub const BASELINE_OFFSET: Offset = Offset::new(2, 6); const LINE_SPACING: i16 = 7; const SUBTEXT_STYLE: TextStyle = theme::label_menu_item_subtitle(); pub const fn new(content: ButtonContent) -> Self { Self { content, + content_offset: Offset::zero(), area: Rect::zero(), touch_expand: None, styles: theme::button_default(), @@ -85,6 +86,11 @@ impl Button { self } + pub const fn with_content_offset(mut self, offset: Offset) -> Self { + self.content_offset = offset; + self + } + pub const fn with_expanded_touch_area(mut self, expand: Insets) -> Self { self.touch_expand = Some(expand); self @@ -135,6 +141,10 @@ impl Button { ) } + pub fn is_pressed(&self) -> bool { + matches!(self.state, State::Pressed) + } + pub fn long_press(&self) -> Option { self.long_press } @@ -153,6 +163,10 @@ impl Button { &self.content } + pub fn content_offset(&self) -> Offset { + self.content_offset + } + pub fn content_height(&self) -> i16 { match &self.content { ButtonContent::Empty => 0, @@ -234,11 +248,9 @@ impl Button { ButtonContent::Text(text) => { let y_offset = Offset::y(self.content_height() / 2); let start_of_baseline = match self.text_align { - Alignment::Start => { - self.area.left_center() + Offset::x(Self::BASELINE_OFFSET.x) - } + Alignment::Start => self.area.left_center() + self.content_offset, Alignment::Center => self.area.center(), - Alignment::End => self.area.right_center() - Offset::x(Self::BASELINE_OFFSET.x), + Alignment::End => self.area.right_center() - self.content_offset, } + y_offset; text.map(|text| { shape::Text::new(start_of_baseline, text, style.font) @@ -249,16 +261,13 @@ impl Button { }); } ButtonContent::TextAndSubtext(text, subtext) => { - let text_y_offset = Offset::y( - self.content_height() / 2 - self.style().font.allcase_text_height() / 2, - ); + let text_y_offset = + Offset::y(self.content_height() / 2 - self.style().font.allcase_text_height()); let subtext_y_offset = Offset::y(self.content_height() / 2); let start_of_baseline = match self.text_align { - Alignment::Start => { - self.area.left_center() + Offset::x(Self::BASELINE_OFFSET.x) - } + Alignment::Start => self.area.left_center() + self.content_offset, Alignment::Center => self.area.center(), - Alignment::End => self.area.right_center() - Offset::x(Self::BASELINE_OFFSET.x), + Alignment::End => self.area.right_center() - self.content_offset, }; let text_baseline = start_of_baseline - text_y_offset; let subtext_baseline = start_of_baseline + subtext_y_offset; @@ -271,7 +280,7 @@ impl Button { .render(target); }); - text.map(|subtext| { + subtext.map(|subtext| { shape::Text::new(subtext_baseline, subtext, Self::SUBTEXT_STYLE.text_font) .with_fg(Self::SUBTEXT_STYLE.text_color) .with_align(self.text_align) @@ -287,13 +296,7 @@ impl Button { .render(target); } ButtonContent::IconAndText(child) => { - child.render( - target, - self.area, - self.style(), - Self::BASELINE_OFFSET, - alpha, - ); + child.render(target, self.area, self.style(), self.content_offset, alpha); } } } diff --git a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs index 2407b01f59e..4ef5d37e2fb 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs @@ -6,18 +6,23 @@ mod header; mod hint; mod hold_to_confirm; mod result; +mod select_word_screen; +mod share_words; mod text_screen; mod vertical_menu; mod vertical_menu_screen; mod welcome_screen; -pub use action_bar::ActionBar; +pub use action_bar::{ActionBar, ActionBarMsg}; pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, IconText}; pub use error::ErrorScreen; pub use header::{Header, HeaderMsg}; pub use hint::Hint; pub use hold_to_confirm::HoldToConfirmAnim; pub use result::{ResultFooter, ResultScreen, ResultStyle}; +pub use select_word_screen::{SelectWordMsg, SelectWordScreen}; +#[cfg(feature = "translations")] +pub use share_words::{ShareWordsScreen, ShareWordsScreenMsg}; pub use text_screen::{AllowedTextContent, TextScreen, TextScreenMsg}; pub use vertical_menu::{VerticalMenu, VerticalMenuMsg, MENU_MAX_ITEMS}; pub use vertical_menu_screen::{VerticalMenuScreen, VerticalMenuScreenMsg}; diff --git a/core/embed/rust/src/ui/layout_eckhart/component/select_word_screen.rs b/core/embed/rust/src/ui/layout_eckhart/component/select_word_screen.rs new file mode 100644 index 00000000000..ba25df94b09 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/select_word_screen.rs @@ -0,0 +1,108 @@ +use crate::{ + strutil::TString, + ui::{ + component::{Component, Event, EventCtx, Label}, + geometry::{Alignment, Insets, Rect}, + shape::Renderer, + ui_firmware::MAX_WORD_QUIZ_ITEMS, + }, +}; + +use super::super::{ + component::{Button, Header, HeaderMsg, VerticalMenu, VerticalMenuMsg}, + constant::SCREEN, + theme, +}; + +pub struct SelectWordScreen { + header: Header, + description: Label<'static>, + menu: VerticalMenu, +} + +pub enum SelectWordMsg { + Selected(usize), + /// Right header button clicked + Cancelled, +} + +impl SelectWordScreen { + const INSET: i16 = 24; + const DESCRIPTION_HEIGHT: i16 = 52; + const BUTTON_RADIUS: u8 = 12; + + pub fn new( + share_words_vec: [TString<'static>; MAX_WORD_QUIZ_ITEMS], + description: TString<'static>, + ) -> Self { + let mut menu = VerticalMenu::empty().with_separators().with_fit_area(); + + for word in share_words_vec { + menu = menu.item( + Button::with_text(word) + .styled(theme::button_select_word()) + .with_radius(Self::BUTTON_RADIUS), + ); + } + + Self { + header: Header::new(TString::empty()), + description: Label::new(description, Alignment::Start, theme::TEXT_MEDIUM) + .vertically_centered(), + menu, + } + } + + pub fn with_header(mut self, header: Header) -> Self { + self.header = header; + self + } +} + +impl Component for SelectWordScreen { + type Msg = SelectWordMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // assert full screen + debug_assert_eq!(bounds.height(), SCREEN.height()); + debug_assert_eq!(bounds.width(), SCREEN.width()); + + let (header_area, rest) = bounds.split_top(Header::HEADER_HEIGHT); + let (description_area, rest) = rest.split_top(Self::DESCRIPTION_HEIGHT); + let (_, rest) = rest.split_top(Self::INSET); + let (menu_area, _) = rest.split_bottom(Self::INSET); + + let description_area = description_area.inset(Insets::sides(Self::INSET)); + + self.menu.place(menu_area); + self.description.place(description_area); + self.header.place(header_area); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(HeaderMsg::Cancelled) = self.header.event(ctx, event) { + return Some(SelectWordMsg::Cancelled); + } + + if let Some(VerticalMenuMsg::Selected(i)) = self.menu.event(ctx, event) { + return Some(SelectWordMsg::Selected(i)); + } + + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.header.render(target); + self.description.render(target); + self.menu.render(target); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SelectWordScreen { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("SelectWordScreen"); + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/component/share_words.rs b/core/embed/rust/src/ui/layout_eckhart/component/share_words.rs new file mode 100644 index 00000000000..3742648a3e2 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/share_words.rs @@ -0,0 +1,329 @@ +use crate::{ + strutil::TString, + translations::TR, + ui::{ + component::{ + swipe_detect::SwipeConfig, Component, Event, EventCtx, Never, PaginateFull, Swipe, + }, + flow::Swipable, + geometry::{Alignment, Direction, Offset, Rect}, + shape::{Bar, Renderer, Text}, + util::Pager, + }, +}; + +use heapless::Vec; + +use super::super::{ + component::{button::Button, ActionBar, ActionBarMsg, Header, HeaderMsg, Hint}, + constant::SCREEN, + fonts, theme, +}; + +const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less +type IndexVec = Vec; + +/// Full-screen component for rendering ShareWords. +pub struct ShareWordsScreen<'a> { + header: Header, + content: ShareWords<'a>, + hint: Hint<'static>, + action_bar: ActionBar, + /// Common area for the content and hint + area: Rect, + page_swipe: Swipe, + swipe_config: SwipeConfig, +} + +pub enum ShareWordsScreenMsg { + Cancelled, + Confirmed, +} + +impl<'a> ShareWordsScreen<'a> { + const WORD_AREA_HEIGHT: i16 = 120; + const WORD_AREA_WIDTH: i16 = 330; + const WORD_Y_OFFSET: i16 = 76; + + pub fn new(share_words_vec: Vec, 33>) -> Self { + let content = ShareWords::new(share_words_vec); + + let mut action_bar = ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_UP), + Button::with_text(TR::buttons__continue.into()), + ); + // Set action bar page counter + action_bar.update(content.pager()); + + let header = Header::new(TR::reset__recovery_wallet_backup_title.into()); + + let hint = Hint::new_instruction(TR::reset__share_words_first, Some(theme::ICON_INFO)); + + Self { + content, + header, + hint, + action_bar, + area: Rect::zero(), + page_swipe: Swipe::vertical(), + swipe_config: SwipeConfig::new(), + } + } + + fn on_page_change(&mut self, direction: Direction) { + // Update page based on the direction + + match direction { + Direction::Up => { + self.content.change_page(self.content.pager().next()); + } + Direction::Down => { + self.content.change_page(self.content.pager().prev()); + } + _ => {} + } + + // Update action bar content based on the current page + self.action_bar.update(self.content.pager()); + + // Update hint content based on the current page + + // First word gets a special hint + if self.content.pager().is_first() { + self.hint = Hint::new_instruction(TR::reset__share_words_first, Some(theme::ICON_INFO)); + // Repeated words get a special hint + } else if self.content.is_repeated() { + self.hint = Hint::new_instruction_green( + TR::reset__the_word_is_repeated, + Some(theme::ICON_INFO), + ); + // Other words get a page counter hint + } else { + self.hint = Hint::new_page_counter(); + self.hint.update(self.content.pager()); + } + + // use place function because the hint height is floating based on its content + self.place(self.area); + } +} + +impl<'a> Swipable for ShareWordsScreen<'a> { + fn get_pager(&self) -> Pager { + self.content.pager() + } + fn get_swipe_config(&self) -> SwipeConfig { + SwipeConfig::default() + } +} + +impl<'a> Component for ShareWordsScreen<'a> { + type Msg = ShareWordsScreenMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // assert full screen + debug_assert_eq!(bounds.height(), SCREEN.height()); + debug_assert_eq!(bounds.width(), SCREEN.width()); + + self.area = bounds; + let (header_area, rest) = bounds.split_top(Header::HEADER_HEIGHT); + let (rest, action_bar_area) = rest.split_bottom(ActionBar::ACTION_BAR_HEIGHT); + let (content_area, hint_area) = rest.split_bottom(self.hint.height()); + + // Use constant y offset for the word area because the height is floating + let top_left = content_area.top_left().ofs(Offset::new( + (content_area.width() - Self::WORD_AREA_WIDTH) / 2, + Self::WORD_Y_OFFSET, + )); + let content_area = Rect::from_top_left_and_size( + top_left, + Offset::new(Self::WORD_AREA_WIDTH, Self::WORD_AREA_HEIGHT), + ); + + self.header.place(header_area); + self.content.place(content_area); + self.hint.place(hint_area); + self.action_bar.place(action_bar_area); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(swipe) = self.page_swipe.event(ctx, event) { + // We have detected a vertical swipe. Change the keyboard page. + self.on_page_change(swipe); + ctx.request_paint(); + return None; + } + + if let Some(msg) = self.header.event(ctx, event) { + match msg { + HeaderMsg::Cancelled => return Some(ShareWordsScreenMsg::Cancelled), + _ => {} + } + } + + if let Some(msg) = self.action_bar.event(ctx, event) { + match msg { + ActionBarMsg::Cancelled => { + return Some(ShareWordsScreenMsg::Cancelled); + } + ActionBarMsg::Confirmed => { + return Some(ShareWordsScreenMsg::Confirmed); + } + ActionBarMsg::Prev => { + self.on_page_change(Direction::Down); + return None; + } + ActionBarMsg::Next => { + self.on_page_change(Direction::Up); + return None; + } + } + } + + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.header.render(target); + self.content.render(target); + self.hint.render(target); + self.action_bar.render(target); + } +} + +#[cfg(feature = "ui_debug")] +impl<'a> crate::trace::Trace for ShareWordsScreen<'a> { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("TextComponent"); + self.header.trace(t); + self.content.trace(t); + self.hint.trace(t); + self.action_bar.trace(t); + } +} + +/// Component showing mnemonic/share words during backup procedure. Model T3W1 +/// contains one word per screen. A user is instructed to swipe up/down to see +/// next/previous word. +struct ShareWords<'a> { + share_words: Vec, MAX_WORDS>, + area: Rect, + repeated_indices: IndexVec, + pager: Pager, +} + +impl<'a> ShareWords<'a> { + const AREA_WORD_HEIGHT: i16 = 120; + const ORDINAL_PADDING: i16 = 16; + + pub fn new(share_words: Vec, MAX_WORDS>) -> Self { + let repeated_indices = Self::find_repeated(share_words.as_slice()); + let pager = Pager::new(share_words.len() as u16); + Self { + share_words, + area: Rect::zero(), + repeated_indices, + pager, + } + } + + pub fn is_repeated(&self) -> bool { + self.repeated_indices + .contains(&(self.pager().current() as u8)) + } + + fn find_repeated(share_words: &[TString]) -> IndexVec { + let mut repeated_indices = IndexVec::new(); + for i in (0..share_words.len()).rev() { + let word = share_words[i]; + if share_words[..i].contains(&word) { + unwrap!(repeated_indices.push(i as u8)); + } + } + repeated_indices.reverse(); + repeated_indices + } +} + +// Pagination +impl<'a> PaginateFull for ShareWords<'a> { + fn pager(&self) -> Pager { + self.pager + } + + fn change_page(&mut self, to_page: u16) { + let to_page = to_page.min(self.pager.total() - 1); + + // Update the pager + self.pager.set_current(to_page); + } +} + +impl<'a> Component for ShareWords<'a> { + 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 render<'s>(&'s self, target: &mut impl Renderer<'s>) { + // the ordinal number of the current word + let ordinal_val = self.pager().current() as u8 + 1; + let ordinal_pos = self.area.top_left(); + let ordinal = uformat!("{}", ordinal_val); + Text::new(ordinal_pos, &ordinal, fonts::FONT_SATOSHI_REGULAR_38) + .with_fg(theme::GREY) + .render(target); + + // Render lines as bars with the with 1px + let top_line = Rect::from_bottom_right_and_size( + self.area.top_right(), + Offset::new( + self.area.width() + - theme::TEXT_NORMAL.text_font.text_width(&ordinal) + - Self::ORDINAL_PADDING, + 1, + ), + ); + let bottom_line = Rect::from_bottom_right_and_size( + self.area.bottom_right(), + Offset::new(self.area.width(), 1), + ); + + Bar::new(top_line) + .with_fg(theme::GREY_EXTRA_DARK) + .render(target); + + Bar::new(bottom_line) + .with_fg(theme::GREY_EXTRA_DARK) + .render(target); + + let word = self.share_words[self.pager().current() as usize]; + let font = fonts::FONT_SATOSHI_EXTRALIGHT_72; + + let word_baseline = self.area.center() + Offset::y(font.visible_text_height("A") / 2); + word.map(|w| { + Text::new(word_baseline, w, font) + .with_align(Alignment::Center) + .render(target); + }); + } +} + +#[cfg(feature = "ui_debug")] +impl<'a> crate::trace::Trace for ShareWords<'a> { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("ShareWordsInner"); + let word = &self.share_words[self.pager().current() as usize]; + let content = word.map(|w| uformat!("{}. {}\n", self.pager().current() + 1, w)); + t.string("screen_content", content.as_str().into()); + t.int("page_count", self.share_words.len() as i64) + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs index 85de9f9f141..751d871bc75 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs @@ -1,15 +1,16 @@ use crate::ui::{ component::{Component, Event, EventCtx}, geometry::{Insets, Offset, Rect}, - layout_eckhart::{ - component::{Button, ButtonContent, ButtonMsg}, - theme, - }, shape::{Bar, Renderer}, }; use heapless::Vec; +use super::super::{ + component::{Button, ButtonMsg}, + theme, +}; + /// Number of buttons. /// Presently, VerticalMenu holds only fixed number of buttons. pub const MENU_MAX_ITEMS: usize = 5; @@ -29,19 +30,19 @@ pub struct VerticalMenu { offset_y: i16, /// Maximum vertical offset. max_offset: i16, + /// Adapt padding to fit entire area. If the area is too small, the padding + /// will be reduced to min value. + fit_area: bool, } pub enum VerticalMenuMsg { Selected(usize), - /// Left header button clicked - Back, - /// Right header button clicked - Close, } impl VerticalMenu { - const SIDE_INSET: i16 = 24; - const BUTTON_PADDING: i16 = 28; + const SIDE_INSETS: Insets = Insets::sides(12); + const DEFAULT_PADDING: i16 = 28; + const MIN_PADDING: i16 = 2; fn new(buttons: VerticalMenuButtons) -> Self { Self { @@ -51,6 +52,7 @@ impl VerticalMenu { separators: false, offset_y: 0, max_offset: 0, + fit_area: false, } } @@ -63,22 +65,13 @@ impl VerticalMenu { self } - pub fn item(mut self, button: Button) -> Self { - unwrap!(self.buttons.push(button.styled(theme::menu_item_title()))); + pub fn with_fit_area(mut self) -> Self { + self.fit_area = true; self } - pub fn item_yellow(mut self, button: Button) -> Self { - unwrap!(self - .buttons - .push(button.styled(theme::menu_item_title_yellow()))); - self - } - - pub fn item_red(mut self, button: Button) -> Self { - unwrap!(self - .buttons - .push(button.styled(theme::menu_item_title_red()))); + pub fn item(mut self, button: Button) -> Self { + unwrap!(self.buttons.push(button)); self } @@ -112,6 +105,49 @@ impl VerticalMenu { button.enable_if(ctx, in_bounds); } } + + fn set_max_offset(&mut self) { + // Calculate the overflow of the menu area + let menu_overflow = (self.virtual_bounds.height() - self.bounds.height()).max(0); + + // Find the first button from the top that would completely fit in the menu area + // in the bottom position + for button in &self.buttons { + let offset = button.area().top_left().y - self.area().top_left().y; + if offset > menu_overflow { + self.max_offset = offset; + return; + } + } + + self.max_offset = menu_overflow; + } + + fn render_buttons<'s>(&'s self, target: &mut impl Renderer<'s>) { + for button in &self.buttons { + button.render(target); + } + } + + fn render_separators<'s>(&'s self, target: &mut impl Renderer<'s>) { + for i in 1..self.buttons.len() { + let button = &self.buttons[i]; + let button_prev = &self.buttons[i - 1]; + + if !button.is_pressed() && !button_prev.is_pressed() { + let separator = Rect::from_top_left_and_size( + button + .area() + .top_left() + .ofs(Offset::x(button.content_offset().x).into()), + Offset::new(button.area().width() - 2 * button.content_offset().x, 1), + ); + Bar::new(separator) + .with_fg(theme::GREY_EXTRA_DARK) + .render(target); + } + } + } } impl Component for VerticalMenu { @@ -119,31 +155,44 @@ impl Component for VerticalMenu { fn place(&mut self, bounds: Rect) -> Rect { // Crop the menu area - self.bounds = bounds.inset(Insets::sides(Self::SIDE_INSET)); + self.bounds = bounds.inset(Self::SIDE_INSETS); + + // Determine padding dynamically if `fit_area` is enabled + let padding = if self.fit_area { + let mut content_height = 0; + for button in self.buttons.iter_mut() { + content_height += button.content_height(); + } + let padding = (self.bounds.height() - content_height) / (self.buttons.len() as i16) / 2; + padding.max(Self::MIN_PADDING) + } else { + Self::DEFAULT_PADDING + }; let button_width = self.bounds.width(); let mut top_left = self.bounds.top_left(); + // Place each button (might overflow the menu bounds) for button in self.buttons.iter_mut() { - let button_height = button.content_height() + 2 * Self::BUTTON_PADDING; - - // Calculate button bounds (might overflow the menu bounds) + let button_height = button.content_height() + 2 * padding; let button_bounds = Rect::from_top_left_and_size(top_left, Offset::new(button_width, button_height)); + button.place(button_bounds); top_left = top_left + Offset::y(button_height); } // Calculate virtual bounds of all buttons combined - let height = top_left.y - self.bounds.top_left().y; + let total_height = top_left.y - self.bounds.top_left().y; self.virtual_bounds = Rect::from_top_left_and_size( self.bounds.top_left(), - Offset::new(self.bounds.width(), height), + Offset::new(self.bounds.width(), total_height), ); // Calculate maximum offset for scrolling - self.max_offset = (self.virtual_bounds.height() - self.bounds.height()).max(0); + self.set_max_offset(); + bounds } @@ -160,25 +209,11 @@ impl Component for VerticalMenu { // Clip and translate the sliding window based on the scroll offset target.in_clip(self.bounds, &|target| { target.with_origin(Offset::y(-self.offset_y), &|target| { - // Render menu button - for button in (&self.buttons).into_iter() { - button.render(target); - } + self.render_buttons(target); // Render separators between buttons if self.separators { - for i in 1..self.buttons.len() { - let button = self.buttons.get(i).unwrap(); - - // Render a line above the button - let separator = Rect::from_top_left_and_size( - button.area().top_left(), - Offset::new(button.area().width(), 1), - ); - Bar::new(separator) - .with_fg(theme::GREY_EXTRA_DARK) - .render(target); - } + self.render_separators(target); } }); }); diff --git a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs index b7388f7d26e..cf86459885b 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs @@ -8,14 +8,15 @@ use crate::{ }, event::{SwipeEvent, TouchEvent}, geometry::{Alignment2D, Direction, Offset, Rect}, - layout_eckhart::{ - component::{constant::screen, Header, HeaderMsg, VerticalMenu, VerticalMenuMsg}, - theme, - }, shape::{Renderer, ToifImage}, }, }; +use super::super::{ + component::{constant::SCREEN, Header, HeaderMsg, VerticalMenu, VerticalMenuMsg}, + theme, +}; + pub struct VerticalMenuScreen { header: Header, /// Scrollable vertical menu @@ -88,8 +89,8 @@ impl Component for VerticalMenuScreen { fn place(&mut self, bounds: Rect) -> Rect { // assert full screen - debug_assert_eq!(bounds.height(), screen().height()); - debug_assert_eq!(bounds.width(), screen().width()); + debug_assert_eq!(bounds.height(), SCREEN.height()); + debug_assert_eq!(bounds.width(), SCREEN.width()); let (header_area, menu_area) = bounds.split_top(Header::HEADER_HEIGHT); @@ -147,21 +148,8 @@ impl Component for VerticalMenuScreen { // Shift touch events in the menu area by the current sliding window position if let Some(shifted) = self.shift_touch_event(event) { - if let Some(msg) = self.menu.event(ctx, shifted) { - match msg { - VerticalMenuMsg::Selected(i) => { - return Some(VerticalMenuScreenMsg::Selected(i)) - } - _ => {} - } - } - } - - // Handle shifted touch events in the menu - if let Some(msg) = self.menu.event(ctx, event) { - match msg { - VerticalMenuMsg::Selected(i) => return Some(VerticalMenuScreenMsg::Selected(i)), - _ => {} + if let Some(VerticalMenuMsg::Selected(i)) = self.menu.event(ctx, shifted) { + return Some(VerticalMenuScreenMsg::Selected(i)); } } diff --git a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs index f5e8f8dbaef..244d020b50c 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs @@ -13,7 +13,9 @@ use crate::{ }, }; -use super::component::{AllowedTextContent, TextScreen, TextScreenMsg}; +use super::component::{ + AllowedTextContent, SelectWordMsg, SelectWordScreen, TextScreen, TextScreenMsg, +}; // Clippy/compiler complains about conflicting implementations // TODO move the common impls to a common module @@ -50,3 +52,12 @@ where } } } + +impl ComponentMsgObj for SelectWordScreen { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + SelectWordMsg::Selected(i) => i.try_into(), + SelectWordMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs index f5b943f5081..a6f4b43a642 100644 --- a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs @@ -1,3 +1,5 @@ pub mod eckhart_swipe_flow_test; +pub mod show_share_words; pub use eckhart_swipe_flow_test::new_eckhart_swipe_flow; +pub use show_share_words::new_show_share_words_flow; diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/show_share_words.rs b/core/embed/rust/src/ui/layout_eckhart/flow/show_share_words.rs new file mode 100644 index 00000000000..fbf582beb93 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/flow/show_share_words.rs @@ -0,0 +1,111 @@ +use crate::{ + error, + strutil::TString, + translations::TR, + ui::{ + component::{ + text::{ + op::OpTextLayout, + paragraphs::{Paragraph, ParagraphSource}, + }, + ComponentExt, FormattedText, + }, + flow::{ + base::{Decision, DecisionBuilder as _}, + FlowController, FlowMsg, SwipeFlow, + }, + geometry::{Direction, LinearPlacement}, + }, +}; + +use heapless::Vec; + +use super::super::{ + component::{ + ActionBar, Button, Header, ShareWordsScreen, ShareWordsScreenMsg, TextScreen, TextScreenMsg, + }, + fonts, theme, +}; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ShowShareWords { + Instruction, + ShareWords, + Confirm, +} + +impl FlowController for ShowShareWords { + #[inline] + fn index(&'static self) -> usize { + *self as usize + } + + fn handle_swipe(&'static self, direction: Direction) -> Decision { + match (self, direction) { + _ => self.do_nothing(), + } + } + + fn handle_event(&'static self, msg: FlowMsg) -> Decision { + match (self, msg) { + (Self::Instruction, FlowMsg::Cancelled) => self.return_msg(FlowMsg::Cancelled), + (Self::Instruction, FlowMsg::Confirmed) => Self::ShareWords.goto(), + (Self::ShareWords, FlowMsg::Cancelled) => Self::Instruction.goto(), + (Self::ShareWords, FlowMsg::Confirmed) => Self::Confirm.goto(), + (Self::Confirm, FlowMsg::Cancelled) => Self::ShareWords.goto(), + (Self::Confirm, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + _ => self.do_nothing(), + } + } +} + +pub fn new_show_share_words_flow( + words: Vec, 33>, + _subtitle: TString<'static>, + instruction: Paragraph<'static>, + text_confirm: TString<'static>, +) -> Result { + let instruction = TextScreen::new( + instruction + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(TR::reset__recovery_wallet_backup_title.into())) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_UP), + Button::with_text(TR::buttons__continue.into()), + )) + .map(|msg| match msg { + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + _ => Some(FlowMsg::Cancelled), + }); + + let share_words = ShareWordsScreen::new(words).map(|msg| match msg { + ShareWordsScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + ShareWordsScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + }); + + let op_confirm = + OpTextLayout::new(theme::TEXT_NORMAL).text(text_confirm, fonts::FONT_SATOSHI_REGULAR_38); + + let confirm = TextScreen::new(FormattedText::new(op_confirm)) + .with_header(Header::new(TR::reset__recovery_wallet_backup_title.into())) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CHEVRON_LEFT), + Button::with_text(TR::buttons__hold_to_confirm.into()) + .styled(theme::button_confirm()) + .with_long_press(theme::CONFIRM_HOLD_DURATION), + )) + .map(|msg| match msg { + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Menu => Some(FlowMsg::Cancelled), + }); + + let res = SwipeFlow::new(&ShowShareWords::Instruction)? + .with_page(&ShowShareWords::Instruction, instruction)? + .with_page(&ShowShareWords::ShareWords, share_words)? + .with_page(&ShowShareWords::Confirm, confirm)?; + Ok(res) +} diff --git a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs index 4da7dd0716d..47348af6f05 100644 --- a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs @@ -280,10 +280,10 @@ macro_rules! menu_item_title { }, active: &ButtonStyle { font: fonts::FONT_SATOSHI_REGULAR_38, - text_color: $color, - button_color: BG, - icon_color: $color, - background_color: BG, + text_color: GREY_DARK, + button_color: GREY_SUPER_DARK, + icon_color: GREY_DARK, + background_color: GREY_SUPER_DARK, }, disabled: &ButtonStyle { font: fonts::FONT_SATOSHI_REGULAR_38, @@ -307,6 +307,32 @@ pub const fn menu_item_title_yellow() -> ButtonStyleSheet { pub const fn menu_item_title_red() -> ButtonStyleSheet { menu_item_title!(RED) } +pub const fn button_select_word() -> ButtonStyleSheet { + ButtonStyleSheet { + normal: &ButtonStyle { + font: fonts::FONT_SATOSHI_EXTRALIGHT_46, + text_color: GREY_EXTRA_LIGHT, + button_color: BG, + icon_color: GREY_EXTRA_LIGHT, + background_color: BG, + }, + active: &ButtonStyle { + font: fonts::FONT_SATOSHI_EXTRALIGHT_46, + text_color: GREY_DARK, + button_color: GREY_SUPER_DARK, + icon_color: GREY_DARK, + background_color: GREY_SUPER_DARK, + }, + // unused + disabled: &ButtonStyle { + font: fonts::FONT_SATOSHI_EXTRALIGHT_46, + text_color: BG, + button_color: BG, + icon_color: BG, + background_color: BG, + }, + } +} // Result constants pub const RESULT_PADDING: i16 = 6; diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs index b1bd638cc2f..b500595372f 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -24,8 +24,8 @@ use crate::{ }; use super::{ - component::{ActionBar, Button, Header, HeaderMsg, Hint, TextScreen}, - fonts, theme, UIEckhart, + component::{ActionBar, Button, Header, HeaderMsg, Hint, SelectWordScreen, TextScreen}, + flow, fonts, theme, UIEckhart, }; impl FirmwareUI for UIEckhart { @@ -389,11 +389,18 @@ impl FirmwareUI for UIEckhart { } fn select_word( - _title: TString<'static>, - _description: TString<'static>, - _words: [TString<'static>; MAX_WORD_QUIZ_ITEMS], + title: TString<'static>, + description: TString<'static>, + words: [TString<'static>; MAX_WORD_QUIZ_ITEMS], ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let component = SelectWordScreen::new(words, description).with_header( + Header::new(title) + .with_right_button(Button::with_icon(theme::ICON_MENU), HeaderMsg::Cancelled), + ); + + let layout = RootComponent::new(component); + + Ok(layout) } fn select_word_count(_recovery_type: RecoveryType) -> Result { @@ -553,17 +560,33 @@ impl FirmwareUI for UIEckhart { _words: heapless::Vec, 33>, _title: Option>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + Err::, Error>(Error::ValueError( + c"use show_share_words_extended instead", + )) } - fn show_share_words_delizia( - _words: heapless::Vec, 33>, - _subtitle: Option>, - _instructions: Obj, + fn show_share_words_extended( + words: heapless::Vec, 33>, + subtitle: Option>, + instructions: Obj, + // Irrelevant for Eckhart because the footer is dynamic _text_footer: Option>, - _text_confirm: TString<'static>, - ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + text_confirm: TString<'static>, + ) -> Result { + // TODO: add support for multiple instructions + let instruction: TString = IterBuf::new() + .try_iterate(instructions)? + .next() + .unwrap() + .try_into()?; + + let flow = flow::show_share_words::new_show_share_words_flow( + words, + subtitle.unwrap_or(TString::empty()), + Paragraph::new(&theme::TEXT_REGULAR, instruction), + text_confirm, + )?; + Ok(flow) } fn show_remaining_shares(_pages_iterable: Obj) -> Result { diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs index 054dd90ae7f..927213041fd 100644 --- a/core/embed/rust/src/ui/ui_firmware.rs +++ b/core/embed/rust/src/ui/ui_firmware.rs @@ -340,8 +340,8 @@ pub trait FirmwareUI { ) -> Result; // TODO: merge with `show_share_words` instead of having specific version for - // Delizia UI - fn show_share_words_delizia( + // Delizia/Eckhart UI + fn show_share_words_extended( words: Vec, 33>, subtitle: Option>, instructions: Obj, // TODO: replace Obj diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi index 876ecfbb636..5dbefcd2ae7 100644 --- a/core/mocks/generated/trezorui_api.pyi +++ b/core/mocks/generated/trezorui_api.pyi @@ -603,7 +603,7 @@ def show_share_words( # rust/src/ui/api/firmware_micropython.rs -def show_share_words_delizia( +def show_share_words_extended( *, words: Iterable[str], subtitle: str | None, diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index 99590e03092..76bba5b4974 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -665,6 +665,7 @@ class TR: reset__set_it_to_count_template: str = "Set it to {0} and you will need " reset__share_checked_successfully_template: str = "Share #{0} checked successfully." reset__share_completed_template: str = "Share #{0} completed" + reset__share_words_first: str = "Write down the first word from the backup." reset__share_words_title: str = "Standard backup" reset__slip39_checklist_more_info_threshold: str = "The threshold sets the minumum number of shares needed to recover your wallet." reset__slip39_checklist_more_info_threshold_example_template: str = "If you set {0} out of {1} shares, you'll need {2} backup shares to recover your wallet." diff --git a/core/src/trezor/ui/layouts/delizia/reset.py b/core/src/trezor/ui/layouts/delizia/reset.py index ec65910b6da..c7e616228d1 100644 --- a/core/src/trezor/ui/layouts/delizia/reset.py +++ b/core/src/trezor/ui/layouts/delizia/reset.py @@ -38,7 +38,7 @@ def show_share_words( text_confirm = TR.reset__words_written_down_template.format(words_count) return raise_if_not_confirmed( - trezorui_api.show_share_words_delizia( + trezorui_api.show_share_words_extended( words=share_words, subtitle=subtitle, instructions=instructions, diff --git a/core/src/trezor/ui/layouts/eckhart/reset.py b/core/src/trezor/ui/layouts/eckhart/reset.py index 2430cc6ac57..459a44ec145 100644 --- a/core/src/trezor/ui/layouts/eckhart/reset.py +++ b/core/src/trezor/ui/layouts/eckhart/reset.py @@ -16,8 +16,31 @@ def show_share_words( share_index: int | None = None, group_index: int | None = None, ) -> Awaitable[None]: - # FIXME: not implemented - raise NotImplemented + if share_index is None: + subtitle = None + elif group_index is None: + subtitle = TR.reset__recovery_share_title_template.format(share_index + 1) + else: + subtitle = TR.reset__group_share_title_template.format( + group_index + 1, share_index + 1 + ) + words_count = len(share_words) + description = None + # Eckhart currently has only one instruction, other are shown in the hint area + instructions = [TR.reset__write_down_words_template.format(words_count)] + assert len(instructions) == 1 + text_confirm = TR.reset__words_written_down_template.format(words_count) + + return raise_if_not_confirmed( + trezorui_api.show_share_words_extended( + words=share_words, + subtitle=subtitle, + instructions=instructions, + text_footer=description, + text_confirm=text_confirm, + ), + None, + ) async def select_word( @@ -46,9 +69,7 @@ async def select_word( result = await interact( trezorui_api.select_word( title=title, - description=TR.reset__select_word_x_of_y_template.format( - checked_index + 1, count - ), + description=TR.reset__select_word_template.format(checked_index + 1), words=(words[0], words[1], words[2]), ), None, diff --git a/core/translations/en.json b/core/translations/en.json index b00b3531e16..1a5263944f0 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -662,12 +662,18 @@ "reset__required_number_of_groups": "The required number of groups for recovery.", "reset__select_correct_word": "Select the correct word for each position.", "reset__select_threshold": "Select the minimum shares required to recover your wallet.", - "reset__select_word_template": "Select {0} word", + "reset__select_word_template": { + "Bolt": "Select {0} word", + "Caesar": "Select {0} word", + "Delizia": "Select {0} word", + "Eckhart": "Select word #{0} from your wallet backup" + }, "reset__select_word_x_of_y_template": "Select word {0} of {1}:", "reset__set_it_to_count_template": "Set it to {0} and you will need ", "reset__share_checked_successfully_template": "Share #{0} checked successfully.", "reset__share_completed_template": "Share #{0} completed", "reset__share_words_title": "Standard backup", + "reset__share_words_first": "Write down the first word from the backup.", "reset__slip39_checklist_more_info_threshold": "The threshold sets the minumum number of shares needed to recover your wallet.", "reset__slip39_checklist_more_info_threshold_example_template": "If you set {0} out of {1} shares, you'll need {2} backup shares to recover your wallet.", "reset__slip39_checklist_num_groups": "Number of groups", @@ -684,7 +690,12 @@ "reset__slip39_checklist_write_down": "Write down and check all shares", "reset__slip39_checklist_write_down_recovery": "Write down & check all wallet backup shares", "reset__the_threshold_sets_the_number_of_shares": "The threshold sets the number of shares ", - "reset__the_word_is_repeated": "The word is repeated", + "reset__the_word_is_repeated": { + "Bolt": "The word is repeated", + "Caesar": "The word is repeated", + "Delizia": "The word is repeated", + "Eckhart": "The word appears multiple times in the backup." + }, "reset__threshold_info": "= minimum number of unique word lists used for recovery.", "reset__title_backup_is_done": "Backup is done", "reset__title_create_wallet": "Create wallet", diff --git a/core/translations/order.json b/core/translations/order.json index 648cdcfe017..10cd796855e 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -974,5 +974,6 @@ "972": "ethereum__interaction_contract", "973": "misc__enable_labeling", "974": "ethereum__unknown_contract_address_short", - "975": "instructions__keep_holding" + "975": "instructions__keep_holding", + "976": "reset__share_words_first" } diff --git a/core/translations/signatures.json b/core/translations/signatures.json index f5f26e02793..70066928b7c 100644 --- a/core/translations/signatures.json +++ b/core/translations/signatures.json @@ -1,8 +1,8 @@ { "current": { - "merkle_root": "43c08e81d71c1c28d77b1650fc96b0dfcd473fde0c922717e5588baeeb581bd3", - "datetime": "2025-02-14T16:12:57.065880", - "commit": "3dabb94653e04856efc89d07c67b7e6f0c587f8c" + "merkle_root": "31454a46346717afd55e29f2a23f6cf64e4e23679af127d774895aa3a700c764", + "datetime": "2025-02-18T22:33:29.769368", + "commit": "778d5265dc258c1b0b74e8433acbd08d090b8746" }, "history": [ {