From 3c337723b208884edfde05083f9d6261d2981e57 Mon Sep 17 00:00:00 2001 From: Jan Pochyla Date: Thu, 3 Feb 2022 14:25:15 -0300 Subject: [PATCH] chore(core/rust): Add mnemonic keyboards --- core/embed/rust/src/trezorhal/bip39.rs | 30 +++ core/embed/rust/src/trezorhal/mod.rs | 2 + core/embed/rust/src/trezorhal/slip39.rs | 36 +++ core/embed/rust/src/ui/component/maybe.rs | 89 +++++++ core/embed/rust/src/ui/component/mod.rs | 2 + core/embed/rust/src/ui/geometry.rs | 9 + .../rust/src/ui/model_tt/component/button.rs | 151 ++++++----- .../ui/model_tt/component/keyboard/bip39.rs | 214 ++++++++++++++++ .../ui/model_tt/component/keyboard/common.rs | 214 ++++++++++++++++ .../model_tt/component/keyboard/mnemonic.rs | 160 ++++++++++++ .../src/ui/model_tt/component/keyboard/mod.rs | 7 + .../component/{ => keyboard}/passphrase.rs | 11 +- .../model_tt/component/{ => keyboard}/pin.rs | 23 +- .../ui/model_tt/component/keyboard/slip39.rs | 242 ++++++++++++++++++ .../rust/src/ui/model_tt/component/mod.rs | 3 +- .../embed/rust/src/ui/model_tt/res/click.toif | Bin 0 -> 139 bytes core/embed/rust/src/ui/model_tt/res/left.toif | Bin 0 -> 73 bytes core/embed/rust/src/ui/model_tt/theme.rs | 2 + core/embed/rust/src/util.rs | 13 + 19 files changed, 1131 insertions(+), 77 deletions(-) create mode 100644 core/embed/rust/src/trezorhal/bip39.rs create mode 100644 core/embed/rust/src/trezorhal/slip39.rs create mode 100644 core/embed/rust/src/ui/component/maybe.rs create mode 100644 core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs create mode 100644 core/embed/rust/src/ui/model_tt/component/keyboard/common.rs create mode 100644 core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs create mode 100644 core/embed/rust/src/ui/model_tt/component/keyboard/mod.rs rename core/embed/rust/src/ui/model_tt/component/{ => keyboard}/passphrase.rs (98%) rename core/embed/rust/src/ui/model_tt/component/{ => keyboard}/pin.rs (94%) create mode 100644 core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs create mode 100644 core/embed/rust/src/ui/model_tt/res/click.toif create mode 100644 core/embed/rust/src/ui/model_tt/res/left.toif diff --git a/core/embed/rust/src/trezorhal/bip39.rs b/core/embed/rust/src/trezorhal/bip39.rs new file mode 100644 index 000000000..b83c51fe2 --- /dev/null +++ b/core/embed/rust/src/trezorhal/bip39.rs @@ -0,0 +1,30 @@ +use cstr_core::CStr; + +extern "C" { + // trezor-crypto/bip39.h + fn mnemonic_complete_word(prefix: *const cty::c_char, len: cty::c_int) -> *const cty::c_char; + fn mnemonic_word_completion_mask(prefix: *const cty::c_char, len: cty::c_int) -> u32; +} + +pub fn complete_word(prefix: &str) -> Option<&'static str> { + if prefix.is_empty() { + None + } else { + // SAFETY: `mnemonic_complete_word` shouldn't retain nor modify the passed byte + // string, making the call safe. + let word = unsafe { mnemonic_complete_word(prefix.as_ptr() as _, prefix.len() as _) }; + if word.is_null() { + None + } else { + // SAFETY: On success, `mnemonic_complete_word` should return a 0-terminated + // UTF-8 string with static lifetime. + Some(unsafe { CStr::from_ptr(word).to_str().unwrap_unchecked() }) + } + } +} + +pub fn word_completion_mask(prefix: &str) -> u32 { + // SAFETY: `mnemonic_word_completion_mask` shouldn't retain nor modify the + // passed byte string, making the call safe. + unsafe { mnemonic_word_completion_mask(prefix.as_ptr() as _, prefix.len() as _) } +} diff --git a/core/embed/rust/src/trezorhal/mod.rs b/core/embed/rust/src/trezorhal/mod.rs index 3bd5157b8..b75deffb7 100644 --- a/core/embed/rust/src/trezorhal/mod.rs +++ b/core/embed/rust/src/trezorhal/mod.rs @@ -1,4 +1,6 @@ +pub mod bip39; pub mod common; #[cfg(feature = "ui")] pub mod display; pub mod random; +pub mod slip39; diff --git a/core/embed/rust/src/trezorhal/slip39.rs b/core/embed/rust/src/trezorhal/slip39.rs new file mode 100644 index 000000000..ace10c70f --- /dev/null +++ b/core/embed/rust/src/trezorhal/slip39.rs @@ -0,0 +1,36 @@ +use cstr_core::CStr; + +mod ffi { + extern "C" { + // trezor-crypto/slip39.h + pub fn slip39_word_completion_mask(prefix: u16) -> u16; + pub fn button_sequence_to_word(sequence: u16) -> *const cty::c_char; + } +} + +/// Calculates which buttons still can be pressed after some already were. +/// Returns a 9-bit bitmask, where each bit specifies which buttons +/// can be further pressed (there are still words in this combination). +/// LSB denotes first button. +/// +/// Example: 110000110 - second, third, eighth and ninth button still can be +/// pressed. +pub fn word_completion_mask(prefix: u16) -> Option { + if !(1..=9999).contains(&prefix) { + None + } else { + Some(unsafe { ffi::slip39_word_completion_mask(prefix) }) + } +} + +/// Finds the first word that fits the given button prefix. +pub fn button_sequence_to_word(prefix: u16) -> Option<&'static str> { + let word = unsafe { ffi::button_sequence_to_word(prefix) }; + if word.is_null() { + None + } else { + // SAFETY: On success, `button_sequence_to_word` should return a 0-terminated + // UTF-8 string with static lifetime. + Some(unsafe { CStr::from_ptr(word).to_str().unwrap_unchecked() }) + } +} diff --git a/core/embed/rust/src/ui/component/maybe.rs b/core/embed/rust/src/ui/component/maybe.rs new file mode 100644 index 000000000..836247463 --- /dev/null +++ b/core/embed/rust/src/ui/component/maybe.rs @@ -0,0 +1,89 @@ +use crate::ui::{ + component::{Component, ComponentExt, Event, EventCtx, Pad}, + display::Color, + geometry::Rect, +}; + +pub struct Maybe { + inner: T, + pad: Pad, + visible: bool, +} + +impl Maybe { + pub fn new(pad: Pad, inner: T, visible: bool) -> Self { + Self { + inner, + visible, + pad, + } + } + + pub fn visible(area: Rect, clear: Color, inner: T) -> Self { + Self::new(Pad::with_background(area, clear), inner, true) + } + + pub fn hidden(area: Rect, clear: Color, inner: T) -> Self { + Self::new(Pad::with_background(area, clear), inner, false) + } +} + +impl Maybe +where + T: Component, +{ + pub fn show_if(&mut self, ctx: &mut EventCtx, show: bool) { + if self.visible != show { + self.visible = show; + + // Invalidate the pad, so either we prepare a fresh canvas for the content, or + // paint over it. + self.pad.clear(); + if show { + // Make sure the whole inner tree is painted. + self.inner.request_complete_repaint(ctx); + } else { + // Just make sure out `paint` method is called, to clear the pad. + ctx.request_paint(); + } + } + } + + pub fn show(&mut self, ctx: &mut EventCtx) { + self.show_if(ctx, true) + } + + pub fn hide(&mut self, ctx: &mut EventCtx) { + self.show_if(ctx, false) + } + + pub fn inner(&self) -> &T { + &self.inner + } + + pub fn inner_mut(&mut self) -> &mut T { + &mut self.inner + } +} + +impl Component for Maybe +where + T: Component, +{ + type Msg = T::Msg; + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.visible { + self.inner.event(ctx, event) + } else { + None + } + } + + fn paint(&mut self) { + self.pad.paint(); + if self.visible { + self.inner.paint(); + } + } +} diff --git a/core/embed/rust/src/ui/component/mod.rs b/core/embed/rust/src/ui/component/mod.rs index b1e7c1431..bc28f791e 100644 --- a/core/embed/rust/src/ui/component/mod.rs +++ b/core/embed/rust/src/ui/component/mod.rs @@ -4,6 +4,7 @@ pub mod base; pub mod empty; pub mod label; pub mod map; +pub mod maybe; pub mod pad; pub mod paginated; pub mod text; @@ -13,6 +14,7 @@ pub use base::{Child, Component, ComponentExt, Event, EventCtx, Never, TimerToke pub use empty::Empty; pub use label::{Label, LabelStyle}; pub use map::Map; +pub use maybe::Maybe; pub use pad::Pad; pub use paginated::{PageMsg, Paginate}; pub use text::{ diff --git a/core/embed/rust/src/ui/geometry.rs b/core/embed/rust/src/ui/geometry.rs index 94fc01b18..6f4aec198 100644 --- a/core/embed/rust/src/ui/geometry.rs +++ b/core/embed/rust/src/ui/geometry.rs @@ -188,6 +188,15 @@ impl Rect { point.x >= self.x0 && point.x < self.x1 && point.y >= self.y0 && point.y < self.y1 } + pub fn union(&self, other: Self) -> Self { + Self { + x0: self.x0.min(other.x0), + y0: self.y0.min(other.y0), + x1: self.x1.max(other.x1), + y1: self.y1.max(other.y1), + } + } + pub fn inset(&self, insets: Insets) -> Self { Self { x0: self.x0 + insets.left, 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 b4eae66ea..1daffe429 100644 --- a/core/embed/rust/src/ui/model_tt/component/button.rs +++ b/core/embed/rust/src/ui/model_tt/component/button.rs @@ -1,10 +1,11 @@ -use super::{event::TouchEvent, theme}; use crate::ui::{ component::{Component, Event, EventCtx, Map}, display::{self, Color, Font}, geometry::{Grid, Insets, Offset, Rect}, }; +use super::{event::TouchEvent, theme}; + pub enum ButtonMsg { Pressed, Released, @@ -43,6 +44,10 @@ impl Button { Self::new(area, ButtonContent::Icon(image)) } + pub fn empty(area: Rect) -> Self { + Self::new(area, ButtonContent::Empty) + } + pub fn styled(mut self, styles: ButtonStyleSheet) -> Self { self.styles = styles; self @@ -75,11 +80,28 @@ impl Button { matches!(self.state, State::Disabled) } + pub fn set_content(&mut self, ctx: &mut EventCtx, content: ButtonContent) + where + T: PartialEq, + { + if self.content != content { + self.content = content; + ctx.request_paint(); + } + } + pub fn content(&self) -> &ButtonContent { &self.content } - fn style(&self) -> &ButtonStyle { + pub fn set_stylesheet(&mut self, ctx: &mut EventCtx, styles: ButtonStyleSheet) { + if self.styles != styles { + self.styles = styles; + ctx.request_paint(); + } + } + + pub fn style(&self) -> &ButtonStyle { match self.state { State::Initial | State::Released => self.styles.normal, State::Pressed => self.styles.active, @@ -87,12 +109,75 @@ impl Button { } } + pub fn area(&self) -> Rect { + self.area + } + fn set(&mut self, ctx: &mut EventCtx, state: State) { if self.state != state { self.state = state; ctx.request_paint(); } } + + pub fn paint_background(&self, style: &ButtonStyle) { + if style.border_width > 0 { + // Paint the border and a smaller background on top of it. + display::rect_fill_rounded( + self.area, + style.border_color, + style.background_color, + style.border_radius, + ); + display::rect_fill_rounded( + self.area.inset(Insets::uniform(style.border_width)), + style.button_color, + style.border_color, + style.border_radius, + ); + } else { + // We do not need to draw an explicit border in this case, just a + // bigger background. + display::rect_fill_rounded( + self.area, + style.button_color, + style.background_color, + style.border_radius, + ); + } + } + + pub fn paint_content(&self, style: &ButtonStyle) + where + T: AsRef<[u8]>, + { + match &self.content { + ButtonContent::Empty => {} + ButtonContent::Text(text) => { + let text = text.as_ref(); + let width = style.font.text_width(text); + let height = style.font.text_height(); + let start_of_baseline = self.area.center() + + Offset::new(-width / 2, height / 2) + + Offset::y(Self::BASELINE_OFFSET); + display::text( + start_of_baseline, + text, + style.font, + style.text_color, + style.button_color, + ); + } + ButtonContent::Icon(icon) => { + display::icon( + self.area.center(), + icon, + style.text_color, + style.button_color, + ); + } + } + } } impl Component for Button @@ -157,61 +242,8 @@ where fn paint(&mut self) { let style = self.style(); - - if style.border_width > 0 { - // Paint the border and a smaller background on top of it. - display::rect_fill_rounded( - self.area, - style.border_color, - style.background_color, - style.border_radius, - ); - display::rect_fill_rounded( - self.area.inset(Insets::uniform(style.border_width)), - style.button_color, - style.border_color, - style.border_radius, - ); - } else { - // We do not need to draw an explicit border in this case, just a - // bigger background. - display::rect_fill_rounded( - self.area, - style.button_color, - style.background_color, - style.border_radius, - ); - } - - match &self.content { - ButtonContent::Text(text) => { - let text = text.as_ref(); - let width = style.font.text_width(text); - let height = style.font.text_height(); - let start_of_baseline = self.area.center() - + Offset::new(-width / 2, height / 2) - + Offset::y(Self::BASELINE_OFFSET); - display::text( - start_of_baseline, - text, - style.font, - style.text_color, - style.button_color, - ); - } - ButtonContent::Icon(icon) => { - display::icon( - self.area.center(), - icon, - style.text_color, - style.button_color, - ); - } - } - } - - fn bounds(&self, sink: &mut dyn FnMut(Rect)) { - sink(self.area) + self.paint_background(&style); + self.paint_content(&style); } } @@ -223,6 +255,7 @@ where fn trace(&self, t: &mut dyn crate::trace::Tracer) { t.open("Button"); match &self.content { + ButtonContent::Empty => {} ButtonContent::Text(text) => t.field("text", text), ButtonContent::Icon(_) => t.symbol("icon"), } @@ -238,17 +271,21 @@ enum State { Disabled, } +#[derive(PartialEq, Eq)] pub enum ButtonContent { + Empty, Text(T), Icon(&'static [u8]), } +#[derive(PartialEq, Eq)] pub struct ButtonStyleSheet { pub normal: &'static ButtonStyle, pub active: &'static ButtonStyle, pub disabled: &'static ButtonStyle, } +#[derive(PartialEq, Eq)] pub struct ButtonStyle { pub font: Font, pub text_color: Color, 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 new file mode 100644 index 000000000..20da501f8 --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs @@ -0,0 +1,214 @@ +use crate::{ + trezorhal::bip39, + ui::{ + component::{Component, Event, EventCtx}, + display, + geometry::{Offset, Rect}, + model_tt::{ + component::{ + keyboard::{ + common::{MultiTapKeyboard, TextBox}, + mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT}, + }, + Button, ButtonContent, ButtonMsg, + }, + theme, + }, + }, +}; + +const MAX_LENGTH: usize = 8; + +pub struct Bip39Input { + button: Button<&'static [u8]>, + textbox: TextBox, + multi_tap: MultiTapKeyboard, + suggested_word: Option<&'static str>, +} + +impl MnemonicInput for Bip39Input { + fn new(area: Rect) -> Self { + Self { + button: Button::empty(area), + textbox: TextBox::empty(), + multi_tap: MultiTapKeyboard::new(), + suggested_word: None, + } + } + + /// 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"] + } + + /// 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 is_empty(&self) -> bool { + self.textbox.is_empty() + } +} + +impl Component for Bip39Input { + type Msg = MnemonicInputMsg; + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.multi_tap.is_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 paint(&mut self) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.paint_background(&style); + + // Paint the entered content (the prefix of the suggested word). + let text = self.textbox.content().as_bytes(); + let width = style.font.text_width(text); + // 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( + text_baseline, + text, + style.font, + style.text_color, + style.button_color, + ); + + // 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( + word_baseline, + word.as_bytes(), + style.font, + theme::GREY_LIGHT, + style.button_color, + ); + } + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + // Measure the width of the last character of input. + if let Some(last) = text.last().copied() { + let last_width = style.font.text_width(&[last]); + // Draw the marker 2px under the start of the baseline of the last character. + let marker_origin = text_baseline + Offset::new(width - last_width, 2); + // Draw the marker 1px longer than the last character, and 3px thick. + let marker_rect = + Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3)); + display::rect_fill(marker_rect, style.text_color); + } + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + display::icon(icon_center, icon, style.text_color, style.button_color); + } + } +} + +impl Bip39Input { + /// 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 + } + + /// 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 { + match self.suggested_word { + Some(word) if word == self.textbox.content() => { + self.textbox.clear(ctx); + Some(MnemonicInputMsg::Confirmed) + } + Some(word) => { + self.textbox.replace(ctx, word); + Some(MnemonicInputMsg::Completed) + } + None => 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.suggested_word = bip39::complete_word(self.textbox.content()); + + // Change the style of the button depending on the completed word. + if let Some(word) = self.suggested_word { + if self.textbox.content() == word { + // Confirm button. + self.button.enable(ctx); + self.button.set_stylesheet(ctx, theme::button_confirm()); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_CONFIRM)); + } else { + // Auto-complete button. + self.button.enable(ctx); + self.button.set_stylesheet(ctx, theme::button_default()); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_CLICK)); + } + } else { + // Disabled button. + self.button.disable(ctx); + self.button.set_stylesheet(ctx, theme::button_default()); + self.button.set_content(ctx, ButtonContent::Text(b"")); + } + } +} diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/common.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/common.rs new file mode 100644 index 000000000..f89467e40 --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/common.rs @@ -0,0 +1,214 @@ +use heapless::String; + +use crate::util::ResultExt; +use crate::{ + time::Duration, + ui::component::{Event, EventCtx, TimerToken}, +}; + +/// Contains state commonly used in implementations multi-tap keyboards. +pub struct MultiTapKeyboard { + /// Configured timeout after which we cancel currently pending key. + timeout: Duration, + /// The currently pending state. + pending: Option, +} + +struct Pending { + /// Index of the pending key. + key: usize, + /// Index of the key press (how many times the `key` was pressed, minus + /// one). + press: usize, + /// Timer for clearing the pending state. + timer: TimerToken, +} + +impl MultiTapKeyboard { + /// Create a new, empty, multi-tap state. + pub fn new() -> Self { + Self { + timeout: Duration::from_secs(1), + pending: None, + } + } + + /// Return the index of the currently pending key, if any. + pub fn pending_key(&self) -> Option { + self.pending.as_ref().map(|p| p.key) + } + + /// Return the index of the pending key press. + pub fn pending_press(&self) -> Option { + self.pending.as_ref().map(|p| p.press) + } + + /// Return the token for the currently pending timer. + pub fn pending_timer(&self) -> Option { + self.pending.as_ref().map(|p| p.timer) + } + + /// Returns `true` if `event` is an `Event::Timer` for the currently pending + /// timer. + pub fn is_timeout_event(&self, event: Event) -> bool { + matches!((event, self.pending_timer()), (Event::Timer(t), Some(pt)) if pt == t) + } + + /// Reset to the empty state. Takes `EventCtx` to request a paint pass (to + /// either hide or show any pending marker our caller might want to draw + /// later). + pub fn clear_pending_state(&mut self, ctx: &mut EventCtx) { + if self.pending.is_some() { + self.pending = None; + ctx.request_paint(); + } + } + + /// Register a click to a key. `MultiTapKeyboard` itself does not have any + /// concept of the key set, so both the key index and the key content is + /// taken here. Returns a text editing operation the caller should apply on + /// the output buffer. Takes `EventCtx` to request a timeout for cancelling + /// the pending state. Caller is required to handle the timer event and + /// call `Self::clear_pending_state` when the timer hits. + pub fn click_key(&mut self, ctx: &mut EventCtx, key: usize, key_text: &str) -> TextEdit { + // To simplify things, we assume the key text is ASCII-only. + let ascii_text = key_text.as_bytes(); + + let (is_pending, press) = match &self.pending { + Some(pending) if pending.key == key => { + // This key is pending. Cycle the last inserted character through the + // key content. + (true, pending.press.wrapping_add(1)) + } + _ => { + // This key is not pending. Append the first character in the key. + (false, 0) + } + }; + + // If the key has more then one character, we need to set it as pending, so we + // can cycle through on the repeated clicks. We also request a timer so we can + // reset the pending state after a deadline. + // + // Note: It might seem that we should make sure to `request_paint` in case we + // progress into a pending state (to display the pending marker), but such + // transition only happens as a result of an append op, so the painting should + // be requested by handling the `TextEdit`. + self.pending = if ascii_text.len() > 1 { + Some(Pending { + key, + press, + timer: ctx.request_timer(self.timeout), + }) + } else { + None + }; + + let ch = ascii_text[press % ascii_text.len()] as char; + if is_pending { + TextEdit::ReplaceLast(ch) + } else { + TextEdit::Append(ch) + } + } +} + +/// Reified editing operations of `TextBox`. +/// +/// Note: This does not contain all supported editing operations, only the ones +/// we currently use. +pub enum TextEdit { + ReplaceLast(char), + Append(char), +} + +/// Wraps a character buffer of maximum length `L` and provides text editing +/// operations over it. Text ops usually take a `EventCtx` to request a paint +/// pass in case of any state modification. +pub struct TextBox { + text: String, +} + +impl TextBox { + /// Create a new `TextBox` with content `text`. + pub fn new(text: String) -> Self { + Self { text } + } + + /// Create an empty `TextBox`. + pub fn empty() -> Self { + Self::new(String::new()) + } + + pub fn content(&self) -> &str { + &self.text + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + /// Delete the last character of content, if any. + pub fn delete_last(&mut self, ctx: &mut EventCtx) { + let changed = self.text.pop().is_some(); + if changed { + ctx.request_paint(); + } + } + + /// Replaces the last character of the content with `ch`. If the content is + /// empty, `ch` is appended. + pub fn replace_last(&mut self, ctx: &mut EventCtx, ch: char) { + let previous = self.text.pop(); + self.text + .push(ch) + .assert_if_debugging_ui("TextBox has zero capacity"); + let changed = previous != Some(ch); + if changed { + ctx.request_paint(); + } + } + + /// Append `ch` at the end of the content. + pub fn append(&mut self, ctx: &mut EventCtx, ch: char) { + self.text.push(ch).assert_if_debugging_ui("TextBox is full"); + ctx.request_paint(); + } + + /// Replace the textbox content with `text`. + pub fn replace(&mut self, ctx: &mut EventCtx, text: &str) { + if self.text != text { + self.text.clear(); + self.text + .push_str(text) + .assert_if_debugging_ui("TextBox is full"); + ctx.request_paint(); + } + } + + /// Clear the textbox content. + pub fn clear(&mut self, ctx: &mut EventCtx) { + self.replace(ctx, ""); + } + + /// Apply a editing operation to the text buffer. + pub fn apply(&mut self, ctx: &mut EventCtx, edit: TextEdit) { + match edit { + TextEdit::ReplaceLast(char) => self.replace_last(ctx, char), + TextEdit::Append(char) => self.append(ctx, char), + } + } +} + +/// Analogue to `[T]::enumerate().map(...)`. +pub fn array_map_enumerate( + array: [T; L], + mut func: impl FnMut(usize, T) -> U, +) -> [U; L] { + let mut i = 0; + array.map(|t| { + let u = func(i, t); + i += 1; + u + }) +} diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs new file mode 100644 index 000000000..73fd2e0f3 --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs @@ -0,0 +1,160 @@ +use crate::ui::{ + component::{Child, Component, Event, EventCtx, Label, Maybe}, + geometry::{Grid, Rect}, + model_tt::{ + component::{keyboard::common::array_map_enumerate, Button, ButtonMsg}, + theme, + }, +}; + +pub const MNEMONIC_KEY_COUNT: usize = 9; + +pub enum MnemonicKeyboardMsg { + Confirmed, +} + +pub struct MnemonicKeyboard { + /// Initial prompt, displayed on empty input. + prompt: Child>>, + /// Backspace button. + back: Child>>, + /// Input area, acting as the auto-complete and confirm button. + input: Child>, + /// Key buttons. + keys: [Child>; MNEMONIC_KEY_COUNT], +} + +impl MnemonicKeyboard +where + T: MnemonicInput, +{ + pub fn new(area: Rect, prompt: &'static [u8]) -> Self { + let grid = Grid::new(area, 3, 4); + let back_area = grid.row_col(0, 0); + let input_area = grid.row_col(0, 1).union(grid.row_col(0, 3)); + let prompt_area = grid.row_col(0, 0).union(grid.row_col(0, 3)); + let prompt_origin = prompt_area.top_left(); + + let input = T::new(input_area); + let keys = T::keys(); + + Self { + prompt: Child::new(Maybe::visible( + prompt_area, + theme::BG, + Label::left_aligned(prompt_origin, prompt, theme::label_default()), + )), + back: Child::new(Maybe::hidden( + back_area, + theme::BG, + Button::with_icon(back_area, theme::ICON_BACK).styled(theme::button_clear()), + )), + input: Child::new(Maybe::hidden(input_area, theme::BG, input)), + keys: Self::key_buttons(keys, &grid, grid.cols), // Start in the second row. + } + } + + fn key_buttons( + keys: [&'static str; MNEMONIC_KEY_COUNT], + grid: &Grid, + offset: usize, + ) -> [Child>; MNEMONIC_KEY_COUNT] { + array_map_enumerate(keys, |index, text| { + Child::new(Button::with_text( + grid.cell(offset + index), + text.as_bytes(), + )) + }) + } + + fn on_input_change(&mut self, ctx: &mut EventCtx) { + self.toggle_key_buttons(ctx); + self.toggle_prompt_or_input(ctx); + } + + /// Either enable or disable the key buttons, depending on the dictionary + /// completion mask and the pending key. + fn toggle_key_buttons(&mut self, ctx: &mut EventCtx) { + for (key, btn) in self.keys.iter_mut().enumerate() { + let enabled = self + .input + .inner() + .inner() + .can_key_press_lead_to_a_valid_word(key); + btn.mutate(ctx, |ctx, b| b.enabled(ctx, enabled)); + } + } + + /// After edit operations, we need to either show or hide the prompt, the + /// input, and the back button. + fn toggle_prompt_or_input(&mut self, ctx: &mut EventCtx) { + let prompt_visible = self.input.inner().inner().is_empty(); + self.prompt + .mutate(ctx, |ctx, p| p.show_if(ctx, prompt_visible)); + self.input + .mutate(ctx, |ctx, i| i.show_if(ctx, !prompt_visible)); + self.back + .mutate(ctx, |ctx, b| b.show_if(ctx, !prompt_visible)); + } +} + +impl Component for MnemonicKeyboard +where + T: MnemonicInput, +{ + type Msg = MnemonicKeyboardMsg; + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + match self.input.event(ctx, event) { + Some(MnemonicInputMsg::Confirmed) => { + // Confirmed, bubble up. + return Some(MnemonicKeyboardMsg::Confirmed); + } + Some(_) => { + // Either a timeout or a completion. + self.on_input_change(ctx); + return None; + } + _ => {} + } + if let Some(ButtonMsg::Clicked) = self.back.event(ctx, event) { + self.input + .mutate(ctx, |ctx, i| i.inner_mut().on_backspace_click(ctx)); + self.on_input_change(ctx); + return None; + } + for (key, btn) in self.keys.iter_mut().enumerate() { + if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { + self.input + .mutate(ctx, |ctx, i| i.inner_mut().on_key_click(ctx, key)); + self.on_input_change(ctx); + return None; + } + } + None + } + + fn paint(&mut self) { + self.prompt.paint(); + self.input.paint(); + self.back.paint(); + for btn in &mut self.keys { + btn.paint(); + } + } +} + +pub trait MnemonicInput: Component { + fn new(area: Rect) -> Self; + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT]; + fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool; + fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize); + fn on_backspace_click(&mut self, ctx: &mut EventCtx); + fn is_empty(&self) -> bool; +} + +pub enum MnemonicInputMsg { + Confirmed, + Completed, + TimedOut, +} diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/mod.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/mod.rs new file mode 100644 index 000000000..959db583a --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/mod.rs @@ -0,0 +1,7 @@ +pub mod bip39; +pub mod mnemonic; +pub mod passphrase; +pub mod pin; +pub mod slip39; + +mod common; diff --git a/core/embed/rust/src/ui/model_tt/component/passphrase.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs similarity index 98% rename from core/embed/rust/src/ui/model_tt/component/passphrase.rs rename to core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs index e5f984ced..08b5241f1 100644 --- a/core/embed/rust/src/ui/model_tt/component/passphrase.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/passphrase.rs @@ -6,15 +6,14 @@ use crate::{ component::{base::ComponentExt, Child, Component, Event, EventCtx, Never, TimerToken}, display, geometry::{Grid, Rect}, + model_tt::component::{ + button::{Button, ButtonContent, ButtonMsg::Clicked}, + swipe::{Swipe, SwipeDirection}, + theme, + }, }, }; -use super::{ - button::{Button, ButtonContent, ButtonMsg::Clicked}, - swipe::{Swipe, SwipeDirection}, - theme, -}; - pub enum PassphraseKeyboardMsg { Confirmed, Cancelled, diff --git a/core/embed/rust/src/ui/model_tt/component/pin.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs similarity index 94% rename from core/embed/rust/src/ui/model_tt/component/pin.rs rename to core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs index a7de9738f..dda2eb194 100644 --- a/core/embed/rust/src/ui/model_tt/component/pin.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs @@ -10,15 +10,14 @@ use crate::{ }, display, geometry::{Grid, Offset, Point, Rect}, + model_tt::component::{ + button::{Button, ButtonContent, ButtonMsg::Clicked}, + theme, + }, }, }; -use super::{ - button::{Button, ButtonContent, ButtonMsg::Clicked}, - theme, -}; - -pub enum PinDialogMsg { +pub enum PinKeyboardMsg { Confirmed, Cancelled, } @@ -26,7 +25,7 @@ pub enum PinDialogMsg { const MAX_LENGTH: usize = 9; const DIGIT_COUNT: usize = 10; // 0..10 -pub struct PinDialog { +pub struct PinKeyboard { digits: Vec, major_prompt: Label<&'static [u8]>, minor_prompt: Label<&'static [u8]>, @@ -37,7 +36,7 @@ pub struct PinDialog { digit_btns: [Child>; DIGIT_COUNT], } -impl PinDialog { +impl PinKeyboard { pub fn new(area: Rect, major_prompt: &'static [u8], minor_prompt: &'static [u8]) -> Self { let digits = Vec::new(); @@ -141,15 +140,15 @@ impl PinDialog { } } -impl Component for PinDialog { - type Msg = PinDialogMsg; +impl Component for PinKeyboard { + type Msg = PinKeyboardMsg; fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { if let Some(Clicked) = self.confirm_btn.event(ctx, event) { - return Some(PinDialogMsg::Confirmed); + return Some(PinKeyboardMsg::Confirmed); } if let Some(Clicked) = self.cancel_btn.event(ctx, event) { - return Some(PinDialogMsg::Cancelled); + return Some(PinKeyboardMsg::Cancelled); } if let Some(Clicked) = self.reset_btn.event(ctx, event) { self.digits.clear(); 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 new file mode 100644 index 000000000..ce0898ea7 --- /dev/null +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs @@ -0,0 +1,242 @@ +use core::iter; + +use heapless::String; + +use crate::{ + trezorhal::slip39, + ui::{ + component::{Component, Event, EventCtx}, + display, + geometry::{Offset, Rect}, + model_tt::{ + component::{ + keyboard::{ + common::{MultiTapKeyboard, TextBox, TextEdit}, + mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT}, + }, + Button, ButtonContent, ButtonMsg, + }, + theme, + }, + }, + util::ResultExt, +}; + +const MAX_LENGTH: usize = 8; + +pub struct Slip39Input { + button: Button<&'static [u8]>, + textbox: TextBox, + multi_tap: MultiTapKeyboard, + final_word: Option<&'static str>, + input_mask: Slip39Mask, +} + +impl MnemonicInput for Slip39Input { + fn new(area: Rect) -> Self { + Self { + button: Button::empty(area), + textbox: TextBox::empty(), + multi_tap: MultiTapKeyboard::new(), + final_word: None, + input_mask: Slip39Mask::full(), + } + } + + /// Return the key set. Keys are further specified as indices into this + /// array. + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] { + ["ab", "cd", "ef", "ghij", "klm", "nopq", "rs", "tuv", "xyz"] + } + + /// 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 { + if self.input_mask.is_final() { + false + } else { + // Currently pending key is always enabled. + // Keys that mach the completion mask are enabled as well. + self.multi_tap.pending_key() == Some(key) || self.input_mask.contains_key(key) + } + } + + /// 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]); + if let TextEdit::Append(_) = edit { + // This key press wasn't just a pending key rotation, so let's push the key + // digit to the buffer. + self.textbox.append(ctx, Self::key_digit(key)); + } else { + // Ignore the pending char rotation. We use the pending key to paint + // the last character, but the mnemonic word computation depends + // only on the pressed key, not on the specific character inside it. + } + 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 is_empty(&self) -> bool { + self.textbox.is_empty() + } +} + +impl Component for Slip39Input { + type Msg = MnemonicInputMsg; + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.multi_tap.is_timeout_event(event) { + // Timeout occurred. Reset the pending key. + self.multi_tap.clear_pending_state(ctx); + return Some(MnemonicInputMsg::TimedOut); + } + if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) { + // Input button was clicked. If the whole word is totally identified, let's + // confirm it, otherwise don't do anything. + if self.input_mask.is_final() { + return Some(MnemonicInputMsg::Confirmed); + } + } + None + } + + fn paint(&mut self) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.paint_background(&style); + + // 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); + + // To simplify things, we always copy the printed string here, even if it + // wouldn't be strictly necessary. + let mut text: String = String::new(); + + if let Some(word) = self.final_word { + // We're done with input, paint the full word. + text.push_str(word) + .assert_if_debugging_ui("Text buffer is too small"); + } else { + // Paint an asterisk for each letter of input. + for ch in iter::repeat('*').take(self.textbox.content().len()) { + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + // If we're in the pending state, paint the pending character at the end. + if let (Some(key), Some(press)) = + (self.multi_tap.pending_key(), self.multi_tap.pending_press()) + { + let ascii_text = Self::keys()[key].as_bytes(); + let ch = ascii_text[press % ascii_text.len()] as char; + text.pop(); + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + } + display::text( + text_baseline, + text.as_bytes(), + style.font, + style.text_color, + style.button_color, + ); + let width = style.font.text_width(text.as_bytes()); + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() && self.final_word.is_none() { + // Measure the width of the last character of input. + if let Some(last) = text.as_bytes().last().copied() { + let last_width = style.font.text_width(&[last]); + // Draw the marker 2px under the start of the baseline of the last character. + let marker_origin = text_baseline + Offset::new(width - last_width, 2); + // Draw the marker 1px longer than the last character, and 3px thick. + let marker_rect = + Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3)); + display::rect_fill(marker_rect, style.text_color); + } + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + display::icon(icon_center, icon, style.text_color, style.button_color); + } + } +} + +impl Slip39Input { + /// Convert a key index into the key digit. This is what we push into the + /// input buffer. + /// + /// # Examples + /// + /// ``` + /// Self::key_digit(0) == '1'; + /// Self::key_digit(1) == '2'; + /// ``` + fn key_digit(key: usize) -> char { + let index = key + 1; + char::from_digit(index as u32, 10).unwrap() + } + + fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) { + let sequence = self.input_sequence(); + self.final_word = sequence.and_then(slip39::button_sequence_to_word); + self.input_mask = sequence + .and_then(slip39::word_completion_mask) + .map(Slip39Mask) + .unwrap_or_else(Slip39Mask::full); + + // Change the style of the button depending on the input. + if self.final_word.is_some() { + // Confirm button. + self.button.enable(ctx); + self.button.set_stylesheet(ctx, theme::button_confirm()); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_CONFIRM)); + } else { + // Disabled button. + self.button.disable(ctx); + self.button.set_stylesheet(ctx, theme::button_default()); + self.button.set_content(ctx, ButtonContent::Text(b"")); + } + } + + fn input_sequence(&self) -> Option { + self.textbox.content().parse().ok() + } +} + +struct Slip39Mask(u16); + +impl Slip39Mask { + /// Return a mask with all keys allowed. + fn full() -> Self { + Self(0x1FF) // All buttons are allowed. 9-bit bitmap all set to 1. + } + + /// Returns `true` if `key` can lead to a valid SLIP39 word with this mask. + fn contains_key(&self, key: usize) -> bool { + self.0 & (1 << key) != 0 + } + + /// Returns `true` if mask has exactly one bit set to 1, or is equal to 0. + fn is_final(&self) -> bool { + self.0.count_ones() <= 1 + } +} diff --git a/core/embed/rust/src/ui/model_tt/component/mod.rs b/core/embed/rust/src/ui/model_tt/component/mod.rs index a94437b9a..47ae587f0 100644 --- a/core/embed/rust/src/ui/model_tt/component/mod.rs +++ b/core/embed/rust/src/ui/model_tt/component/mod.rs @@ -2,10 +2,9 @@ mod button; mod confirm; mod dialog; mod frame; +mod keyboard; mod loader; mod page; -mod passphrase; -mod pin; mod swipe; pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet}; diff --git a/core/embed/rust/src/ui/model_tt/res/click.toif b/core/embed/rust/src/ui/model_tt/res/click.toif new file mode 100644 index 0000000000000000000000000000000000000000..8c906acf81b4d199894b707ba375f0ef518fb8cd GIT binary patch literal 139 zcmV;60CfLUPf2GG02Kg#0000j%OMWJKoo@Ge+$X>B?uf6Zh!#|N8l!KgK{Dywipy4 z905bnkZqx+eZ1{5=1XQWxKbZjis#~X0$+n`VZO0$y5|r#|Kf`~lLadB4r)F~hYMVz twy)M^Hba86%lIjC)!i5d=KXDjsA_Xes`@H9(n;@7FTHFvDSs&<%@1*QI#mDw literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_tt/res/left.toif b/core/embed/rust/src/ui/model_tt/res/left.toif new file mode 100644 index 0000000000000000000000000000000000000000..9b230ed6a93873b5089e0c32335398ec14e1d018 GIT binary patch literal 73 zcmWIX_e>XH5MZ!nU|>j2NO%x&fPwAjepUyO+yDP3SpEO+%<-My<=g*cH%CXu1#kBM d2ZFW#`Pu#_nE1#n<6Fm{#&;)XhOIy~BLGRmAIks$ literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_tt/theme.rs b/core/embed/rust/src/ui/model_tt/theme.rs index 6e1382c03..2da234fe3 100644 --- a/core/embed/rust/src/ui/model_tt/theme.rs +++ b/core/embed/rust/src/ui/model_tt/theme.rs @@ -41,6 +41,8 @@ pub const ICON_SIZE: i32 = 16; pub const ICON_CANCEL: &[u8] = include_res!("model_tt/res/cancel.toif"); pub const ICON_CONFIRM: &[u8] = include_res!("model_tt/res/confirm.toif"); pub const ICON_SPACE: &[u8] = include_res!("model_tt/res/space.toif"); +pub const ICON_BACK: &[u8] = include_res!("model_tt/res/left.toif"); +pub const ICON_CLICK: &[u8] = include_res!("model_tt/res/click.toif"); pub fn label_default() -> LabelStyle { LabelStyle { diff --git a/core/embed/rust/src/util.rs b/core/embed/rust/src/util.rs index caa2ae2d3..885b72220 100644 --- a/core/embed/rust/src/util.rs +++ b/core/embed/rust/src/util.rs @@ -81,3 +81,16 @@ pub unsafe fn try_with_args_and_kwargs_inline( }; unsafe { try_or_raise(block) } } + +pub trait ResultExt { + fn assert_if_debugging_ui(self, message: &str); +} + +impl ResultExt for Result { + fn assert_if_debugging_ui(self, #[allow(unused)] message: &str) { + #[cfg(feature = "ui_debug")] + if self.is_err() { + panic!("{}", message); + } + } +}