From caf3c304ede893d6e7448d6e552523425ccf5aa7 Mon Sep 17 00:00:00 2001 From: Lukas Bielesch Date: Wed, 29 Jan 2025 18:40:57 +0100 Subject: [PATCH] feat(eckhart): Add BIP39 input component --- .../component/keyboard/bip39.rs | 271 ++++++++++++++++++ .../layout_eckhart/component/keyboard/mod.rs | 1 + .../src/ui/layout_eckhart/component/mod.rs | 1 + .../rust/src/ui/layout_eckhart/ui_firmware.rs | 15 +- 4 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 core/embed/rust/src/ui/layout_eckhart/component/keyboard/bip39.rs diff --git a/core/embed/rust/src/ui/layout_eckhart/component/keyboard/bip39.rs b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/bip39.rs new file mode 100644 index 0000000000..8a2dd7285e --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/keyboard/bip39.rs @@ -0,0 +1,271 @@ +use crate::{ + trezorhal::bip39, + ui::{ + component::{text::common::TextBox, Component, Event, EventCtx}, + geometry::{Alignment, Offset, Point, Rect}, + shape::{Renderer, Text}, + }, +}; + +use super::super::super::{ + component::{ + keyboard::{ + common::{render_pending_marker, MultiTapKeyboard}, + mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT}, + }, + Button, ButtonMsg, + }, + constant::WIDTH, + theme, +}; + +const MAX_LENGTH: usize = 8; + +pub struct Bip39Input { + button: Button, + // used only to keep track of suggestion text color + button_suggestion: Button, + textbox: TextBox, + multi_tap: MultiTapKeyboard, + options_num: Option, + suggested_word: Option<&'static str>, +} + +impl MnemonicInput for Bip39Input { + /// Return the key set. Keys are further specified as indices into this + /// array. + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] { + ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"] + } + + fn can_be_confirmed(&self) -> bool { + self.is_choice_unambiguous() + } + + /// Returns `true` if given key index can continue towards a valid mnemonic + /// word, `false` otherwise. + fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool { + // Currently pending key is always enabled. + let key_is_pending = self.multi_tap.pending_key() == Some(key); + // Keys that contain letters from the completion mask are enabled as well. + let key_matches_mask = + bip39::word_completion_mask(self.textbox.content()) & Self::key_mask(key) != 0; + key_is_pending || key_matches_mask + } + + /// Key button was clicked. If this button is pending, let's cycle the + /// pending character in textbox. If not, let's just append the first + /// character. + fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) { + let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]); + self.textbox.apply(ctx, edit); + self.complete_word_from_dictionary(ctx); + } + + /// Backspace button was clicked, let's delete the last character of input + /// and clear the pending marker. + fn on_backspace_click(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.delete_last(ctx); + self.complete_word_from_dictionary(ctx); + } + + fn on_confirm_click(&mut self, ctx: &mut EventCtx) -> Option { + // same action as clicking on the input + self.on_input_click(ctx) + } + + /// Backspace button was long pressed, let's delete all characters of input + /// and clear the pending marker. + fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.clear(ctx); + self.complete_word_from_dictionary(ctx); + } + + fn is_empty(&self) -> bool { + self.textbox.is_empty() + } + + fn mnemonic(&self) -> Option<&'static str> { + self.suggested_word + } +} + +impl Component for Bip39Input { + type Msg = MnemonicInputMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.button.place(bounds); + self.button_suggestion.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Event::Attach(_) = event { + // Catch up with the prefilled word + if !self.textbox.is_empty() { + self.complete_word_from_dictionary(ctx); + return Some(MnemonicInputMsg::Completed); + } + } + + _ = self.button_suggestion.event(ctx, event); + + if self.multi_tap.timeout_event(event) { + self.on_timeout(ctx) + } else if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) { + self.on_input_click(ctx) + } else { + None + } + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + let area = self.button.area(); + let style = self.button.style(); + let suggestion_style = self.button_suggestion.style(); + + // Paint the entered content (the prefix of the suggested word). + let text = self.textbox.content(); + let width = style.font.text_width(text); + + // User input together with suggestion is centered vertically in the input area + // and centered horizontally on the screen + let text_base_y = area.left_center().y + style.font.allcase_text_height() / 2; + let text_base_x = if let Some(word) = self.suggested_word { + style.font.horz_center(0, WIDTH, word) + } else { + style.font.horz_center(0, WIDTH, text) + }; + let text_base = Point::new(text_base_x, text_base_y); + + self.button.render(target); + + // Render text input + suggested completion + Text::new(text_base, text, style.font) + .with_fg(style.text_color) + .with_align(Alignment::Start) + .render(target); + if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) { + let word_baseline = text_base + Offset::x(width); + Text::new(word_baseline, word, style.font) + .with_fg(suggestion_style.text_color) + .with_align(Alignment::Start) + .render(target); + } + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + render_pending_marker(target, text_base, text, style.font, style.text_color); + } + } +} + +impl Bip39Input { + pub fn new() -> Self { + Self::prefilled_word("") + } + + pub fn prefilled_word(word: &str) -> Self { + // Word may be empty string, fallback to normal input + let (options_num, suggested_word) = if word.is_empty() { + (bip39::options_num(word), bip39::complete_word(word)) + } else { + (None, None) + }; + + // Styling the input to reflect already filled word + Self { + button: Button::empty() + .styled(theme::input_mnemonic()) + .with_radius(12), + button_suggestion: Button::empty().styled(theme::input_mnemonic_suggestion()), + textbox: TextBox::new(word, MAX_LENGTH), + multi_tap: MultiTapKeyboard::new(), + options_num, + suggested_word, + } + } + + /// Compute a bitmask of all letters contained in given key text. Lowest bit + /// is 'a', second lowest 'b', etc. + fn key_mask(key: usize) -> u32 { + let mut mask = 0; + for ch in Self::keys()[key].as_bytes() { + // We assume the key text is lower-case alphabetic ASCII, making the subtraction + // and the shift panic-free. + mask |= 1 << (ch - b'a'); + } + mask + } + + fn is_choice_unambiguous(&self) -> bool { + if let (Some(word), Some(_num)) = (self.suggested_word, self.options_num) { + return word.eq(self.textbox.content()); + } + false + } + + /// Input button was clicked. If the content matches the suggested word, + /// let's confirm it, otherwise just auto-complete. + fn on_input_click(&mut self, ctx: &mut EventCtx) -> Option { + if let (Some(word), Some(_num)) = (self.suggested_word, self.options_num) { + return if word.eq(self.textbox.content()) { + // Confirm button. + self.textbox.clear(ctx); + Some(MnemonicInputMsg::Confirmed) + } else { + // Auto-complete button. + self.textbox.replace(ctx, word); + self.complete_word_from_dictionary(ctx); + ctx.request_paint(); + Some(MnemonicInputMsg::Completed) + }; + } + None + } + + /// Timeout occurred. If we can auto-complete current input, let's just + /// reset the pending marker. If not, input is invalid, let's backspace the + /// last character. + fn on_timeout(&mut self, ctx: &mut EventCtx) -> Option { + self.multi_tap.clear_pending_state(ctx); + if self.suggested_word.is_none() { + self.textbox.delete_last(ctx); + self.complete_word_from_dictionary(ctx); + } + Some(MnemonicInputMsg::TimedOut) + } + + fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) { + self.options_num = bip39::options_num(self.textbox.content()); + self.suggested_word = bip39::complete_word(self.textbox.content()); + + // Change the style of the button depending on the completed word. + if let (Some(word), Some(_num)) = (self.suggested_word, self.options_num) { + if word.eq(self.textbox.content()) { + // Confirm button. + self.button.enable(ctx); + self.button.set_stylesheet(theme::input_mnemonic_confirm()); + } else { + // Auto-complete button. + self.button.enable(ctx); + self.button.set_stylesheet(theme::input_mnemonic()); + } + } else { + // Disabled button. + self.button.disable(ctx); + self.button.set_stylesheet(theme::input_mnemonic()); + } + } +} + +// DEBUG-ONLY SECTION BELOW + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Bip39Input { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Bip39Input"); + t.child("textbox", &self.textbox); + } +} 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 78fed89eda..9727baf1db 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 @@ -1,3 +1,4 @@ +pub mod bip39; pub mod mnemonic; pub mod passphrase; pub mod pin; 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 336882081a..6c5994f0b0 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs @@ -22,6 +22,7 @@ pub use hint::Hint; pub use hold_to_confirm::HoldToConfirmAnim; #[cfg(feature = "translations")] pub use keyboard::{ + bip39::Bip39Input, mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg}, passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg}, pin::{PinKeyboard, PinKeyboardMsg}, 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 a74e6cf67e..2a43f36327 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -25,7 +25,7 @@ use crate::{ use super::{ component::{ - ActionBar, Button, Header, HeaderMsg, Hint, MnemonicKeyboard, PinKeyboard, + ActionBar, Bip39Input, Button, Header, HeaderMsg, Hint, MnemonicKeyboard, PinKeyboard, SelectWordScreen, TextScreen, }, flow, fonts, theme, UIEckhart, @@ -349,11 +349,16 @@ impl FirmwareUI for UIEckhart { } fn request_bip39( - _prompt: TString<'static>, - _prefill_word: TString<'static>, - _can_go_back: bool, + prompt: TString<'static>, + prefill_word: TString<'static>, + can_go_back: bool, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let layout = RootComponent::new(MnemonicKeyboard::new( + prefill_word.map(Bip39Input::prefilled_word), + prompt, + can_go_back, + )); + Ok(layout) } fn request_slip39(