You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
trezor-firmware/core/embed/rust/src/ui/model_tr/component/input_methods/wordlist.rs

338 lines
12 KiB

use crate::{
translations::TR,
trezorhal::{random, wordlist::Wordlist},
ui::{
component::{text::common::TextBox, Child, Component, ComponentExt, Event, EventCtx},
geometry::Rect,
shape::Renderer,
util::char_to_string,
},
};
use super::super::{theme, ButtonLayout, ChangingTextLine, ChoiceFactory, ChoiceItem, ChoicePage};
use heapless::{String, Vec};
enum WordlistAction {
Letter(char),
Word(&'static str),
Delete,
Previous,
}
const MAX_WORD_LENGTH: usize = 10;
/// Offer words when there will be fewer of them than this
const OFFER_WORDS_THRESHOLD: usize = 10;
/// Where will be the DELETE option - at the first position
const DELETE_INDEX: usize = 0;
/// Which index will be used at the beginning.
/// (Accounts for DELETE to be at index 0)
const INITIAL_PAGE_COUNTER: usize = DELETE_INDEX + 1;
const PROMPT: &str = "_";
/// Choosing random choice index, disregarding DELETE option
fn get_random_position(num_choices: usize) -> usize {
random::uniform_between(INITIAL_PAGE_COUNTER as u32, (num_choices - 1) as u32) as usize
}
/// Type of the wordlist, deciding the list of words to be used
#[derive(Clone, Copy)]
pub enum WordlistType {
Bip39,
Slip39,
}
struct ChoiceFactoryWordlist {
wordlist: Wordlist,
offer_words: bool,
/// We want to randomize the order in which we show the words
word_random_order: Vec<usize, OFFER_WORDS_THRESHOLD>,
/// Whether the input is empty - and we should show PREVIOUS instead of
/// DELETE
empty_input: bool,
}
impl ChoiceFactoryWordlist {
pub fn new(wordlist_type: WordlistType, prefix: &str) -> Self {
let wordlist = match wordlist_type {
WordlistType::Bip39 => Wordlist::bip39(),
WordlistType::Slip39 => Wordlist::slip39(),
}
.filter_prefix(prefix);
let offer_words = wordlist.len() < OFFER_WORDS_THRESHOLD;
let word_random_order: Vec<usize, OFFER_WORDS_THRESHOLD> = if offer_words {
// Filling slice with numbers 0..wordlist.len() and shuffling them
let slice = &mut [0; OFFER_WORDS_THRESHOLD][..wordlist.len()];
for (i, item) in slice.iter_mut().enumerate() {
*item = i;
}
random::shuffle(slice);
slice.iter().copied().collect()
} else {
Vec::new()
};
Self {
wordlist,
offer_words,
word_random_order,
empty_input: prefix.is_empty(),
}
}
}
impl ChoiceFactory for ChoiceFactoryWordlist {
type Action = WordlistAction;
type Item = ChoiceItem;
fn count(&self) -> usize {
// Accounting for the DELETE option (+1)
1 + if self.offer_words {
self.wordlist.len()
} else {
self.wordlist.get_available_letters().count()
}
}
fn get(&self, choice_index: usize) -> (Self::Item, Self::Action) {
// Putting DELETE as the first option in both cases
// (is a requirement for WORDS, doing it for LETTERS as well to unite it)
if choice_index == DELETE_INDEX {
if self.empty_input {
return (
TR::inputs__previous.map_translated(|t| {
ChoiceItem::new(
t,
ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
)
.with_icon(theme::ICON_DELETE)
.with_middle_action_without_release()
}),
WordlistAction::Previous,
);
} else {
return (
TR::inputs__delete.map_translated(|t| {
ChoiceItem::new(
t,
ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
)
.with_icon(theme::ICON_DELETE)
.with_middle_action_without_release()
}),
WordlistAction::Delete,
);
}
}
if self.offer_words {
// Taking a random (but always the same) word on this position
let index = self.word_random_order[choice_index - 1];
let word = self.wordlist.get(index).unwrap_or_default();
(
ChoiceItem::new(
word,
ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
),
WordlistAction::Word(word),
)
} else {
let letter = self
.wordlist
.get_available_letters()
.nth(choice_index - 1)
.unwrap_or_default();
(
ChoiceItem::new(
char_to_string(letter),
ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
),
WordlistAction::Letter(letter),
)
}
}
}
/// Component for entering a mnemonic from a wordlist - BIP39 or SLIP39.
pub struct WordlistEntry {
choice_page: ChoicePage<ChoiceFactoryWordlist, WordlistAction>,
chosen_letters: Child<ChangingTextLine<String<{ MAX_WORD_LENGTH + 1 }>>>,
textbox: TextBox<MAX_WORD_LENGTH>,
offer_words: bool,
wordlist_type: WordlistType,
/// Whether going back is allowed (is not on the very first word).
can_go_back: bool,
}
impl WordlistEntry {
pub fn new(wordlist_type: WordlistType, can_go_back: bool) -> Self {
let choices = ChoiceFactoryWordlist::new(wordlist_type, "");
let choices_count = <ChoiceFactoryWordlist as ChoiceFactory>::count(&choices);
Self {
// Starting at random letter position
choice_page: ChoicePage::new(choices)
.with_incomplete(true)
.with_carousel(true)
.with_initial_page_counter(get_random_position(choices_count)),
chosen_letters: Child::new(ChangingTextLine::center_mono(String::from(PROMPT))),
textbox: TextBox::empty(),
offer_words: false,
wordlist_type,
can_go_back,
}
}
pub fn prefilled_word(word: &str, wordlist_type: WordlistType, can_go_back: bool) -> Self {
// Word may be empty string, fallback to normal input
if word.is_empty() {
return Self::new(wordlist_type, can_go_back);
}
let choices = ChoiceFactoryWordlist::new(wordlist_type, word);
Self {
// Showing the chosen word at index 1
choice_page: ChoicePage::new(choices)
.with_incomplete(true)
.with_initial_page_counter(1),
chosen_letters: Child::new(ChangingTextLine::center_mono(String::from(word))),
textbox: TextBox::new(String::from(word)),
offer_words: false,
wordlist_type,
can_go_back,
}
}
/// Gets up-to-date choices for letters or words.
fn get_current_choices(&mut self) -> ChoiceFactoryWordlist {
// Narrowing the word list
ChoiceFactoryWordlist::new(self.wordlist_type, self.textbox.content())
}
fn get_last_textbox_letter(&self) -> Option<char> {
self.textbox.content().chars().last()
}
fn get_new_page_counter(&self, new_choices: &ChoiceFactoryWordlist) -> usize {
// Starting at the random position in case of letters and at the beginning in
// case of words.
if self.offer_words {
INITIAL_PAGE_COUNTER
} else {
let choices_count = <ChoiceFactoryWordlist as ChoiceFactory>::count(new_choices);
// There should be always DELETE and at least one letter
assert!(choices_count > 1);
if choices_count == 2 {
// In case there is only DELETE and one letter, starting on that letter
// (regardless of the last letter in the textbox)
return INITIAL_PAGE_COUNTER;
}
// We do not want to end up at the same letter as the last one in the textbox
loop {
let random_position = get_random_position(choices_count);
let current_action =
<ChoiceFactoryWordlist as ChoiceFactory>::get(new_choices, random_position).1;
if let WordlistAction::Letter(current_letter) = current_action {
if let Some(last_letter) = self.get_last_textbox_letter() {
if current_letter == last_letter {
// Randomly trying again when the last and current letter match
continue;
}
}
}
break random_position;
}
}
}
/// Updates the whole page.
fn update(&mut self, ctx: &mut EventCtx) {
self.update_chosen_letters(ctx);
let new_choices = self.get_current_choices();
self.offer_words = new_choices.offer_words;
let new_page_counter = self.get_new_page_counter(&new_choices);
// Not using carousel in case of words, as that looks weird in case
// there is only one word to choose from.
self.choice_page
.reset(ctx, new_choices, Some(new_page_counter), !self.offer_words);
ctx.request_paint();
}
/// Reflects currently chosen letters in the textbox.
fn update_chosen_letters(&mut self, ctx: &mut EventCtx) {
let text = build_string!({ MAX_WORD_LENGTH + 1 }, self.textbox.content(), PROMPT);
self.chosen_letters.mutate(ctx, |ctx, chosen_letters| {
chosen_letters.update_text(text);
chosen_letters.request_complete_repaint(ctx);
});
}
}
impl Component for WordlistEntry {
type Msg = &'static str;
fn place(&mut self, bounds: Rect) -> Rect {
let letters_area_height = self.chosen_letters.inner().needed_height();
let (letters_area, choice_area) = bounds.split_top(letters_area_height);
self.chosen_letters.place(letters_area);
self.choice_page.place(choice_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some((action, long_press)) = self.choice_page.event(ctx, event) {
match action {
WordlistAction::Previous => {
if self.can_go_back {
return Some("");
}
}
WordlistAction::Delete => {
// Deleting all when long-pressed
if long_press {
self.textbox.clear(ctx);
} else {
self.textbox.delete_last(ctx);
}
self.update(ctx);
}
WordlistAction::Letter(letter) => {
self.textbox.append(ctx, letter);
self.update(ctx);
}
WordlistAction::Word(word) => {
return Some(word);
}
}
}
None
}
fn paint(&mut self) {
self.chosen_letters.paint();
self.choice_page.paint();
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.chosen_letters.render(target);
self.choice_page.render(target);
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for WordlistEntry {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("MnemonicKeyboard"); // unified with TT
t.string("textbox", self.textbox.content().into());
if self.offer_words {
t.bool("word_choices", true);
} else {
t.bool("letter_choices", true);
}
t.child("choice_page", &self.choice_page);
}
}