parent
548651608c
commit
3c337723b2
@ -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 _) }
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
|
pub mod bip39;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
#[cfg(feature = "ui")]
|
#[cfg(feature = "ui")]
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod random;
|
pub mod random;
|
||||||
|
pub mod slip39;
|
||||||
|
@ -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<u16> {
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
use crate::ui::{
|
||||||
|
component::{Component, ComponentExt, Event, EventCtx, Pad},
|
||||||
|
display::Color,
|
||||||
|
geometry::Rect,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Maybe<T> {
|
||||||
|
inner: T,
|
||||||
|
pad: Pad,
|
||||||
|
visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Maybe<T> {
|
||||||
|
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<T> Maybe<T>
|
||||||
|
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<T> Component for Maybe<T>
|
||||||
|
where
|
||||||
|
T: Component,
|
||||||
|
{
|
||||||
|
type Msg = T::Msg;
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||||
|
if self.visible {
|
||||||
|
self.inner.event(ctx, event)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self) {
|
||||||
|
self.pad.paint();
|
||||||
|
if self.visible {
|
||||||
|
self.inner.paint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<MAX_LENGTH>,
|
||||||
|
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<Self::Msg> {
|
||||||
|
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<MnemonicInputMsg> {
|
||||||
|
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<MnemonicInputMsg> {
|
||||||
|
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""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<Pending>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
self.pending.as_ref().map(|p| p.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the index of the pending key press.
|
||||||
|
pub fn pending_press(&self) -> Option<usize> {
|
||||||
|
self.pending.as_ref().map(|p| p.press)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the token for the currently pending timer.
|
||||||
|
pub fn pending_timer(&self) -> Option<TimerToken> {
|
||||||
|
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<const L: usize> {
|
||||||
|
text: String<L>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const L: usize> TextBox<L> {
|
||||||
|
/// Create a new `TextBox` with content `text`.
|
||||||
|
pub fn new(text: String<L>) -> 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<T, U, const L: usize>(
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
@ -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<T> {
|
||||||
|
/// Initial prompt, displayed on empty input.
|
||||||
|
prompt: Child<Maybe<Label<&'static [u8]>>>,
|
||||||
|
/// Backspace button.
|
||||||
|
back: Child<Maybe<Button<&'static [u8]>>>,
|
||||||
|
/// Input area, acting as the auto-complete and confirm button.
|
||||||
|
input: Child<Maybe<T>>,
|
||||||
|
/// Key buttons.
|
||||||
|
keys: [Child<Button<&'static [u8]>>; MNEMONIC_KEY_COUNT],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> MnemonicKeyboard<T>
|
||||||
|
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<Button<&'static [u8]>>; 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<T> Component for MnemonicKeyboard<T>
|
||||||
|
where
|
||||||
|
T: MnemonicInput,
|
||||||
|
{
|
||||||
|
type Msg = MnemonicKeyboardMsg;
|
||||||
|
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||||
|
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<Msg = MnemonicInputMsg> {
|
||||||
|
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,
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
pub mod bip39;
|
||||||
|
pub mod mnemonic;
|
||||||
|
pub mod passphrase;
|
||||||
|
pub mod pin;
|
||||||
|
pub mod slip39;
|
||||||
|
|
||||||
|
mod common;
|
@ -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<MAX_LENGTH>,
|
||||||
|
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<Self::Msg> {
|
||||||
|
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<MAX_LENGTH> = 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<u16> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue