diff --git a/core/embed/rust/src/ui/layout_eckhart/component/keyboard/mod.rs b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/mod.rs index a125587332..26bcc1cd8e 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/keyboard/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/mod.rs @@ -3,6 +3,7 @@ pub mod mnemonic; pub mod passphrase; pub mod pin; pub mod slip39; +pub mod word_count_screen; mod common; mod keypad; diff --git a/core/embed/rust/src/ui/layout_eckhart/component/keyboard/word_count_screen.rs b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/word_count_screen.rs new file mode 100644 index 0000000000..f5035cb9fb --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/word_count_screen.rs @@ -0,0 +1,291 @@ +use crate::{ + strutil::TString, + ui::{ + component::{Component, Event, EventCtx, Label}, + geometry::{Alignment, Insets, Offset, Rect}, + shape::Renderer, + }, +}; + +use super::super::{ + super::{super::constant::SCREEN, theme}, + Button, ButtonMsg, Header, +}; + +use heapless::Vec; + +pub enum SelectWordCountMsg { + Cancelled, + Selected(u32), +} + +pub struct SelectWordCountScreen { + /// Screen header + header: Header, + /// Screeen description + description: Label<'static>, + /// Value keypad + keypad: ValueKeypad, +} + +impl SelectWordCountScreen { + const DESCRIPTION_HEIGHT: i16 = 71; + const KEYPAD_HEIGHT: i16 = 334; + + pub fn new_multi_share(description: TString<'static>) -> Self { + Self::new(description, ValueKeypad::new_multi_share()) + } + + pub fn new_single_share(description: TString<'static>) -> Self { + Self::new(description, ValueKeypad::new_single_share()) + } + + fn new(description: TString<'static>, keypad: ValueKeypad) -> Self { + Self { + header: Header::new(TString::empty()), + description: Label::new(description, Alignment::Start, theme::TEXT_MEDIUM) + .top_aligned(), + keypad, + } + } + + pub fn with_header(mut self, header: Header) -> Self { + self.header = header; + self + } +} + +impl Component for SelectWordCountScreen { + type Msg = SelectWordCountMsg; + + 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 (keypad_area, _) = rest.split_top(Self::KEYPAD_HEIGHT); + + let description_area = description_area.inset(Insets::sides(24)); + + self.header.place(header_area); + self.description.place(description_area); + self.keypad.place(keypad_area); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.keypad.event(ctx, event) + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.header.render(target); + self.description.render(target); + self.keypad.render(target); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SelectWordCountScreen { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("SelectWordCountScreen"); + t.child("description", &self.description); + } +} + +const MAX_KEYS: usize = 5; +pub struct ValueKeypad { + cancel: Button, + keys: Vec, + numbers: Vec, + area: Rect, + pressed: Option, +} + +impl ValueKeypad { + const ROWS: usize = 3; + const BUTTON_SIZE: Offset = Offset::new(138, 130); + const CANCEL_BUTTON_INDEX: usize = 2; + + pub fn new_single_share() -> Self { + const NUMBERS: [u32; 5] = [12, 24, 18, 20, 33]; + const LABELS: [&'static str; 5] = ["12", "24", "18", "20", "33"]; + Self::new(&LABELS, &NUMBERS) + } + + pub fn new_multi_share() -> Self { + const NUMBERS: [u32; 2] = [20, 33]; + const LABELS: [&'static str; 2] = ["20", "33"]; + Self::new(&LABELS, &NUMBERS) + } + + /// Convert key index to grid cell index. + fn key_2_grid_cell(key: usize) -> usize { + // Make sure the key is within bounds. + debug_assert!(key < MAX_KEYS); + // Key with index 2 must be mapped after the cancel button. + if key < Self::CANCEL_BUTTON_INDEX { + key + } else { + key + 1 + } + } + + fn new(labels: &[&'static str], numbers: &[u32]) -> Self { + debug_assert_eq!(labels.len(), numbers.len()); + debug_assert!(labels.len() <= MAX_KEYS); + + let keys: Vec = labels + .iter() + .map(|&t| { + Button::with_text(t.into()) + .styled(theme::button_keyboard_numeric()) + .with_text_align(Alignment::Center) + .with_radius(12) + }) + .collect(); + + let numbers: Vec = numbers.iter().copied().collect(); + + ValueKeypad { + cancel: Button::with_icon(theme::ICON_CROSS) + .styled(theme::button_cancel()) + .with_radius(12), + keys, + numbers, + area: Rect::zero(), + pressed: None, + } + } + + fn get_button_border(&self, idx: usize) -> Rect { + // Make sure the key is within bounds. + debug_assert!(idx < MAX_KEYS); + match idx { + 0 => Rect::from_top_left_and_size(self.area.top_left(), Self::BUTTON_SIZE), + 1 => Rect::from_center_and_size( + self.area + .left_center() + .ofs(Offset::x(Self::BUTTON_SIZE.x / 2)), + Self::BUTTON_SIZE, + ), + 2 => Rect::from_bottom_left_and_size(self.area.bottom_left(), Self::BUTTON_SIZE), + 3 => Rect::from_top_right_and_size(self.area.top_right(), Self::BUTTON_SIZE), + 4 => Rect::from_center_and_size( + self.area + .right_center() + .ofs(Offset::x(-Self::BUTTON_SIZE.x / 2)), + Self::BUTTON_SIZE, + ), + 5 => Rect::from_bottom_right_and_size(self.area.bottom_right(), Self::BUTTON_SIZE), + _ => Rect::zero(), // Default case for out-of-range indices. + } + } + + fn get_touch_expand(&self, idx: usize) -> Insets { + debug_assert!(idx < MAX_KEYS); // Ensure the index is within bounds. + + let vertical_spacing = (self.area.height() - Self::BUTTON_SIZE.y * Self::ROWS as i16) + / (Self::ROWS as i16 - 1); + + if idx % Self::ROWS == 0 { + Insets::bottom(vertical_spacing / 2) + } else if idx % Self::ROWS == Self::ROWS - 1 { + Insets::top(vertical_spacing / 2) + } else { + Insets::new(vertical_spacing / 2, 0, vertical_spacing / 2, 0) + } + } +} + +impl Component for ValueKeypad { + type Msg = SelectWordCountMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = if self.keys.len() < 3 { + // One column + Rect::from_center_and_size( + bounds.center(), + Offset::new(Self::BUTTON_SIZE.x, bounds.height()), + ) + } else { + // Two columns + bounds.inset(Insets::sides(42)) + }; + + for i in 0..self.keys.len() { + let cell = Self::key_2_grid_cell(i); + let border = self.get_button_border(cell); + let touch_expand = self.get_touch_expand(cell); + self.keys[i].place(border); + self.keys[i].set_expanded_touch_area(touch_expand); + } + + self.cancel + .place(self.get_button_border(Self::CANCEL_BUTTON_INDEX)); + self.cancel + .set_expanded_touch_area(self.get_touch_expand(Self::CANCEL_BUTTON_INDEX)); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + for (i, btn) in self.keys.iter_mut().enumerate() { + match btn.event(ctx, event) { + Some(ButtonMsg::Clicked) => { + self.pressed = None; + return Some(SelectWordCountMsg::Selected(self.numbers[i])); + } + // Detect press of all special buttons for rendering purposes + Some(ButtonMsg::Pressed) => { + self.pressed = Some(i); + } + _ => {} + } + } + + match self.cancel.event(ctx, event) { + Some(ButtonMsg::Clicked) => { + self.pressed = None; + return Some(SelectWordCountMsg::Cancelled); + } + Some(ButtonMsg::Pressed) => { + // No need to detect press of cancel button bacause of the bottom row placement + self.pressed = None; + } + _ => {} + } + + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + for btn in self.keys.iter() { + btn.render(target) + } + + self.cancel.render(target); + + if let Some(idx) = self.pressed { + self.keys[idx].render(target); + } + } +} + +#[cfg(test)] +mod tests { + use super::{super::super::constant::SCREEN, *}; + + #[test] + fn test_component_heights_fit_screen() { + assert!( + SelectWordCountScreen::DESCRIPTION_HEIGHT + + SelectWordCountScreen::KEYPAD_HEIGHT + + Header::HEADER_HEIGHT + <= SCREEN.height(), + "Components overflow the screen height", + ); + } +} 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 540cadfa6d..24013c36bd 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs @@ -27,6 +27,7 @@ pub use keyboard::{ passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg}, pin::{PinKeyboard, PinKeyboardMsg}, slip39::Slip39Input, + word_count_screen::{SelectWordCountMsg, SelectWordCountScreen}, }; pub use result::{ResultFooter, ResultScreen, ResultStyle}; pub use select_word_screen::{SelectWordMsg, SelectWordScreen}; 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 6bec866557..43408cfa7a 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 @@ -15,7 +15,8 @@ use crate::{ use super::component::{ AllowedTextContent, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, PinKeyboard, - PinKeyboardMsg, SelectWordMsg, SelectWordScreen, TextScreen, TextScreenMsg, + PinKeyboardMsg, SelectWordCountMsg, SelectWordCountScreen, SelectWordMsg, SelectWordScreen, + TextScreen, TextScreenMsg, }; impl ComponentMsgObj for PinKeyboard<'_> { @@ -89,3 +90,12 @@ impl ComponentMsgObj for SelectWordScreen { } } } + +impl ComponentMsgObj for SelectWordCountScreen { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + SelectWordCountMsg::Selected(i) => i.try_into(), + SelectWordCountMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} 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 4305031a13..c0be24275e 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -26,7 +26,7 @@ use crate::{ use super::{ component::{ ActionBar, Bip39Input, Button, Header, HeaderMsg, Hint, MnemonicKeyboard, PinKeyboard, - SelectWordScreen, Slip39Input, TextScreen, + SelectWordCountScreen, SelectWordScreen, Slip39Input, TextScreen, }, flow, fonts, theme, UIEckhart, }; @@ -428,8 +428,16 @@ impl FirmwareUI for UIEckhart { Ok(layout) } - fn select_word_count(_recovery_type: RecoveryType) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + fn select_word_count(recovery_type: RecoveryType) -> Result { + let description = TR::recovery__num_of_words.into(); + let content = if matches!(recovery_type, RecoveryType::UnlockRepeatedBackup) { + SelectWordCountScreen::new_multi_share(description) + } else { + SelectWordCountScreen::new_single_share(description) + } + .with_header(Header::new(TR::recovery__title_recover.into())); + let layout = RootComponent::new(content); + Ok(layout) } fn set_brightness(_current_brightness: Option) -> Result {