Merge commit 'refs/pull/2073/head' of https://github.com/BitBoxSwiss/bitbox02-firmware
What changed, and why it matters
This commit adds a new recovery-word entry screen for the upcoming BitBox03 hardware wallet. It is a large feature patch: it introduces a dedicated BIP39 wordlist keyboard, a new recovery-words review screen, and changes how the device handles going back versus cancelling during seed restoration. There is no direct evidence in the commit that this fixes a security vulnerability; it reads as a user-experience and hardware-support change. The code does improve clarity around cancel/back handling and adds confirmation prompts before aborting a restore, which is a sensible defensive design, but it is not presented by the vendor as a security fix.
No immediate security action is required. Treat this as a normal feature merge. If auditing the BitBox03 bring-up, reviewers may want to verify that the new wordlist keyboard and recovery-words screen do not introduce touch-input edge cases, memory leaks, or race conditions in the async UI framework, and that the new `WordlistEntryAbort` paths are exercised in device tests.
Security signals we found
New UI workflow distinguishes 'back' from 'cancel' during seed restoration, reducing accidental aborts.
Cancel actions still require an explicit confirmation prompt before the restore is abandoned.
Wordlist keyboard disables keys that cannot lead to a valid BIP39 word, preventing invalid-word compositions at the widget level.
No vendor disclosure of security relevance, CVE, or bug bounty attribution in commit or supplied references.
Evidence from the diff
The merge commit imports PR #2073, which implements BitBox03 UI support for BIP39 recovery-word entry and review. Key changes: (1) a new enter_wordlist_word HAL method that distinguishes Back, Cancel, and Unspecified aborts; (2) a letters-only wordlist keyboard with per-key enabling based on the candidate wordlist, autocomplete on unique prefix, and disabled invalid keys; (3) a new recovery-words review screen showing all words in two columns; (4) updated mnemonic workflow logic so BitBox03’s dedicated back/cancel controls route correctly, while BitBox02’s single abort control keeps the existing ‘Choose’ menu behavior. The patch also adds new Inter Medium fonts and updates the simulator lock files. No memory-safety bugs, cryptographic flaws, or bypasses are visible in the diff, and the commit message does not frame the change as security-relevant.
Changed components
BitBox02/BitBox03 firmware Rust UI stacksrc/rust/bitbox-hal/src/ui.rssrc/rust/bitbox02-rust/src/workflow/mnemonic.rssrc/rust/bitbox03/src/ui.rssrc/rust/bitbox03/src/ui/enter_string.rssrc/rust/bitbox03/src/ui/keyboard.rssrc/rust/bitbox03/src/ui/recovery_words.rssrc/rust/bitbox03/src/ui/menu.rssrc/rust/bitbox03/src/ui/choice.rssrc/rust/bitbox03/src/ui/confirm.rsInspect captured patch +7010 / −246
### src/rust/Cargo.lock
@@ -452,6 +452,7 @@ dependencies = [
name = "bitbox03"
version = "0.1.0"
dependencies = [
+ "bip39",
"bitbox-hal",
"bitbox-lvgl",
"png-decoder",
### src/rust/bitbox-hal/src/ui.rs
@@ -10,6 +10,17 @@ pub const MAX_CONFIRM_BODY_SIZE: usize = 640;
pub struct UserAbort;
+/// How the user left a recovery-word entry screen without entering a word.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum WordlistEntryAbort {
+ /// A dedicated back control: return to the previous word.
+ Back,
+ /// A dedicated cancel control: abort the whole flow (the workflow asks for confirmation).
+ Cancel,
+ /// The UI offers a single abort control, so the workflow must ask the user what they meant.
+ Unspecified,
+}
+
#[derive(Copy, Clone, Default)]
pub enum Font {
#[default]
@@ -143,6 +154,23 @@ pub trait Ui {
preset: &str,
) -> Result<zeroize::Zeroizing<String>, UserAbort>;
+ /// Enter one recovery word from `params.wordlist` (which must be set). Like
+ /// [`Ui::enter_string`], but the error reports how the user left the screen, so a UI with
+ /// separate back and cancel controls (BitBox03) lets the mnemonic workflow go straight back
+ /// to the previous word. The default delegates to `enter_string`, whose single abort maps
+ /// to [`WordlistEntryAbort::Unspecified`] (BitBox02): the workflow then asks what the user
+ /// meant.
+ async fn enter_wordlist_word(
+ &mut self,
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ ) -> Result<zeroize::Zeroizing<String>, WordlistEntryAbort> {
+ self.enter_string(params, can_cancel, preset)
+ .await
+ .map_err(|UserAbort| WordlistEntryAbort::Unspecified)
+ }
+
async fn insert_sdcard(&mut self) -> Result<(), UserAbort>;
/// Returns the index of the word chosen by the user.
### src/rust/bitbox-lvgl-sys/build.rs
@@ -265,6 +265,8 @@ fn main() -> Result<(), &'static str> {
fonts.file(manifest_dir.join("../../ui/fonts/inter_regular_24.c"));
fonts.file(manifest_dir.join("../../ui/fonts/inter_regular_32.c"));
fonts.file(manifest_dir.join("../../ui/fonts/inter_regular_48.c"));
+ fonts.file(manifest_dir.join("../../ui/fonts/inter_medium_20.c"));
+ fonts.file(manifest_dir.join("../../ui/fonts/inter_medium_32.c"));
fonts.file(manifest_dir.join("../../ui/fonts/inter_bold_32.c"));
fonts.file(manifest_dir.join("../../ui/fonts/inter_bold_48.c"));
for flag in &cflags {
### src/rust/bitbox-lvgl-sys/wrapper.h
@@ -5,5 +5,7 @@
extern const lv_font_t inter_regular_24;
extern const lv_font_t inter_regular_32;
extern const lv_font_t inter_regular_48;
+extern const lv_font_t inter_medium_20;
+extern const lv_font_t inter_medium_32;
extern const lv_font_t inter_bold_32;
extern const lv_font_t inter_bold_48;
### src/rust/bitbox-lvgl/src/font.rs
@@ -15,9 +15,19 @@ impl LvFont {
Self { raw }
}
- pub(crate) fn as_ptr(self) -> *const ffi::lv_font_t {
+ pub fn as_ptr(self) -> *const ffi::lv_font_t {
self.raw as *const ffi::lv_font_t
}
+
+ /// The maximum line height required by the font, in pixels.
+ pub fn line_height(self) -> i32 {
+ self.raw.line_height
+ }
+
+ /// The baseline position, measured up from the bottom of the line box.
+ pub fn base_line(self) -> i32 {
+ self.raw.base_line
+ }
}
impl PartialEq for LvFont {
@@ -35,6 +45,8 @@ pub mod fonts {
pub const INTER_REGULAR_24: LvFont = unsafe { LvFont::new(&ffi::inter_regular_24) };
pub const INTER_REGULAR_32: LvFont = unsafe { LvFont::new(&ffi::inter_regular_32) };
pub const INTER_REGULAR_48: LvFont = unsafe { LvFont::new(&ffi::inter_regular_48) };
+ pub const INTER_MEDIUM_20: LvFont = unsafe { LvFont::new(&ffi::inter_medium_20) };
+ pub const INTER_MEDIUM_32: LvFont = unsafe { LvFont::new(&ffi::inter_medium_32) };
pub const INTER_BOLD_32: LvFont = unsafe { LvFont::new(&ffi::inter_bold_32) };
pub const INTER_BOLD_48: LvFont = unsafe { LvFont::new(&ffi::inter_bold_48) };
}
### src/rust/bitbox02-rust/src/hal/testing/ui.rs
@@ -3,6 +3,7 @@
use crate::hal::Ui;
use crate::hal::ui::{
CanCancel, ConfirmParams, Empty, EnterStringParams, Progress, TrinaryChoice, UserAbort,
+ WordlistEntryAbort,
};
use alloc::boxed::Box;
@@ -62,6 +63,8 @@ pub struct ProgressScreen {
}
type EnterStringCb<'a> = Box<dyn FnMut(&EnterStringParams<'_>) -> Result<String, UserAbort> + 'a>;
+type EnterWordlistWordCb<'a> =
+ Box<dyn FnMut(&EnterStringParams<'_>) -> Result<String, WordlistEntryAbort> + 'a>;
type MenuCb<'a> = Box<dyn FnMut(&[&str], Option<&str>) -> Result<u8, UserAbort> + 'a>;
type TrinaryChoiceCb<'a> =
Box<dyn FnMut(&str, Option<&str>, Option<&str>, Option<&str>) -> TrinaryChoice + 'a>;
@@ -75,6 +78,7 @@ pub struct TestingUi<'a> {
pub confirm_scrollable: Vec<bool>,
progress_screens: Rc<RefCell<Vec<ProgressScreen>>>,
_enter_string: Option<EnterStringCb<'a>>,
+ _enter_wordlist_word: Option<EnterWordlistWordCb<'a>>,
_menu: Option<MenuCb<'a>>,
_trinary_choice: Option<TrinaryChoiceCb<'a>>,
_quiz_choices: VecDeque<u8>,
@@ -236,6 +240,22 @@ impl Ui for TestingUi<'_> {
self._enter_string.as_mut().unwrap()(params).map(zeroize::Zeroizing::new)
}
+ async fn enter_wordlist_word(
+ &mut self,
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ ) -> Result<zeroize::Zeroizing<String>, WordlistEntryAbort> {
+ match self._enter_wordlist_word.as_mut() {
+ Some(cb) => cb(params).map(zeroize::Zeroizing::new),
+ // Mirror the trait default: a single abort control reports an unspecified abort.
+ None => self
+ .enter_string(params, can_cancel, preset)
+ .await
+ .map_err(|UserAbort| WordlistEntryAbort::Unspecified),
+ }
+ }
+
async fn insert_sdcard(&mut self) -> Result<(), UserAbort> {
Ok(())
}
@@ -307,6 +327,7 @@ impl<'a> TestingUi<'a> {
progress_screens: Rc::new(RefCell::new(vec![])),
_abort_nth: None,
_enter_string: None,
+ _enter_wordlist_word: None,
_menu: None,
_trinary_choice: None,
_quiz_choices: VecDeque::new(),
@@ -334,6 +355,10 @@ impl<'a> TestingUi<'a> {
self._enter_string = Some(cb);
}
+ pub fn set_enter_wordlist_word(&mut self, cb: EnterWordlistWordCb<'a>) {
+ self._enter_wordlist_word = Some(cb);
+ }
+
pub fn remove_enter_string(&mut self) {
self._enter_string = None;
}
### src/rust/bitbox02-rust/src/workflow/mnemonic.rs
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
-use crate::hal::ui::{CanCancel, ConfirmParams, TrinaryChoice, UserAbort};
+use crate::hal::ui::{CanCancel, ConfirmParams, TrinaryChoice, UserAbort, WordlistEntryAbort};
use alloc::string::String;
use alloc::vec::Vec;
@@ -228,7 +228,7 @@ async fn get_12th_18th_word(
hal_ui: &mut impl crate::hal::Ui,
title: &str,
entered_words: &[&str],
-) -> Result<zeroize::Zeroizing<String>, UserAbort> {
+) -> Result<zeroize::Zeroizing<String>, WordlistEntryAbort> {
// With 12/18 words there are 128/32 candidates, so we limit the keyboard to allow entering only
// these.
loop {
@@ -264,10 +264,10 @@ async fn enter_word_from_wordlist(
title: &str,
wordlist: &[u16],
preset: &str,
-) -> Result<zeroize::Zeroizing<String>, UserAbort> {
+) -> Result<zeroize::Zeroizing<String>, WordlistEntryAbort> {
loop {
let word = hal_ui
- .enter_string(
+ .enter_wordlist_word(
&crate::hal::ui::EnterStringParams {
title,
wordlist: Some(wordlist),
@@ -315,7 +315,8 @@ pub async fn get(
// goes forward again.
let preset = entered_words[word_idx].as_str();
- let user_entry: Result<zeroize::Zeroizing<String>, UserAbort> = if word_idx == num_words - 1
+ let user_entry: Result<zeroize::Zeroizing<String>, WordlistEntryAbort> = if word_idx
+ == num_words - 1
{
// For the last word, we can restrict to a subset of bip39 words that fulfil the
// checksum requirement. This special case exists so that users can generate a seed
@@ -326,7 +327,8 @@ pub async fn get(
match get_24th_word(hal_ui, &title, &as_str_vec(&entered_words[..word_idx])).await {
Ok(None) => return Err(UserAbort),
Ok(Some(r)) => Ok(r),
- Err(e) => Err(e),
+ // The menu has a single abort control, so ask what the user meant.
+ Err(UserAbort) => Err(WordlistEntryAbort::Unspecified),
}
} else {
get_12th_18th_word(hal_ui, &title, &as_str_vec(&entered_words[..word_idx])).await
@@ -336,31 +338,35 @@ pub async fn get(
};
match user_entry {
- Err(UserAbort) => {
- // User clicked the cancel button. There are two choices:
+ Err(abort) => {
+ // User left the word entry without entering a word. There are two choices:
enum GetWordError {
Cancel,
EditPrevious,
}
- let cancel_choice = if word_idx == 0 {
- // In the first word, there is no previous word, so we go straight to the cancel
- // action.
- GetWordError::Cancel
- } else {
- // In all other words, we give the choice between editing the previous word and
- // cancelling.
- match hal_ui
- .menu(&["Edit previous word", "Cancel restore"], Some("Choose"))
- .await
- {
- Err(UserAbort) => {
- // Cancel cancelled.
- continue;
+ let cancel_choice = match abort {
+ // A dedicated back control (BitBox03) goes straight back to the previous
+ // word; in the first word there is no previous word, so it acts as cancel.
+ WordlistEntryAbort::Back if word_idx > 0 => GetWordError::EditPrevious,
+ WordlistEntryAbort::Back | WordlistEntryAbort::Cancel => GetWordError::Cancel,
+ // A single abort control (BitBox02): ask whether the user meant to edit the
+ // previous word or to cancel — except in the first word, where there is no
+ // previous word and we go straight to the cancel action.
+ WordlistEntryAbort::Unspecified if word_idx == 0 => GetWordError::Cancel,
+ WordlistEntryAbort::Unspecified => {
+ match hal_ui
+ .menu(&["Edit previous word", "Cancel restore"], Some("Choose"))
+ .await
+ {
+ Err(UserAbort) => {
+ // Cancel cancelled.
+ continue;
+ }
+ Ok(0) => GetWordError::EditPrevious,
+ Ok(1) => GetWordError::Cancel,
+ _ => panic!("only two choices"),
}
- Ok(0) => GetWordError::EditPrevious,
- Ok(1) => GetWordError::Cancel,
- _ => panic!("only two choices"),
}
};
@@ -369,7 +375,7 @@ pub async fn get(
GetWordError::Cancel => {
let params = ConfirmParams {
title: "Restore",
- body: "Do you really\nwant to cancel?",
+ body: "Cancel restore?",
..Default::default()
};
@@ -516,6 +522,290 @@ mod tests {
));
}
+ /// Scripts `enter_wordlist_word` with a fixed (expected title, response) sequence, as a UI
+ /// with dedicated back/cancel controls (BitBox03) produces it.
+ fn script_word_entries(
+ ui: &mut TestingUi<'_>,
+ script: Vec<(String, Result<String, WordlistEntryAbort>)>,
+ ) {
+ let mut script: VecDeque<(String, Result<String, WordlistEntryAbort>)> =
+ script.into_iter().collect();
+ ui.set_enter_wordlist_word(Box::new(move |params| {
+ let (expected_title, response) = script.pop_front().expect("unexpected word entry");
+ assert_eq!(params.title, expected_title);
+ assert!(params.wordlist.is_some());
+ response
+ }));
+ }
+
+ fn word(s: &str) -> Result<String, WordlistEntryAbort> {
+ Ok(String::from(s))
+ }
+
+ #[async_test::test]
+ async fn test_get_back_goes_straight_to_previous_word() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ // No menu is configured: if the workflow showed its "Choose" menu, the test would panic.
+
+ let first_eleven = [
+ "boring", "portion", "dish", "oyster", "truth", "pigeon", "viable", "emerge", "sort",
+ "crash", "wire",
+ ];
+ let last_word = crate::bip39::get_word(lastword_choices(&first_eleven)[0]).unwrap();
+ let mut script = vec![
+ (String::from("1 of 12"), word("boring")),
+ (String::from("2 of 12"), word("mistake")),
+ // A dedicated back control goes straight back to the previous word, which can then
+ // be replaced.
+ (String::from("3 of 12"), Err(WordlistEntryAbort::Back)),
+ (String::from("2 of 12"), word("portion")),
+ ];
+ for (i, w) in first_eleven.iter().enumerate().skip(2) {
+ script.push((format!("{} of 12", i + 1), word(w)));
+ }
+ script.push((String::from("12 of 12"), word(&last_word)));
+ script_word_entries(&mut ui, script);
+
+ let result = get(&mut ui).await;
+ let Ok(mnemonic) = result else {
+ panic!("unexpected user abort");
+ };
+ let mut expected: Vec<&str> = first_eleven.to_vec();
+ expected.push(&last_word);
+ assert_eq!(mnemonic.as_str(), expected.join(" "));
+ // Going back never asks for cancel confirmation.
+ assert!(!ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ #[async_test::test]
+ async fn test_get_cancel_asks_to_confirm() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+
+ // A dedicated cancel control goes straight to the "Cancel restore?" confirmation (the
+ // testing UI accepts confirms by default), without the "Choose" menu.
+ script_word_entries(
+ &mut ui,
+ vec![
+ (String::from("1 of 12"), word("boring")),
+ (String::from("2 of 12"), Err(WordlistEntryAbort::Cancel)),
+ ],
+ );
+
+ let result = get(&mut ui).await;
+ assert!(result.is_err(), "confirmed cancel must abort the restore");
+ assert!(ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ #[async_test::test]
+ async fn test_get_cancel_rejected_continues() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ // Screens: 0 = "Enter 12 words" status, 1 = the "Cancel restore?" confirm — reject it.
+ ui.abort_nth(1);
+
+ let first_eleven = [
+ "boring", "mistake", "dish", "oyster", "truth", "pigeon", "viable", "emerge", "sort",
+ "crash", "wire",
+ ];
+ let last_word = crate::bip39::get_word(lastword_choices(&first_eleven)[0]).unwrap();
+ let mut script = vec![
+ (String::from("1 of 12"), word("boring")),
+ (String::from("2 of 12"), Err(WordlistEntryAbort::Cancel)),
+ // Rejecting the cancel confirmation returns to the same word.
+ (String::from("2 of 12"), word("mistake")),
+ ];
+ for (i, w) in first_eleven.iter().enumerate().skip(2) {
+ script.push((format!("{} of 12", i + 1), word(w)));
+ }
+ script.push((String::from("12 of 12"), word(&last_word)));
+ script_word_entries(&mut ui, script);
+
+ let result = get(&mut ui).await;
+ let Ok(mnemonic) = result else {
+ panic!("unexpected user abort");
+ };
+ let mut expected: Vec<&str> = first_eleven.to_vec();
+ expected.push(&last_word);
+ assert_eq!(mnemonic.as_str(), expected.join(" "));
+ }
+
+ #[async_test::test]
+ async fn test_get_back_on_first_word_asks_to_confirm_cancel() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+
+ // In the first word there is no previous word: back acts as a cancel request.
+ script_word_entries(
+ &mut ui,
+ vec![(String::from("1 of 12"), Err(WordlistEntryAbort::Back))],
+ );
+
+ let result = get(&mut ui).await;
+ assert!(result.is_err(), "confirmed cancel must abort the restore");
+ assert!(ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ /// Scripts `enter_string` with a fixed (expected title, response) sequence, as a UI with a
+ /// single abort control (BitBox02) produces it — reaching the workflow through the
+ /// `enter_wordlist_word` fallback that maps the abort to `Unspecified`.
+ fn script_single_abort_word_entries(
+ ui: &mut TestingUi<'_>,
+ script: Vec<(String, Result<String, UserAbort>)>,
+ ) {
+ let mut script: VecDeque<(String, Result<String, UserAbort>)> =
+ script.into_iter().collect();
+ ui.set_enter_string(Box::new(move |params| {
+ let (expected_title, response) = script.pop_front().expect("unexpected word entry");
+ assert_eq!(params.title, expected_title);
+ assert!(params.wordlist.is_some());
+ response
+ }));
+ }
+
+ /// Scripts the "Choose" menu with fixed responses, asserting its exact contents.
+ fn script_choose_menu(ui: &mut TestingUi<'_>, responses: Vec<Result<u8, UserAbort>>) {
+ let mut responses: VecDeque<Result<u8, UserAbort>> = responses.into_iter().collect();
+ ui.set_menu(Box::new(move |menu_words, title| {
+ assert_eq!(menu_words, ["Edit previous word", "Cancel restore"]);
+ assert_eq!(title, Some("Choose"));
+ responses.pop_front().expect("unexpected menu")
+ }));
+ }
+
+ /// BitBox02 parity: a single-control abort at a word > 1 shows the "Choose" menu; "Edit
+ /// previous word" goes back one word, and cancelling the menu returns to the same word.
+ #[async_test::test]
+ async fn test_get_unspecified_abort_shows_choose_menu() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ script_choose_menu(
+ &mut ui,
+ vec![
+ Ok(0), // first abort: edit the previous word
+ Err(UserAbort), // second abort: cancel the menu -> same word again
+ ],
+ );
+
+ let first_eleven = [
+ "portion", "mistake", "dish", "oyster", "truth", "pigeon", "viable", "emerge", "sort",
+ "crash", "wire",
+ ];
+ let last_word = crate::bip39::get_word(lastword_choices(&first_eleven)[0]).unwrap();
+ let mut script = vec![
+ (String::from("1 of 12"), Ok(String::from("boring"))),
+ (String::from("2 of 12"), Err(UserAbort)), // menu -> edit previous word
+ (String::from("1 of 12"), Ok(String::from("portion"))),
+ (String::from("2 of 12"), Err(UserAbort)), // menu -> cancelled -> same word
+ (String::from("2 of 12"), Ok(String::from("mistake"))),
+ ];
+ for (i, w) in first_eleven.iter().enumerate().skip(2) {
+ script.push((format!("{} of 12", i + 1), Ok(String::from(*w))));
+ }
+ script.push((String::from("12 of 12"), Ok(String::from(&*last_word))));
+ script_single_abort_word_entries(&mut ui, script);
+
+ let result = get(&mut ui).await;
+ let Ok(mnemonic) = result else {
+ panic!("unexpected user abort");
+ };
+ let mut expected: Vec<&str> = first_eleven.to_vec();
+ expected.push(&last_word);
+ assert_eq!(mnemonic.as_str(), expected.join(" "));
+ assert!(!ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ /// BitBox02 parity: picking "Cancel restore" in the "Choose" menu asks for confirmation and
+ /// aborts.
+ #[async_test::test]
+ async fn test_get_unspecified_abort_menu_cancel_confirms() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ script_choose_menu(&mut ui, vec![Ok(1)]);
+ script_single_abort_word_entries(
+ &mut ui,
+ vec![
+ (String::from("1 of 12"), Ok(String::from("boring"))),
+ (String::from("2 of 12"), Err(UserAbort)),
+ ],
+ );
+
+ let result = get(&mut ui).await;
+ assert!(result.is_err(), "confirmed cancel must abort the restore");
+ assert!(ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ /// BitBox02 parity: a single-control abort in the first word skips the menu (there is no
+ /// previous word) and goes straight to the cancel confirmation.
+ #[async_test::test]
+ async fn test_get_unspecified_abort_first_word_goes_to_cancel() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ // No menu is configured: showing it would panic the test.
+ script_single_abort_word_entries(&mut ui, vec![(String::from("1 of 12"), Err(UserAbort))]);
+
+ let result = get(&mut ui).await;
+ assert!(result.is_err(), "confirmed cancel must abort the restore");
+ assert!(ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ /// Back from the restricted last-word candidate screen goes straight to the previous word,
+ /// like from any other word.
+ #[async_test::test]
+ async fn test_get_back_at_last_word_goes_to_previous_word() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+ // No menu is configured: showing it would panic the test.
+
+ let first_eleven = [
+ "boring", "mistake", "dish", "oyster", "truth", "pigeon", "viable", "emerge", "sort",
+ "crash", "wire",
+ ];
+ let last_word = crate::bip39::get_word(lastword_choices(&first_eleven)[0]).unwrap();
+ let mut script: Vec<(String, Result<String, WordlistEntryAbort>)> = first_eleven
+ .iter()
+ .enumerate()
+ .map(|(i, w)| (format!("{} of 12", i + 1), word(w)))
+ .collect();
+ script.push((String::from("12 of 12"), Err(WordlistEntryAbort::Back)));
+ script.push((String::from("11 of 12"), word("wire")));
+ script.push((String::from("12 of 12"), word(&last_word)));
+ script_word_entries(&mut ui, script);
+
+ let result = get(&mut ui).await;
+ let Ok(mnemonic) = result else {
+ panic!("unexpected user abort");
+ };
+ let mut expected: Vec<&str> = first_eleven.to_vec();
+ expected.push(&last_word);
+ assert_eq!(mnemonic.as_str(), expected.join(" "));
+ assert!(!ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
+ /// Cancel from the restricted last-word candidate screen asks "Cancel restore?" directly.
+ #[async_test::test]
+ async fn test_get_cancel_at_last_word_confirms() {
+ let mut ui = TestingUi::new();
+ ui.set_trinary_choice(Box::new(|_, _, _, _| TrinaryChoice::Left)); // 12 words
+
+ let first_eleven = [
+ "boring", "mistake", "dish", "oyster", "truth", "pigeon", "viable", "emerge", "sort",
+ "crash", "wire",
+ ];
+ let mut script: Vec<(String, Result<String, WordlistEntryAbort>)> = first_eleven
+ .iter()
+ .enumerate()
+ .map(|(i, w)| (format!("{} of 12", i + 1), word(w)))
+ .collect();
+ script.push((String::from("12 of 12"), Err(WordlistEntryAbort::Cancel)));
+ script_word_entries(&mut ui, script);
+
+ let result = get(&mut ui).await;
+ assert!(result.is_err(), "confirmed cancel must abort the restore");
+ assert!(ui.contains_confirm("Restore", "Cancel restore?"));
+ }
+
#[test]
fn test_lastword_choices() {
// 23 words
### src/rust/bitbox03/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
bitbox-lvgl = { path = "../bitbox-lvgl" }
bitbox-hal = { path = "../bitbox-hal" }
+bip39 = { workspace = true }
util = { path = "../util" }
tracing = { version = "0.1.41", features = ["log"], default-features = false }
png-decoder = { version="0.2"}
### src/rust/bitbox03/icons/status_cancel.png
[binary or diff unavailable]
### src/rust/bitbox03/icons/status_success.png
[binary or diff unavailable]
### src/rust/bitbox03/src/ui.rs
@@ -11,16 +11,17 @@ use core::marker::PhantomData;
use tracing::info;
use util::futures::completion;
-mod choice;
+pub mod choice;
pub mod confirm;
pub mod demo;
pub mod enter_string;
pub mod keyboard;
pub mod keypad;
pub mod menu;
pub mod nav_button;
+pub mod recovery_words;
pub mod slide_to_confirm;
-mod status;
+pub mod status;
#[cfg(test)]
mod test_util;
@@ -164,6 +165,18 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
.await
}
+ async fn enter_wordlist_word(
+ &mut self,
+ params: &bitbox_hal::ui::EnterStringParams<'_>,
+ can_cancel: bitbox_hal::ui::CanCancel,
+ preset: &str,
+ ) -> Result<zeroize::Zeroizing<alloc::string::String>, bitbox_hal::ui::WordlistEntryAbort> {
+ self.with_result_screen(|responder| {
+ enter_string::build_wordlist_screen(params, can_cancel, preset, responder)
+ })
+ .await
+ }
+
async fn insert_sdcard(&mut self) -> Result<(), bitbox_hal::ui::UserAbort> {
todo!()
}
@@ -173,10 +186,9 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
words: &[&str],
title: Option<&str>,
) -> Result<u8, bitbox_hal::ui::UserAbort> {
- match self.menu_impl(words, title, true, false, 0).await {
+ match self.menu_impl(words, title, 0).await {
menu::MenuResult::Selected(choice_idx) => Ok(choice_idx),
menu::MenuResult::Cancel(_) => Err(bitbox_hal::ui::UserAbort),
- menu::MenuResult::Continue => panic!("unexpected menu continue"),
}
}
@@ -200,18 +212,20 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
}
async fn show_mnemonic(&mut self, words: &[&str]) -> Result<(), bitbox_hal::ui::UserAbort> {
- let mut index = 0usize;
loop {
- match self.menu_impl(words, None, false, true, index).await {
- menu::MenuResult::Continue => return Ok(()),
- menu::MenuResult::Cancel(cancelled_index) => {
- index = cancelled_index;
- match menu::confirm_recovery_words_cancel(self).await {
+ let action = self
+ .with_result_screen(|responder| {
+ recovery_words::build_recovery_words_screen(words, responder)
+ })
+ .await;
+ match action {
+ recovery_words::RecoveryWordsAction::Continue => return Ok(()),
+ recovery_words::RecoveryWordsAction::Cancel => {
+ match recovery_words::confirm_recovery_words_cancel(self).await {
Ok(()) => return Err(bitbox_hal::ui::UserAbort),
Err(bitbox_hal::ui::UserAbort) => {}
}
}
- menu::MenuResult::Selected(_) => panic!("unexpected mnemonic word selection"),
}
}
}
@@ -223,19 +237,15 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
) -> Result<u8, bitbox_hal::ui::UserAbort> {
let mut index = 0usize;
loop {
- match self
- .menu_impl(choices, Some(title), true, false, index)
- .await
- {
+ match self.menu_impl(choices, Some(title), index).await {
menu::MenuResult::Selected(choice_idx) => return Ok(choice_idx),
menu::MenuResult::Cancel(cancelled_index) => {
index = cancelled_index;
- match menu::confirm_recovery_words_cancel(self).await {
+ match recovery_words::confirm_recovery_words_cancel(self).await {
Ok(()) => return Err(bitbox_hal::ui::UserAbort),
Err(bitbox_hal::ui::UserAbort) => {}
}
}
- menu::MenuResult::Continue => panic!("unexpected mnemonic quiz continue"),
}
}
}
@@ -382,23 +392,14 @@ impl<Timer: bitbox_hal::timer::Timer> BitBox03Ui<Timer> {
&mut self,
words: &[&str],
title: Option<&str>,
- select_word: bool,
- continue_on_last: bool,
start_index: usize,
) -> menu::MenuResult {
assert!(!words.is_empty(), "menu requires at least one word");
let mut index = start_index.min(words.len() - 1);
loop {
let action = self
.with_result_screen(|responder| {
- menu::build_menu_screen(
- words,
- title,
- index,
- select_word,
- continue_on_last,
- responder,
- )
+ menu::build_menu_screen(words, title, index, responder)
})
.await;
match action {
@@ -413,7 +414,6 @@ impl<Timer: bitbox_hal::timer::Timer> BitBox03Ui<Timer> {
index.try_into().expect("menu supports at most 256 items"),
);
}
- menu::MenuAction::Continue => return menu::MenuResult::Continue,
menu::MenuAction::Cancel => return menu::MenuResult::Cancel(index),
}
}
### src/rust/bitbox03/src/ui/choice.rs
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
+use alloc::vec;
use alloc::vec::Vec;
use bitbox_hal::ui::TrinaryChoice;
@@ -9,6 +10,11 @@ use bitbox_lvgl::{
};
use util::futures::completion::Responder;
+use super::nav_button::{enable_press_invert, style_outline_button};
+
+/// Style selector for the pressed state.
+const PRESSED: u32 = lvgl::LvState::LV_STATE_PRESSED as u32;
+
fn add_button(
parent: &LvObj,
width: i32,
@@ -18,10 +24,8 @@ fn add_button(
) {
let button = LvButton::new(parent).unwrap();
button.set_size(width, 72);
- button.set_style_bg_color(lvgl::color::white(), 0);
- button.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- button.set_style_border_width(2, 0);
- button.set_style_border_color(lvgl::color::black(), 0);
+ button.set_style_radius(19, 0); // navigation-button corner radius
+ style_outline_button(&button, 2);
button
.add_click_cb(move || responder.resolve(choice))
.expect("failed to register choice callback");
@@ -32,11 +36,15 @@ fn add_button(
lvgl::fonts::INTER_BOLD_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
- button_label.set_style_text_color(lvgl::color::black(), 0);
+ button_label.set_style_text_color(lvgl::color::white(), 0);
+ button_label.set_style_text_color(lvgl::color::black(), PRESSED);
button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+
+ let label_part = button.child(0).expect("choice label");
+ enable_press_invert(&button, vec![label_part]);
}
-pub(super) fn build_trinary_choice_screen(
+pub fn build_trinary_choice_screen(
message: &str,
label_left: Option<&str>,
label_middle: Option<&str>,
@@ -66,6 +74,8 @@ pub(super) fn build_trinary_choice_screen(
title.set_style_flex_grow(1, 0);
let actions = LvObj::with_parent(&screen).unwrap();
+ actions.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ actions.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
actions.set_width(380);
actions.set_height(72);
actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
### src/rust/bitbox03/src/ui/confirm.rs
@@ -35,7 +35,9 @@ pub fn build_confirm_screen(
screen.set_style_text_color(lvgl::color::white(), 0);
screen.set_style_pad_top(40, 0);
screen.set_style_pad_right(50, 0);
- screen.set_style_pad_bottom(40, 0);
+ // Same bottom padding as the entry screens, so the navigation buttons sit at the same
+ // height across a workflow's screens.
+ screen.set_style_pad_bottom(32, 0);
screen.set_style_pad_left(50, 0);
screen.set_style_pad_row(24, 0);
### src/rust/bitbox03/src/ui/enter_string.rs
@@ -2,7 +2,7 @@
use alloc::{rc::Rc, string::String, vec::Vec};
-use bitbox_hal::ui::{CanCancel, EnterStringParams, UserAbort};
+use bitbox_hal::ui::{CanCancel, EnterStringParams, UserAbort, WordlistEntryAbort};
use bitbox_lvgl::{
self as lvgl, KeyboardExt, LabelExt, LvAlign, LvButton, LvButtonmatrixCtrl, LvKeyboard,
LvKeyboardMapEntry, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, LvPart, LvTextarea,
@@ -367,8 +367,6 @@ pub fn build_passphrase_screen(
preset: &str,
responder: Responder<Result<zeroize::Zeroizing<String>, UserAbort>>,
) -> LvObj {
- const DISABLED: u32 = lvgl::LvState::LV_STATE_DISABLED as u32;
-
let screen = build_entry_screen_frame();
add_title(&screen, params.title);
@@ -394,25 +392,19 @@ pub fn build_passphrase_screen(
// Backspace (the mockup's left chevron): deletes the last character; gray and inert while
// the input is empty.
let backspace = build_nav_button(&actions, NavIcon::Back);
- let backspace_icon = backspace.child(0).expect("backspace icon");
- backspace.set_style_border_color(super::keyboard::gray(), DISABLED);
- backspace_icon.set_style_image_recolor(super::keyboard::gray(), DISABLED);
+ let backspace_icon = style_nav_button_disabled(&backspace);
let delete_textarea = Rc::clone(&textarea);
backspace
.add_click_cb(move || delete_textarea.delete_char())
.expect("failed to register backspace callback");
let refresh_textarea = Rc::clone(&textarea);
let refresh_backspace = Rc::new(move || {
- if textarea_is_empty(refresh_textarea.as_ref()) {
- backspace.add_state(lvgl::LvState::LV_STATE_DISABLED);
- backspace_icon.add_state(lvgl::LvState::LV_STATE_DISABLED);
- backspace.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
- } else {
- backspace.remove_state(lvgl::LvState::LV_STATE_DISABLED);
- backspace_icon.remove_state(lvgl::LvState::LV_STATE_DISABLED);
- backspace.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
- }
+ set_nav_button_enabled(
+ &backspace,
+ &backspace_icon,
+ !textarea_is_empty(refresh_textarea.as_ref()),
+ );
});
refresh_backspace();
let refresh_backspace_cb = Rc::clone(&refresh_backspace);
@@ -442,6 +434,271 @@ pub fn build_passphrase_screen(
screen
}
+/// The gray disabled look of a navigation icon button (border and icon); returns the icon for
+/// state toggling. Pairs with [`set_nav_button_enabled`].
+fn style_nav_button_disabled(button: &LvButton) -> LvObj {
+ const DISABLED: u32 = lvgl::LvState::LV_STATE_DISABLED as u32;
+ let icon = button.child(0).expect("nav button icon");
+ button.set_style_border_color(super::keyboard::gray(), DISABLED);
+ icon.set_style_image_recolor(super::keyboard::gray(), DISABLED);
+ icon
+}
+
+/// Enables/disables a navigation icon button: disabled renders the gray look from
+/// [`style_nav_button_disabled`] and makes the button inert.
+fn set_nav_button_enabled(button: &LvButton, icon: &LvObj, enabled: bool) {
+ if enabled {
+ button.remove_state(lvgl::LvState::LV_STATE_DISABLED);
+ icon.remove_state(lvgl::LvState::LV_STATE_DISABLED);
+ button.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ } else {
+ button.add_state(lvgl::LvState::LV_STATE_DISABLED);
+ icon.add_state(lvgl::LvState::LV_STATE_DISABLED);
+ button.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ }
+}
+
+/// Runs `f` on the textarea's content, borrowed in place from LVGL's buffer — unlike
+/// [`snapshot_text`] this makes no copy of the (possibly secret) content.
+fn with_textarea_text<R>(textarea: &LvTextarea, f: impl FnOnce(&str) -> R) -> R {
+ let text = unsafe { lvgl::ffi::lv_textarea_get_text(textarea.as_ptr()) };
+ if text.is_null() {
+ return f("");
+ }
+ let text = unsafe { core::ffi::CStr::from_ptr(text) };
+ f(text.to_str().expect("textarea content must be valid UTF-8"))
+}
+
+/// The BIP39 English word at `idx`, or `None` if out of range. The wordlist is public
+/// compiled-in data, so the returned `&'static str` needs no zeroizing.
+fn bip39_word(idx: u16) -> Option<&'static str> {
+ bip39::Language::English
+ .word_list()
+ .get(idx as usize)
+ .copied()
+}
+
+/// Word-entry state derived from `wordlist` (BIP39 word indices; out-of-range indices are
+/// skipped) for the entered `prefix`.
+struct WordlistMatch {
+ /// The letters that can follow `prefix` towards one of the words.
+ next_letters: super::keyboard::LetterSet,
+ /// `prefix` is itself one of the words.
+ complete: bool,
+ /// The one word starting with `prefix`, if exactly one does (BitBox02 parity: a duplicated
+ /// index counts as two matches).
+ unique: Option<&'static str>,
+}
+
+fn wordlist_matches(wordlist: &[u16], prefix: &str) -> WordlistMatch {
+ let mut next_letters = super::keyboard::LetterSet::EMPTY;
+ let mut complete = false;
+ let mut last_match = None;
+ let mut match_count = 0usize;
+ for &idx in wordlist {
+ let Some(word) = bip39_word(idx) else {
+ continue;
+ };
+ if let Some(rest) = word.strip_prefix(prefix) {
+ match_count += 1;
+ last_match = Some(word);
+ match rest.as_bytes().first() {
+ Some(&letter) => next_letters.insert(letter),
+ None => complete = true,
+ }
+ }
+ }
+ WordlistMatch {
+ next_letters,
+ complete,
+ unique: if match_count == 1 { last_match } else { None },
+ }
+}
+
+/// Adds the visible word entry display: the standard entry field, centred (via a `flex_grow`
+/// wrapper, like the passphrase screen's masked display) in the space the screen's flex flow
+/// leaves between the title and the bottom-anchored keyboard region.
+///
+/// Returns the textarea (the display row's child 0) that the input widgets operate on.
+fn add_word_display(screen: &LvObj, preset: &str) -> Rc<LvTextarea> {
+ let display = LvObj::with_parent(screen).unwrap();
+ display.set_size(380, 72);
+ display.set_style_flex_grow(1, 0);
+ display.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ display.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ display.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ display.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ // Centre the flex track too: cross-place only centres items within their (72px-tall) track,
+ // and the track itself defaults to the container's top.
+ display.set_style_flex_track_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ display.set_style_pad_top(0, 0);
+ display.set_style_pad_bottom(0, 0);
+ display.set_style_pad_left(0, 0);
+ display.set_style_pad_right(0, 0);
+ display.set_style_border_width(0, 0);
+ display.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ display.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
+ display.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+
+ let textarea = add_textarea(&display, preset, false);
+ // The field is display-only: a tap must not move the insertion cursor into the middle of
+ // the word (letters always append; backspace always deletes the last one).
+ textarea.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ // No entry box on this screen: the letters sit directly on the background, centred, in the
+ // same bold 48px the mnemonic review screen shows the words in. The standard field's 72px
+ // box (16px paddings) cannot hold that line height, so the field sizes to its content.
+ textarea.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ textarea.set_style_border_width(0, 0);
+ textarea.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
+ textarea.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ textarea.set_style_pad_top(0, 0);
+ textarea.set_style_pad_bottom(0, 0);
+ textarea.set_height(lvgl::ffi::LV_SIZE_CONTENT as i32);
+ Rc::new(textarea)
+}
+
+/// The BIP39 recovery-word entry screen: title ("x of y"), the word being typed in the standard
+/// entry field (recovery words are shown in plaintext — reading the word back is the point of
+/// the entry), the letters-only keyboard and a backspace/confirm navigation row.
+///
+/// The keyboard only ever offers letters that extend the entry towards one of the
+/// `params.wordlist` words (BIP39 word indices; must be set); all other keys are gray and
+/// inert, so no invalid word can be composed. A typed letter that leaves exactly one candidate
+/// autocompletes the whole word (BitBox02 parity; deleting never re-autocompletes, so backspace
+/// can undo it). Confirm is likewise gray until the entry exactly matches a wordlist word, so
+/// the workflow's "Invalid word" retry loop can never trigger. Back deletes the last letter; on
+/// an empty entry it rejects with [`WordlistEntryAbort::Back`] — the mnemonic workflow goes
+/// straight back to the previous word. With `CanCancel::Yes` (always set by that workflow) a
+/// corner close button rejects with [`WordlistEntryAbort::Cancel`] at any time — the workflow
+/// asks "Cancel restore?"; with `CanCancel::No` there is no close button and Back grays out on
+/// an empty entry.
+pub fn build_wordlist_screen(
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ responder: Responder<Result<zeroize::Zeroizing<String>, WordlistEntryAbort>>,
+) -> LvObj {
+ let wordlist = params
+ .wordlist
+ .expect("wordlist entry requires params.wordlist");
+ let screen = build_entry_screen_frame();
+
+ add_title(&screen, params.title);
+
+ let textarea = add_word_display(&screen, preset);
+ // Wordlist words are lowercase ASCII, at most 8 letters; refuse anything else at the widget
+ // level too (the key filtering already guarantees it for touch input).
+ textarea.set_accepted_chars(Some(c"abcdefghijklmnopqrstuvwxyz"));
+ textarea.set_max_length(8);
+
+ let wordlist: Rc<[u16]> = wordlist.into();
+
+ // BitBox02 parity: a typed letter that leaves exactly one candidate autocompletes the whole
+ // word. This runs from the keyboard's insert path, not the textarea change callback: only
+ // insertions may autocomplete (completing after a deletion would trap backspace in an
+ // undo-redo loop right after it deletes an autocompleted letter), and the textarea callback
+ // cannot mutate the textarea anyway (that would re-enter it). The `set_text` here fires the
+ // change callback below, which then relays the completed state to the keys and buttons.
+ let autocomplete_textarea = Rc::clone(&textarea);
+ let autocomplete_wordlist = Rc::clone(&wordlist);
+ let autocomplete: Rc<dyn Fn()> = Rc::new(move || {
+ let unique = with_textarea_text(autocomplete_textarea.as_ref(), |prefix| {
+ wordlist_matches(&autocomplete_wordlist, prefix)
+ .unique
+ .filter(|word| word.len() > prefix.len())
+ });
+ if let Some(word) = unique {
+ autocomplete_textarea
+ .set_text(word)
+ .expect("wordlist words contain no NUL");
+ }
+ });
+
+ // Keyboard and navigation row are bottom-anchored like on the passphrase screen (see
+ // `build_passphrase_screen`), with the keyboard sitting 50px higher over the navigation
+ // row here.
+ let keyboard =
+ super::keyboard::build_wordlist_keyboard(&screen, Rc::clone(&textarea), autocomplete);
+ keyboard.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ keyboard.align(LvAlign::LV_ALIGN_BOTTOM_MID, 0, -(82 + 20 + 50));
+
+ let actions = add_actions_row(&screen);
+ actions.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ actions.align(LvAlign::LV_ALIGN_BOTTOM_MID, 0, 0);
+
+ add_bottom_region_spacer(
+ &screen,
+ super::keyboard::WORDLIST_KEYBOARD_HEIGHT + 20 + 50 + 82,
+ );
+
+ // Back: deletes the last letter; on an empty entry it goes back to the previous word
+ // instead (the mnemonic workflow re-opens that word directly). With `CanCancel::No` (not
+ // used by the mnemonic workflow) there is nowhere to go back to, so the button grays out
+ // on an empty entry.
+ let allow_back_out = matches!(can_cancel, CanCancel::Yes);
+ let backspace = build_nav_button(&actions, NavIcon::Back);
+ let backspace_icon = style_nav_button_disabled(&backspace);
+ let delete_textarea = Rc::clone(&textarea);
+ let back_responder = responder.clone();
+ backspace
+ .add_click_cb(move || {
+ if !textarea_is_empty(delete_textarea.as_ref()) {
+ delete_textarea.delete_char();
+ } else if allow_back_out {
+ back_responder.resolve(Err(WordlistEntryAbort::Back));
+ }
+ })
+ .expect("failed to register backspace callback");
+
+ // The corner close button requests cancelling the whole restore; the workflow asks for
+ // confirmation before acting on it.
+ if matches!(can_cancel, CanCancel::Yes) {
+ let reject_responder = responder.clone();
+ let close = build_close_button(&screen);
+ // This screen has no side padding; re-anchor the corner button ~12px from the edges.
+ close.align(lvgl::LvAlign::LV_ALIGN_TOP_RIGHT, -12, -28);
+ close
+ .add_click_cb(move || reject_responder.resolve(Err(WordlistEntryAbort::Cancel)))
+ .expect("failed to register cancel callback");
+ }
+
+ // Confirm: gray and inert until the entry is a complete wordlist word.
+ let accept = build_nav_button(&actions, NavIcon::Confirm);
+ let accept_icon = style_nav_button_disabled(&accept);
+ {
+ let textarea = Rc::clone(&textarea);
+ accept
+ .add_click_cb(move || {
+ responder.resolve(Ok(snapshot_text(textarea.as_ref())));
+ })
+ .expect("failed to register confirm callback");
+ }
+
+ // Relay the word-entry state — which letters may come next and whether the entry is a
+ // complete word — from the wordlist to the keys and buttons on every content change.
+ let refresh_textarea = Rc::clone(&textarea);
+ let refresh = Rc::new(move || {
+ let (len, matches) = with_textarea_text(refresh_textarea.as_ref(), |prefix| {
+ (prefix.len(), wordlist_matches(&wordlist, prefix))
+ });
+ super::keyboard::set_enabled_letters(&keyboard, matches.next_letters);
+ set_nav_button_enabled(&backspace, &backspace_icon, len > 0 || allow_back_out);
+ set_nav_button_enabled(&accept, &accept_icon, matches.complete);
+ });
+ refresh();
+ let refresh_cb = Rc::clone(&refresh);
+ textarea
+ .add_event_cb(lvgl::LvEventCode::LV_EVENT_VALUE_CHANGED, move || {
+ refresh_cb()
+ })
+ .expect("failed to register wordlist refresh callback");
+
+ screen
+}
+
/// Builds a screen skeleton shared by the passphrase and PIN screens: black background, flex
/// column with both cross alignments centred (content is wider than the standard 380px on the
/// passphrase screen), standard outer padding, no scrolling.
@@ -638,10 +895,16 @@ pub fn build_enter_string_screen(
preset: &str,
responder: Responder<Result<zeroize::Zeroizing<String>, UserAbort>>,
) -> LvObj {
- if params.pin && params.wordlist.is_none() {
+ // Recovery-word entry goes through `build_wordlist_screen` (via the HAL's
+ // `enter_wordlist_word`), whose result distinguishes going back from cancelling.
+ debug_assert!(
+ params.wordlist.is_none(),
+ "wordlist entry must use build_wordlist_screen"
+ );
+ if params.pin {
return build_pin_screen(params, can_cancel, preset, responder);
}
- if params.passphrase && params.wordlist.is_none() && !params.number_input {
+ if params.passphrase && !params.number_input {
return build_passphrase_screen(params, can_cancel, preset, responder);
}
@@ -675,10 +938,10 @@ pub fn build_enter_string_screen(
// No extra margin: LVGL's flex sizing does not subtract margins when distributing
// flex_grow space, so a margin here overflows the screen's bottom padding.
keyboard.set_popovers(false);
- let show_keyboard_switch = params.wordlist.is_none() && !params.number_input;
+ let show_keyboard_switch = !params.number_input;
let initial_keyboard_mode = if params.number_input {
None
- } else if params.default_to_digits && params.wordlist.is_none() {
+ } else if params.default_to_digits {
Some(KeyboardMode::Digits)
} else {
Some(KeyboardMode::LowerCase)
@@ -873,17 +1136,24 @@ mod tests {
}
}
- struct Harness {
+ /// The wordlist screen resolves with a richer abort than the other entry screens.
+ type WordlistResult = Result<zeroize::Zeroizing<String>, WordlistEntryAbort>;
+
+ struct Harness<R = Result<zeroize::Zeroizing<String>, UserAbort>> {
touch: ScriptedTouch,
screen: LvObj,
- result: completion::Result<Result<zeroize::Zeroizing<String>, UserAbort>>,
+ result: completion::Result<R>,
}
impl Harness {
- fn with_params(params: &EnterStringParams<'_>, can_cancel: CanCancel) -> Self {
+ fn with_params(
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ ) -> Self {
let touch = ScriptedTouch::new();
let (responder, result) = completion::completion();
- let screen = build_enter_string_screen(params, can_cancel, "", responder);
+ let screen = build_enter_string_screen(params, can_cancel, preset, responder);
unsafe { ffi::lv_screen_load(screen.as_ptr()) };
pump_for(60); // layout + first render
Self {
@@ -894,13 +1164,37 @@ mod tests {
}
fn new(can_cancel: CanCancel) -> Self {
- Self::with_params(&passphrase_params(), can_cancel)
+ Self::with_params(&passphrase_params(), can_cancel, "")
}
fn new_pin(can_cancel: CanCancel) -> Self {
- Self::with_params(&pin_params(), can_cancel)
+ Self::with_params(&pin_params(), can_cancel, "")
+ }
+
+ fn new_wordlist(
+ wordlist: &[u16],
+ can_cancel: CanCancel,
+ preset: &str,
+ ) -> Harness<WordlistResult> {
+ let params = EnterStringParams {
+ title: "1 of 24",
+ wordlist: Some(wordlist),
+ ..Default::default()
+ };
+ let touch = ScriptedTouch::new();
+ let (responder, result) = completion::completion();
+ let screen = build_wordlist_screen(¶ms, can_cancel, preset, responder);
+ unsafe { ffi::lv_screen_load(screen.as_ptr()) };
+ pump_for(60); // layout + first render
+ Harness {
+ touch,
+ screen,
+ result,
+ }
}
+ }
+ impl<R> Harness<R> {
/// The PIN keypad container (screen child 2 on the PIN screen).
fn keypad(&self) -> LvObj {
self.screen.child(2).expect("keypad container")
@@ -944,7 +1238,7 @@ mod tests {
(0..MASK_DOT_COUNT_MAX)
.filter(|i| {
let dot = display.child(1 + *i as i32).expect("masking dot");
- !Self::hidden(&dot)
+ !Harness::hidden(&dot)
})
.count()
}
@@ -955,7 +1249,7 @@ mod tests {
.entry_display()
.child(1 + MASK_DOT_COUNT_MAX as i32)
.expect("last-character label");
- if Self::hidden(&label) {
+ if Harness::hidden(&label) {
return None;
}
let label = label
@@ -1022,6 +1316,56 @@ mod tests {
.expect("preview balloon")
}
+ /// The preview balloon of the (letters-only) wordlist keyboard.
+ fn wordlist_preview(&self) -> LvObj {
+ self.keyboard()
+ .child(keyboard::WORDLIST_CHILD_INDEX_PREVIEW)
+ .expect("preview balloon")
+ }
+
+ /// The wordlist-keyboard key-row buttonmatrix `row`.
+ fn wordlist_row(&self, row: usize) -> bitbox_lvgl::LvButtonmatrix {
+ self.keyboard()
+ .child(row as i32)
+ .expect("key row")
+ .try_downcast::<class::ButtonmatrixTag>()
+ .expect("key row is a buttonmatrix")
+ }
+
+ /// Taps the wordlist-keyboard key for `letter`.
+ fn tap_wordlist_letter(&mut self, letter: u8) {
+ let (row, col) = keyboard::wordlist_letter_pos(letter);
+ let (x, y) = self.on_keyboard(keyboard::wordlist_key_center(row, col));
+ self.touch.tap(x, y);
+ }
+
+ /// Whether the wordlist-keyboard key for `letter` is disabled.
+ fn wordlist_letter_disabled(&self, letter: u8) -> bool {
+ use bitbox_lvgl::ButtonmatrixExt;
+ let (row, col) = keyboard::wordlist_letter_pos(letter);
+ self.wordlist_row(row).has_button_ctrl(
+ col as u32,
+ bitbox_lvgl::LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_DISABLED,
+ )
+ }
+
+ /// Asserts that exactly the letters in `expected` are enabled on the wordlist keyboard.
+ #[track_caller]
+ fn assert_enabled_letters(&self, expected: &[u8]) {
+ for letter in b'a'..=b'z' {
+ assert_eq!(
+ !self.wordlist_letter_disabled(letter),
+ expected.contains(&letter),
+ "letter '{}' enabled state",
+ letter as char
+ );
+ }
+ }
+ }
+
+ // Widget-state helpers: associated functions of the non-generic harness so call sites can
+ // stay `Harness::...` without turbofishing the (irrelevant) result type.
+ impl Harness {
fn hidden(obj: &LvObj) -> bool {
unsafe { ffi::lv_obj_has_flag(obj.as_ptr(), lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN) }
}
@@ -1048,7 +1392,7 @@ mod tests {
}
}
- impl Drop for Harness {
+ impl<R> Drop for Harness<R> {
fn drop(&mut self) {
// Swap in a fresh empty screen so the tested screen can be deleted.
let blank = LvObj::new().unwrap();
@@ -1585,4 +1929,435 @@ mod tests {
let result = poll_once(&mut harness.result).expect("close resolves");
assert!(result.is_err(), "close must reject with UserAbort");
}
+
+ /// The index of `word` in the BIP39 English wordlist.
+ fn word_idx(word: &str) -> u16 {
+ bip39::Language::English
+ .word_list()
+ .iter()
+ .position(|w| *w == word)
+ .unwrap_or_else(|| panic!("'{word}' is not a BIP39 word")) as u16
+ }
+
+ fn word_indices(words: &[&str]) -> Vec<u16> {
+ words.iter().map(|word| word_idx(word)).collect()
+ }
+
+ #[test]
+ fn test_wordlist_keyboard_layout() {
+ use bitbox_lvgl::ButtonmatrixExt;
+
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["abandon"]);
+ let harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // The keyboard carries exactly the three letter-row matrices plus the preview balloon —
+ // no digit row and no caps-lock/space/symbols function row.
+ let keyboard = harness.keyboard();
+ assert!(
+ keyboard
+ .child(keyboard::WORDLIST_CHILD_INDEX_PREVIEW)
+ .is_some()
+ );
+ assert!(
+ keyboard
+ .child(keyboard::WORDLIST_CHILD_INDEX_PREVIEW + 1)
+ .is_none(),
+ "unexpected extra child on the wordlist keyboard"
+ );
+ let area = coords(&keyboard);
+ assert_eq!(area.y2 - area.y1 + 1, keyboard::WORDLIST_KEYBOARD_HEIGHT);
+
+ // Each key's text matches the letter table the enable/disable logic works from.
+ for row in 0..3 {
+ let matrix = harness.wordlist_row(row);
+ let letters = keyboard::wordlist_row_letters(row);
+ for (id, letter) in letters.iter().enumerate() {
+ let text = matrix.get_button_text(id as u32).expect("key text");
+ assert_eq!(text.to_bytes(), &[*letter]);
+ }
+ assert!(
+ matrix.get_button_text(letters.len() as u32).is_none(),
+ "row {row} has more keys than letters"
+ );
+ }
+ }
+
+ #[test]
+ fn test_wordlist_typing_filters_keys() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["action", "actor", "zoo"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Empty entry: only the words' first letters are enabled.
+ harness.assert_enabled_letters(b"az");
+ harness.tap_wordlist_letter(b'b'); // disabled: types nothing
+ assert_eq!(harness.text().as_str(), "");
+
+ harness.tap_wordlist_letter(b'a');
+ assert_eq!(harness.text().as_str(), "a");
+ harness.assert_enabled_letters(b"c");
+ harness.tap_wordlist_letter(b'z'); // was valid before, no longer
+ assert_eq!(harness.text().as_str(), "a");
+
+ harness.tap_wordlist_letter(b'c');
+ harness.tap_wordlist_letter(b't');
+ assert_eq!(harness.text().as_str(), "act");
+ harness.assert_enabled_letters(b"io"); // action | actor
+
+ // 'o' leaves "actor" as the only candidate: the word autocompletes, every letter is
+ // disabled, confirm is enabled.
+ harness.tap_wordlist_letter(b'o');
+ assert_eq!(harness.text().as_str(), "actor");
+ harness.assert_enabled_letters(b"");
+ assert!(!Harness::disabled(&harness.confirm()));
+
+ let confirm = harness.confirm();
+ harness.tap_button(&confirm);
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "actor");
+ }
+
+ #[test]
+ fn test_wordlist_confirm_requires_complete_word() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["act", "action"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Confirm is gray and inert while the entry is no complete word.
+ assert!(Harness::disabled(&harness.confirm()));
+ let confirm = harness.confirm();
+ harness.tap_button(&confirm);
+ assert!(poll_once(&mut harness.result).is_none());
+
+ harness.tap_wordlist_letter(b'a');
+ harness.tap_wordlist_letter(b'c');
+ assert!(Harness::disabled(&harness.confirm()));
+ harness.tap_wordlist_letter(b't');
+
+ // "act" is a word itself AND a prefix of "action": confirm and 'i' are both live.
+ assert!(!Harness::disabled(&harness.confirm()));
+ harness.assert_enabled_letters(b"i");
+
+ harness.tap_button(&confirm);
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "act");
+ }
+
+ #[test]
+ fn test_wordlist_backspace_updates_filter() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone", "zebra"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ assert!(Harness::disabled(&harness.backspace()));
+ harness.assert_enabled_letters(b"z");
+ harness.tap_wordlist_letter(b'z');
+ harness.tap_wordlist_letter(b'o');
+ harness.assert_enabled_letters(b"no"); // zone | zoo
+ harness.tap_wordlist_letter(b'o');
+ assert_eq!(harness.text().as_str(), "zoo");
+ assert!(!Harness::disabled(&harness.confirm()));
+
+ // Deleting reverts both the letter filter and the confirm gating.
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "zo");
+ harness.assert_enabled_letters(b"no");
+ assert!(Harness::disabled(&harness.confirm()));
+
+ harness.tap_button(&backspace);
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ assert!(Harness::disabled(&harness.backspace()));
+ harness.assert_enabled_letters(b"z");
+ }
+
+ #[test]
+ fn test_wordlist_back_on_empty_goes_back() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::Yes, "");
+
+ // With a cancellable screen the Back button is live even on an empty entry.
+ assert!(!Harness::disabled(&harness.backspace()));
+
+ // While the entry has letters, Back is backspace: it deletes and does not resolve.
+ harness.tap_wordlist_letter(b'z');
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ assert!(poll_once(&mut harness.result).is_none());
+
+ // On the now-empty entry, Back goes back to the previous word.
+ harness.tap_button(&backspace);
+ let result = poll_once(&mut harness.result).expect("back resolves");
+ assert!(
+ matches!(result, Err(WordlistEntryAbort::Back)),
+ "back on an empty entry must reject with Back"
+ );
+ }
+
+ #[test]
+ fn test_wordlist_back_on_empty_inert_when_not_cancellable() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Without a cancel path there is nowhere to go back to: the button is gray and a tap
+ // neither types nor resolves.
+ assert!(Harness::disabled(&harness.backspace()));
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ assert!(poll_once(&mut harness.result).is_none());
+ }
+
+ #[test]
+ fn test_wordlist_preset_starts_complete() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::Yes, "zoo");
+
+ // A preset word (re-editing a previously entered word) starts out confirmable.
+ assert_eq!(harness.text().as_str(), "zoo");
+ assert!(!Harness::disabled(&harness.confirm()));
+ assert!(!Harness::disabled(&harness.backspace()));
+ harness.assert_enabled_letters(b"");
+
+ let confirm = harness.confirm();
+ harness.tap_button(&confirm);
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "zoo");
+ }
+
+ #[test]
+ fn test_wordlist_disabled_key_no_preview_no_type() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Press and hold the disabled 'q': the preview must not pop up, and releasing must not
+ // type (LVGL never selects a disabled matrix button).
+ assert!(harness.wordlist_letter_disabled(b'q'));
+ let preview = harness.wordlist_preview();
+ let (row, col) = keyboard::wordlist_letter_pos(b'q');
+ let (x, y) = harness.on_keyboard(keyboard::wordlist_key_center(row, col));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(Harness::hidden(&preview));
+ harness.touch.push(x, y, false);
+ pump_for(120);
+ assert_eq!(harness.text().as_str(), "");
+
+ // The enabled 'z' still previews and types.
+ harness.tap_wordlist_letter(b'z');
+ assert_eq!(harness.text().as_str(), "z");
+ }
+
+ #[test]
+ fn test_wordlist_autocompletes_unique_match() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zebra", "zoo"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Two candidates left after 'z': no autocomplete yet.
+ harness.tap_wordlist_letter(b'z');
+ assert_eq!(harness.text().as_str(), "z");
+ assert!(Harness::disabled(&harness.confirm()));
+
+ // 'e' leaves only "zebra": the rest of the word fills in by itself.
+ harness.tap_wordlist_letter(b'e');
+ assert_eq!(harness.text().as_str(), "zebra");
+ harness.assert_enabled_letters(b"");
+ assert!(!Harness::disabled(&harness.confirm()));
+ assert!(!Harness::disabled(&harness.backspace()));
+
+ let confirm = harness.confirm();
+ harness.tap_button(&confirm);
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "zebra");
+ }
+
+ #[test]
+ fn test_wordlist_autocompletes_longest_word() {
+ let _lock = lock_and_init();
+ // "abstract" has 8 letters — exactly the entry's max length; the autocompleting
+ // `set_text` must not be truncated by it.
+ let wordlist = word_indices(&["abstract", "abandon"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ harness.tap_wordlist_letter(b'a');
+ harness.tap_wordlist_letter(b'b');
+ harness.tap_wordlist_letter(b's');
+ assert_eq!(harness.text().as_str(), "abstract");
+ assert!(!Harness::disabled(&harness.confirm()));
+ }
+
+ #[test]
+ fn test_wordlist_autocompletes_past_shorter_word() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["act", "action"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // "act" matches two candidates ("act", "action"): no autocomplete, even though the
+ // entry is itself a confirmable word.
+ harness.tap_wordlist_letter(b'a');
+ harness.tap_wordlist_letter(b'c');
+ harness.tap_wordlist_letter(b't');
+ assert_eq!(harness.text().as_str(), "act");
+ assert!(!Harness::disabled(&harness.confirm()));
+
+ // 'i' leaves only "action": autocompletes.
+ harness.tap_wordlist_letter(b'i');
+ assert_eq!(harness.text().as_str(), "action");
+ assert!(!Harness::disabled(&harness.confirm()));
+ }
+
+ #[test]
+ fn test_wordlist_backspace_undoes_autocomplete() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zebra", "zoo"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ harness.tap_wordlist_letter(b'z');
+ harness.tap_wordlist_letter(b'e');
+ assert_eq!(harness.text().as_str(), "zebra");
+
+ // Deleting never re-autocompletes (else backspace could not get past the completed
+ // part): each press removes exactly one letter, with the filter and confirm gating
+ // tracking along.
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "zebr");
+ assert!(Harness::disabled(&harness.confirm()));
+ harness.assert_enabled_letters(b"a");
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "zeb");
+
+ // Typing again re-arms the autocomplete.
+ harness.tap_wordlist_letter(b'r');
+ assert_eq!(harness.text().as_str(), "zebra");
+ }
+
+ #[test]
+ fn test_wordlist_slide_off_key_cancels() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Press the enabled 'z', slide off it (onto the disabled 'x'), release: the buttonmatrix
+ // discards its selection when the pointer leaves the pressed key, so the preview hides
+ // and nothing is typed.
+ let preview = harness.wordlist_preview();
+ let (row, col) = keyboard::wordlist_letter_pos(b'z');
+ let (x, y) = harness.on_keyboard(keyboard::wordlist_key_center(row, col));
+ let (x_next, _) = harness.on_keyboard(keyboard::wordlist_key_center(row, col + 1));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(!Harness::hidden(&preview));
+
+ harness.touch.push(x_next, y, true);
+ harness.touch.push(x_next, y, true);
+ pump_for(120);
+ assert!(Harness::hidden(&preview));
+
+ harness.touch.push(x_next, y, false);
+ pump_for(120);
+ assert_eq!(harness.text().as_str(), "");
+ }
+
+ #[test]
+ fn test_wordlist_holding_key_types_once() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo", "zone"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // Hold 'z' well past LVGL's long-press threshold (400ms) and repeat period (100ms): the
+ // NO_REPEAT ctrl bit must keep the buttonmatrix from auto-repeating; exactly one letter
+ // is inserted, on release.
+ let (row, col) = keyboard::wordlist_letter_pos(b'z');
+ let (x, y) = harness.on_keyboard(keyboard::wordlist_key_center(row, col));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(700);
+ assert_eq!(harness.text().as_str(), "");
+ harness.touch.push(x, y, false);
+ pump_for(120);
+ assert_eq!(harness.text().as_str(), "z");
+ }
+
+ #[test]
+ fn test_wordlist_preview_on_centred_rows() {
+ let _lock = lock_and_init();
+ // The full wordlist, so every row's first letters are enabled.
+ let wordlist: Vec<u16> = (0..2048).collect();
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::No, "");
+
+ // The 9-key and 7-key rows are centred, so the preview position depends on the row's
+ // key count: press-hold a key on each and check the balloon straddles it.
+ let preview = harness.wordlist_preview();
+ for (letter, expected_label) in [(b's', "s"), (b'c', "c")] {
+ let (row, col) = keyboard::wordlist_letter_pos(letter);
+ let (x, y) = harness.on_keyboard(keyboard::wordlist_key_center(row, col));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(!Harness::hidden(&preview));
+ let balloon = coords(&preview);
+ // `x2` is inclusive (x1 + width - 1), so round the centre up.
+ assert_eq!(
+ (balloon.x1 + balloon.x2 + 1) / 2,
+ x,
+ "balloon not centred on '{}'",
+ letter as char
+ );
+ assert!(
+ balloon.y1 < y - 30 - 60,
+ "balloon head not above '{}'",
+ letter as char
+ );
+ let label = preview
+ .child(1)
+ .expect("preview label")
+ .try_downcast::<class::LabelTag>()
+ .expect("preview label class");
+ assert_eq!(label.get_text().unwrap().to_str().unwrap(), expected_label);
+ harness.touch.push(x, y, false);
+ pump_for(120);
+ assert!(Harness::hidden(&preview));
+ }
+ assert_eq!(harness.text().as_str(), "sc");
+ }
+
+ #[test]
+ fn test_wordlist_close_button_rejects() {
+ let _lock = lock_and_init();
+ let wordlist = word_indices(&["zoo"]);
+ let mut harness = Harness::new_wordlist(&wordlist, CanCancel::Yes, "");
+
+ // Same child order as the passphrase screen: title, display, keyboard, actions, spacer,
+ // corner close button.
+ let close = harness.screen.child(5).expect("corner close button");
+ harness.tap_button(&close);
+ let result = poll_once(&mut harness.result).expect("close resolves");
+ assert!(
+ matches!(result, Err(WordlistEntryAbort::Cancel)),
+ "close button must reject with Cancel"
+ );
+ }
}
### src/rust/bitbox03/src/ui/keyboard.rs
@@ -1,15 +1,22 @@
// SPDX-License-Identifier: Apache-2.0
-//! On-screen QWERTY keyboard used for BIP39 passphrase entry.
+//! On-screen QWERTY keyboard used for BIP39 passphrase entry, and its letters-only variant used
+//! for BIP39 wordlist (recovery word) entry.
//!
-//! Four rows of outlined keys (a digit row and three character rows) above a function row with a
-//! caps-lock toggle, a space bar and a symbols toggle. Pressing a character key "jets out" a
-//! balloon-shaped enlarged preview of the key above the finger, so the user sees which key they
-//! hit; the character is inserted on release. Sliding off the pressed key cancels: the preview
-//! hides and nothing is typed (the buttonmatrix discards its selection when the pointer leaves
-//! the pressed button). The symbols layout reuses BitBox02's special character set
-//! (`_special_chars` in trinary_input_string.c), which — with space on its own key — fills the
-//! three character rows exactly (3 × 10); the digit row stays in place.
+//! The passphrase keyboard: four rows of outlined keys (a digit row and three character rows)
+//! above a function row with a caps-lock toggle, a space bar and a symbols toggle. Pressing a
+//! character key "jets out" a balloon-shaped enlarged preview of the key above the finger, so the
+//! user sees which key they hit; the character is inserted on release. Sliding off the pressed
+//! key cancels: the preview hides and nothing is typed (the buttonmatrix discards its selection
+//! when the pointer leaves the pressed button). The symbols layout reuses BitBox02's special
+//! character set (`_special_chars` in trinary_input_string.c), which — with space on its own key
+//! — fills the three character rows exactly (3 × 10); the digit row stays in place.
+//!
+//! The wordlist keyboard ([`build_wordlist_keyboard`]) is the same keyboard reduced to the three
+//! lowercase letter rows: no digit row and no function row, since no digit, space, capital or
+//! special character occurs in a BIP39 word. Its keys can be individually disabled
+//! ([`set_enabled_letters`]) so entry screens can gray out letters that cannot extend the input
+//! towards a valid word.
//!
//! Each key row is a single `lv_buttonmatrix` rather than per-key buttons: LVGL draws matrix
//! buttons without allocating an object per key, which keeps the screen well inside LVGL's small
@@ -83,6 +90,18 @@ pub const CHILD_INDEX_SYMBOLS: i32 = CHILD_INDEX_CAPSLOCK + 2;
/// Child index of the (initially hidden) pressed-key preview balloon.
pub const CHILD_INDEX_PREVIEW: i32 = CHILD_INDEX_CAPSLOCK + 3;
+/// Number of key rows on the wordlist keyboard (the three lowercase letter rows).
+const WORDLIST_ROWS: usize = 3;
+/// Child index of the pressed-key preview balloon on the wordlist keyboard (after the
+/// `WORDLIST_ROWS` key-row buttonmatrices).
+pub const WORDLIST_CHILD_INDEX_PREVIEW: i32 = WORDLIST_ROWS as i32;
+/// Height of the wordlist keyboard: three key rows, no function row.
+pub const WORDLIST_KEYBOARD_HEIGHT: i32 = 2 * ROW_PITCH_Y + KEY_HEIGHT; // 220
+/// The wordlist keyboard's letters by (row, key id). Must match `MAP_LOWER_1..3` (checked by
+/// `test_wordlist_keyboard_layout`); kept as plain bytes so enabling/disabling keys does not
+/// have to read button texts back out of LVGL.
+const WORDLIST_ROW_LETTERS: [&[u8]; WORDLIST_ROWS] = [b"qwertyuiop", b"asdfghjkl", b"zxcvbnm"];
+
/// Builds a `'static` single-row buttonmatrix map: the given keys plus the required terminator.
macro_rules! key_row_map {
($name:ident, $count:literal, [$($key:expr),+ $(,)?]) => {
@@ -148,6 +167,28 @@ pub(super) fn gray() -> lvgl::LvColor {
lvgl::color::hex(0x777777)
}
+/// A set of lowercase letters `a`..=`z`, as a bitmask (bit 0 = `a`). Which wordlist-keyboard
+/// keys are currently enabled.
+#[derive(Clone, Copy, PartialEq, Eq, Default)]
+pub struct LetterSet(u32);
+
+impl LetterSet {
+ pub const EMPTY: Self = Self(0);
+
+ fn bit(letter: u8) -> u32 {
+ debug_assert!(letter.is_ascii_lowercase());
+ 1 << (letter - b'a')
+ }
+
+ pub fn insert(&mut self, letter: u8) {
+ self.0 |= Self::bit(letter);
+ }
+
+ pub fn contains(self, letter: u8) -> bool {
+ self.0 & Self::bit(letter) != 0
+ }
+}
+
#[derive(Clone, Copy)]
struct Mode {
caps: bool,
@@ -342,14 +383,16 @@ impl Preview {
}
/// Shows the balloon for the key `id` of `row` (labelled `text`), positioned over the key.
- fn show(&self, mode: Mode, row: usize, id: u32, text: &str) {
+ /// `count` is the row's current key count (shorter rows are centred, so it shifts key
+ /// positions).
+ fn show(&self, count: usize, row: usize, id: u32, text: &str) {
if self.shown.get() == Some((row, id)) {
return;
}
self.shown.set(Some((row, id)));
self.label.set_text(text).expect("key text contains no NUL");
self.root.set_pos(
- key_x(row_count(mode, row), id as usize) + PREVIEW_OFFSET_X,
+ key_x(count, id as usize) + PREVIEW_OFFSET_X,
key_y(row) + PREVIEW_OFFSET_Y,
);
self.root.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
@@ -394,6 +437,15 @@ fn style_key_row(matrix: &LvButtonmatrix) {
matrix.set_style_text_color(lvgl::color::white(), selector);
matrix.set_style_text_font(lvgl::fonts::INTER_BOLD_32, selector);
}
+
+ // Disabled keys (wordlist keyboard: letters that cannot extend the entry towards a valid
+ // word) are drawn gray. The default theme's disabled style would instead dim the key with a
+ // 50% grey recolor overlay; pin the recolor props so gray border/text is the whole
+ // difference (same reasoning as in `style_outline_button`).
+ matrix.set_style_border_color(gray(), ITEMS | DISABLED);
+ matrix.set_style_text_color(gray(), ITEMS | DISABLED);
+ matrix.set_style_recolor_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, ITEMS | DISABLED);
+ matrix.set_style_recolor(lvgl::color::black(), ITEMS | DISABLED);
}
/// A second handle to a key-row matrix, for use inside its own event callbacks.
@@ -405,14 +457,11 @@ fn matrix_handle(container: &LvObj, row: usize) -> LvButtonmatrix {
.expect("key row is a buttonmatrix")
}
-/// Builds the QWERTY keyboard as a `KEYBOARD_WIDTH`×`KEYBOARD_HEIGHT` container and appends it to
-/// `parent`. Typed characters are inserted into `textarea`.
-///
-/// Child order (see the `CHILD_INDEX_*` constants): `ROWS` key-row buttonmatrices, caps lock,
-/// space bar, symbols toggle, preview balloon.
-pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
+/// Builds the bare keyboard container: `KEYBOARD_WIDTH`×`height`, transparent, with the preview
+/// balloon's overhang beyond the top key row declared as ext draw size.
+fn build_container(parent: &LvObj, height: i32) -> LvObj {
let container = LvObj::with_parent(parent).unwrap();
- container.set_size(KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
+ container.set_size(KEYBOARD_WIDTH, height);
container.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
container.set_style_border_width(0, 0);
container.set_style_radius(0, 0);
@@ -423,7 +472,7 @@ pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
container.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
// The preview balloon overhangs the top row (by `PREVIEW_OVERHANG`) and the outermost
// columns (by 16px). OVERFLOW_VISIBLE alone is not enough: it only widens the children clip
- // rect to the container's own ext draw size, which is 0 for this plain container — so the
+ // rect to the container's own ext draw size, which is 0 for a plain container — so the
// overhang must also be declared as ext draw size or the balloon head gets clipped away.
container.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_OVERFLOW_VISIBLE);
unsafe extern "C" fn refresh_ext_draw_size_cb(event: *mut lvgl::ffi::lv_event_t) {
@@ -438,6 +487,102 @@ pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
);
lvgl::ffi::lv_obj_refresh_ext_draw_size(container.as_ptr());
}
+ container
+}
+
+/// Wires a key-row buttonmatrix's input behaviour: the jet-out preview over the pressed key
+/// while pressed (hidden again if the finger slides off it, which also discards the selection),
+/// insertion of the selected key's character into `textarea` on release over it (`CLICK_TRIG`),
+/// and discarding the selection once the interaction ends. `count_of_row` resolves the row's
+/// current key count (layouts with mode switching change it per call); `after_insert` runs
+/// after each inserted character (and only then — not on deletions or programmatic changes).
+fn wire_key_row<F>(
+ container: &LvObj,
+ row: usize,
+ textarea: &Rc<LvTextarea>,
+ preview: &Rc<Preview>,
+ count_of_row: F,
+ after_insert: Option<Rc<dyn Fn()>>,
+) where
+ F: Fn(usize) -> usize + Clone + 'static,
+{
+ let matrix = matrix_handle(container, row);
+ {
+ let matrix_cb = matrix_handle(container, row);
+ let textarea = Rc::clone(textarea);
+ let count_of_row = count_of_row.clone();
+ matrix
+ .add_event_cb(LvEventCode::LV_EVENT_VALUE_CHANGED, move || {
+ let id = matrix_cb.get_selected_button();
+ if (id as usize) < count_of_row(row)
+ && let Some(text) = matrix_cb.get_button_text(id)
+ {
+ let _ = textarea.add_text(text.to_str().expect("key text is ASCII"));
+ if let Some(after_insert) = &after_insert {
+ after_insert();
+ }
+ }
+ })
+ .expect("failed to register key callback");
+ }
+ for code in [
+ LvEventCode::LV_EVENT_PRESSED,
+ LvEventCode::LV_EVENT_PRESSING,
+ ] {
+ let matrix_cb = matrix_handle(container, row);
+ let preview = Rc::clone(preview);
+ let count_of_row = count_of_row.clone();
+ matrix
+ .add_event_cb(code, move || {
+ let id = matrix_cb.get_selected_button();
+ let count = count_of_row(row);
+ if (id as usize) < count {
+ // PRESSING fires every input period; skip the button-text lookup (which
+ // allocates) while the preview already shows this key.
+ if preview.shown.get() == Some((row, id)) {
+ return;
+ }
+ match matrix_cb.get_button_text(id) {
+ Some(text) => {
+ preview.show(count, row, id, text.to_str().expect("key text is ASCII"))
+ }
+ None => preview.hide(),
+ }
+ } else {
+ // The finger slid off the keys (e.g. into a row gap).
+ preview.hide();
+ }
+ })
+ .expect("failed to register key press callback");
+ }
+ for code in [
+ LvEventCode::LV_EVENT_RELEASED,
+ LvEventCode::LV_EVENT_PRESS_LOST,
+ ] {
+ let matrix_cb = matrix_handle(container, row);
+ let preview = Rc::clone(preview);
+ matrix
+ .add_event_cb(code, move || {
+ preview.hide();
+ // Discard the selection once the interaction ends (this callback runs after
+ // the class handler has fired VALUE_CHANGED for a legitimate click). LVGL
+ // keeps the lastly clicked key selected forever, and a press sliding in from
+ // a neighbouring key reaches the matrix without a PRESSED event (which is
+ // what re-derives the selection) but still gets RELEASED — a stale selection
+ // would type that key again.
+ matrix_cb.set_selected_button(lvgl::ffi::LV_BUTTONMATRIX_BUTTON_NONE);
+ })
+ .expect("failed to register key release callback");
+ }
+}
+
+/// Builds the QWERTY keyboard as a `KEYBOARD_WIDTH`×`KEYBOARD_HEIGHT` container and appends it to
+/// `parent`. Typed characters are inserted into `textarea`.
+///
+/// Child order (see the `CHILD_INDEX_*` constants): `ROWS` key-row buttonmatrices, caps lock,
+/// space bar, symbols toggle, preview balloon.
+pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
+ let container = build_container(parent, KEYBOARD_HEIGHT);
let mode = Rc::new(RefCell::new(Mode {
caps: false,
@@ -499,77 +644,18 @@ pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
// The preview balloon is created last so it draws above every key.
let preview = Rc::new(Preview::build(&container));
- // Key-row behaviour: preview over the pressed key while pressed (hidden again if the finger
- // slides off it, which also discards the selection); insert the selected key's character on
- // release over it (`CLICK_TRIG`, set in `apply_mode`).
- for (row, matrix) in key_rows.iter().enumerate() {
- {
- let matrix_cb = matrix_handle(&container, row);
- let mode = Rc::clone(&mode);
- let textarea = Rc::clone(&textarea);
- matrix
- .add_event_cb(LvEventCode::LV_EVENT_VALUE_CHANGED, move || {
- let id = matrix_cb.get_selected_button();
- if (id as usize) < row_count(*mode.borrow(), row)
- && let Some(text) = matrix_cb.get_button_text(id)
- {
- let _ = textarea.add_text(text.to_str().expect("key text is ASCII"));
- }
- })
- .expect("failed to register key callback");
- }
- for code in [
- LvEventCode::LV_EVENT_PRESSED,
- LvEventCode::LV_EVENT_PRESSING,
- ] {
- let matrix_cb = matrix_handle(&container, row);
- let mode = Rc::clone(&mode);
- let preview = Rc::clone(&preview);
- matrix
- .add_event_cb(code, move || {
- let mode = *mode.borrow();
- let id = matrix_cb.get_selected_button();
- if (id as usize) < row_count(mode, row) {
- // PRESSING fires every input period; skip the button-text lookup (which
- // allocates) while the preview already shows this key.
- if preview.shown.get() == Some((row, id)) {
- return;
- }
- match matrix_cb.get_button_text(id) {
- Some(text) => preview.show(
- mode,
- row,
- id,
- text.to_str().expect("key text is ASCII"),
- ),
- None => preview.hide(),
- }
- } else {
- // The finger slid off the keys (e.g. into a row gap).
- preview.hide();
- }
- })
- .expect("failed to register key press callback");
- }
- for code in [
- LvEventCode::LV_EVENT_RELEASED,
- LvEventCode::LV_EVENT_PRESS_LOST,
- ] {
- let matrix_cb = matrix_handle(&container, row);
- let preview = Rc::clone(&preview);
- matrix
- .add_event_cb(code, move || {
- preview.hide();
- // Discard the selection once the interaction ends (this callback runs after
- // the class handler has fired VALUE_CHANGED for a legitimate click). LVGL
- // keeps the lastly clicked key selected forever, and a press sliding in from
- // a neighbouring key reaches the matrix without a PRESSED event (which is
- // what re-derives the selection) but still gets RELEASED — a stale selection
- // would type that key again.
- matrix_cb.set_selected_button(lvgl::ffi::LV_BUTTONMATRIX_BUTTON_NONE);
- })
- .expect("failed to register key release callback");
- }
+ // Key-row behaviour (`CLICK_TRIG` is set in `apply_mode`); the key counts change with the
+ // active layout, so they are resolved through the mode cell on every event.
+ for row in 0..ROWS {
+ let mode = Rc::clone(&mode);
+ wire_key_row(
+ &container,
+ row,
+ &textarea,
+ &preview,
+ move |row| row_count(*mode.borrow(), row),
+ None,
+ );
}
let widgets = Rc::new(Widgets {
@@ -631,3 +717,111 @@ pub fn build_keyboard(parent: &LvObj, textarea: Rc<LvTextarea>) -> LvObj {
container
}
+
+/// Builds the letters-only keyboard for BIP39 wordlist (recovery word) entry and appends it to
+/// `parent`: the three lowercase QWERTY letter rows, without the digit row and the caps-lock /
+/// space / symbols function row — no digit, space, capital or special character occurs in a
+/// BIP39 word. Typed characters are inserted into `textarea`; the jet-out preview and
+/// slide-off-to-cancel behave as on the passphrase keyboard. All keys start enabled; the caller
+/// filters them with [`set_enabled_letters`].
+///
+/// `after_insert` runs after each letter inserted by a key (and only then — the entry screen
+/// autocompletes there, which it must not do for deletions and cannot do from a textarea
+/// `VALUE_CHANGED` callback, where mutating the textarea would recursively re-enter that
+/// callback).
+///
+/// Child order: `WORDLIST_ROWS` key-row buttonmatrices, preview balloon
+/// ([`WORDLIST_CHILD_INDEX_PREVIEW`]).
+pub fn build_wordlist_keyboard(
+ parent: &LvObj,
+ textarea: Rc<LvTextarea>,
+ after_insert: Rc<dyn Fn()>,
+) -> LvObj {
+ let container = build_container(parent, WORDLIST_KEYBOARD_HEIGHT);
+
+ for (row, letters) in WORDLIST_ROW_LETTERS.iter().enumerate() {
+ let matrix = LvButtonmatrix::new(&container).unwrap();
+ style_key_row(&matrix);
+ // The maps never change on this keyboard, so unlike `apply_mode` this runs only once.
+ matrix.set_map(row_map(
+ Mode {
+ caps: false,
+ symbols: false,
+ },
+ row + 1,
+ ));
+ // Insert on release (click), not press: while pressed, the jet-out preview shows the key
+ // under the finger, and sliding off the key aborts instead of typing. CLICK_TRIG does
+ // not gate the long-press repeat path — NO_REPEAT keeps a held key from firing
+ // VALUE_CHANGED every repeat period.
+ matrix.set_button_ctrl_all(LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_CLICK_TRIG);
+ matrix.set_button_ctrl_all(LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_NO_REPEAT);
+ let count = letters.len();
+ matrix.set_size(row_width(count), KEY_HEIGHT);
+ matrix.set_pos(row_x(count), key_y(row));
+ }
+
+ // The preview balloon is created last so it draws above every key.
+ let preview = Rc::new(Preview::build(&container));
+
+ for row in 0..WORDLIST_ROWS {
+ wire_key_row(
+ &container,
+ row,
+ &textarea,
+ &preview,
+ |row| WORDLIST_ROW_LETTERS[row].len(),
+ Some(Rc::clone(&after_insert)),
+ );
+ }
+
+ container
+}
+
+/// Applies the set of currently-valid letters to a keyboard built by
+/// [`build_wordlist_keyboard`]: keys outside `enabled` are grayed out and inert. LVGL never
+/// selects a `LV_BUTTONMATRIX_CTRL_DISABLED` button on press, so a disabled key can neither pop
+/// the preview nor insert its character.
+pub fn set_enabled_letters(keyboard: &LvObj, enabled: LetterSet) {
+ for (row, letters) in WORDLIST_ROW_LETTERS.iter().enumerate() {
+ let matrix = matrix_handle(keyboard, row);
+ for (id, letter) in letters.iter().enumerate() {
+ if enabled.contains(*letter) {
+ matrix.clear_button_ctrl(
+ id as u32,
+ LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_DISABLED,
+ );
+ } else {
+ matrix
+ .set_button_ctrl(id as u32, LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_DISABLED);
+ }
+ }
+ }
+}
+
+/// The wordlist-keyboard (row, key id) of `letter`, for tests.
+#[cfg(test)]
+pub(super) fn wordlist_letter_pos(letter: u8) -> (usize, usize) {
+ for (row, letters) in WORDLIST_ROW_LETTERS.iter().enumerate() {
+ if let Some(col) = letters.iter().position(|l| *l == letter) {
+ return (row, col);
+ }
+ }
+ panic!("not a wordlist keyboard letter: {}", letter as char);
+}
+
+/// Centre of wordlist-keyboard key (`row`, `col`), for tests.
+#[cfg(test)]
+pub(super) fn wordlist_key_center(row: usize, col: usize) -> (i32, i32) {
+ let count = WORDLIST_ROW_LETTERS[row].len();
+ (
+ key_x(count, col) + KEY_WIDTH / 2,
+ key_y(row) + KEY_HEIGHT / 2,
+ )
+}
+
+/// The letters of wordlist-keyboard row `row`, for tests (layout-sync check).
+#[cfg(test)]
+pub(super) fn wordlist_row_letters(row: usize) -> &'static [u8] {
+ WORDLIST_ROW_LETTERS[row]
+}
### src/rust/bitbox03/src/ui/menu.rs
@@ -2,7 +2,6 @@
use alloc::format;
-use bitbox_hal::ui::UserAbort;
use bitbox_lvgl::{
self as lvgl, LabelExt, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, ObjExt,
};
@@ -15,19 +14,17 @@ pub enum MenuAction {
Previous,
Next,
Select,
- Continue,
Cancel,
}
pub(super) enum MenuResult {
Selected(u8),
- Continue,
Cancel(usize),
}
-fn transparent_row(parent: &LvObj, height: i32) -> LvObj {
+pub(super) fn transparent_row(parent: &LvObj, width: i32, height: i32) -> LvObj {
let row = LvObj::with_parent(parent).unwrap();
- row.set_width(380);
+ row.set_width(width);
row.set_height(height);
row.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
row.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
@@ -45,8 +42,6 @@ pub fn build_menu_screen(
words: &[&str],
title: Option<&str>,
index: usize,
- select_word: bool,
- continue_on_last: bool,
responder: Responder<MenuAction>,
) -> LvObj {
assert!(!words.is_empty(), "menu requires at least one word");
@@ -90,7 +85,7 @@ pub fn build_menu_screen(
let can_go_previous = index > 0;
let can_go_next = index + 1 < words.len();
if can_go_previous || can_go_next {
- let navigation = transparent_row(&screen, 82);
+ let navigation = transparent_row(&screen, 380, 82);
// Keep Back on the left and Next on the right, whichever are present.
navigation.set_style_flex_main_place(
match (can_go_previous, can_go_next) {
@@ -127,39 +122,16 @@ pub fn build_menu_screen(
})
.expect("failed to register cancel callback");
- let show_continue = continue_on_last && index + 1 == words.len();
- if select_word || show_continue {
- let actions = transparent_row(&screen, 82);
- // Primary action sits on the right, under the Next button.
- actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_END, 0);
- if select_word {
- // Confirming the highlighted word.
- let select = build_nav_button(&actions, NavIcon::Confirm);
- select
- .add_click_cb(move || {
- responder.resolve(MenuAction::Select);
- })
- .expect("failed to register select callback");
- } else {
- // Advancing to the next step of the workflow.
- let cont = build_nav_button(&actions, NavIcon::Next);
- cont.add_click_cb(move || {
- responder.resolve(MenuAction::Continue);
- })
- .expect("failed to register continue callback");
- }
- }
+ let actions = transparent_row(&screen, 380, 82);
+ // The select action sits on the right, under the Next button.
+ actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_END, 0);
+ // Confirming the highlighted word.
+ let select = build_nav_button(&actions, NavIcon::Confirm);
+ select
+ .add_click_cb(move || {
+ responder.resolve(MenuAction::Select);
+ })
+ .expect("failed to register select callback");
screen
}
-
-pub(super) async fn confirm_recovery_words_cancel(
- ui: &mut impl bitbox_hal::ui::Ui,
-) -> Result<(), UserAbort> {
- ui.confirm(&bitbox_hal::ui::ConfirmParams {
- title: "Recovery\nwords",
- body: "Do you really\nwant to cancel?",
- ..Default::default()
- })
- .await
-}
### src/rust/bitbox03/src/ui/recovery_words.rs
@@ -0,0 +1,558 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! The recovery-words review screen: every word of the mnemonic on a single screen, numbered, in
+//! two equal columns (the first half of the words on the left, the second half on the right).
+//! Numbers are gray and right-aligned in their own sub-column; the words themselves are white.
+//!
+//! Numbers and words use different font sizes (medium 20 vs medium 32), whose line heights
+//! differ, so each column pairs two multi-line labels whose per-line advance is equalized via
+//! `text_line_space` and whose first baselines are aligned via `translate_y` — see
+//! [`build_recovery_words_screen`].
+
+use alloc::string::String;
+use core::fmt::Write as _;
+
+use bitbox_hal::ui::UserAbort;
+use bitbox_lvgl::{self as lvgl, LabelExt, LvFont, LvLabel, LvObj, LvOpacityLevel, ObjExt, fonts};
+use util::futures::completion::Responder;
+
+use super::keyboard::gray;
+use super::menu::transparent_row;
+use super::nav_button::{NavIcon, build_nav_button};
+
+/// What the user chose on the recovery-words screen.
+#[derive(Clone, Copy)]
+pub enum RecoveryWordsAction {
+ /// Advance to the next step of the workflow.
+ Continue,
+ /// Request to cancel the workflow (the caller asks for confirmation).
+ Cancel,
+}
+
+/// This screen narrows the standard 50px side padding to 20px: two columns of numbered words need
+/// the width (the width invariants are pinned by `test_widest_word_and_number_fit_their_columns`).
+const SIDE_PAD: i32 = 20;
+/// Content width: the 480px display minus the side padding.
+const CONTENT_WIDTH: i32 = 440;
+/// Width of each of the two columns; the 8px remainder separates them.
+const COLUMN_WIDTH: i32 = 216;
+/// Width of the number sub-column; fits "24" in the number font with room to spare.
+const NUMBER_WIDTH: i32 = 40;
+/// Horizontal gap between a number and its word.
+const NUMBER_WORD_GAP: i32 = 10;
+/// Vertical distance between successive rows (baseline to baseline). The word font's line height
+/// (38px) plus breathing room; 12 rows of 47px fit comfortably above the nav row.
+const ROW_ADVANCE: i32 = 47;
+
+const NUMBER_FONT: LvFont = fonts::INTER_MEDIUM_20;
+const WORD_FONT: LvFont = fonts::INTER_MEDIUM_32;
+
+/// Distance from the top of a line box to the baseline.
+fn baseline_from_top(font: LvFont) -> i32 {
+ font.line_height() - font.base_line()
+}
+
+/// A borderless multi-line label for one sub-column, spacing its lines `ROW_ADVANCE` apart.
+fn build_column_label(parent: &LvObj, text: &str, font: LvFont) -> LvLabel {
+ let label = LvLabel::new(parent).unwrap();
+ label.set_text(text).unwrap();
+ label.set_style_text_font(font, lvgl::LvState::LV_STATE_DEFAULT as u32);
+ label.set_style_text_line_space(ROW_ADVANCE - font.line_height(), 0);
+ label
+}
+
+/// One column: gray right-aligned numbers `first_number..` next to their white words.
+fn build_column(parent: &LvObj, words: &[&str], first_number: usize) {
+ let column = LvObj::with_parent(parent).unwrap();
+ column.set_width(COLUMN_WIDTH);
+ column.set_height(lvgl::ffi::LV_SIZE_CONTENT as i32);
+ column.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ column.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ column.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_START, 0);
+ column.set_style_pad_top(0, 0);
+ column.set_style_pad_bottom(0, 0);
+ column.set_style_pad_left(0, 0);
+ column.set_style_pad_right(0, 0);
+ column.set_style_pad_column(NUMBER_WORD_GAP, 0);
+ column.set_style_border_width(0, 0);
+ column.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ let mut numbers_text = String::new();
+ let mut words_text = String::new();
+ for (i, word) in words.iter().enumerate() {
+ if i > 0 {
+ numbers_text.push('\n');
+ words_text.push('\n');
+ }
+ write!(numbers_text, "{}", first_number + i).unwrap();
+ words_text.push_str(word);
+ }
+
+ let numbers = build_column_label(&column, &numbers_text, NUMBER_FONT);
+ numbers.set_width(NUMBER_WIDTH);
+ numbers.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_RIGHT, 0);
+ numbers.set_style_text_color(gray(), 0);
+ // The number font's first baseline sits higher in its (shorter) line box than the word font's;
+ // shift the whole label down so the two sub-columns share baselines on every row.
+ numbers.set_style_translate_y(
+ baseline_from_top(WORD_FONT) - baseline_from_top(NUMBER_FONT),
+ 0,
+ );
+
+ let word_label = build_column_label(&column, &words_text, WORD_FONT);
+ word_label.set_style_text_color(lvgl::color::white(), 0);
+ word_label.set_style_flex_grow(1, 0);
+}
+
+/// Builds the recovery-words review screen. The words are split in half between the two columns
+/// and numbered starting at 1. The navigation buttons sit exactly where the confirm screen puts
+/// them: the bottom-left Cancel button resolves [`RecoveryWordsAction::Cancel`], the
+/// bottom-right Next button resolves [`RecoveryWordsAction::Continue`].
+pub fn build_recovery_words_screen(
+ words: &[&str],
+ responder: Responder<RecoveryWordsAction>,
+) -> LvObj {
+ assert!(!words.is_empty(), "recovery words screen requires words");
+
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(SIDE_PAD, 0);
+ // Standard bottom padding (32px), so the navigation buttons sit at the same height as on
+ // the other workflow screens.
+ screen.set_style_pad_bottom(32, 0);
+ screen.set_style_pad_left(SIDE_PAD, 0);
+ screen.set_style_pad_row(24, 0);
+ // The navigation row below is narrower than the word columns; centre children so it lands
+ // where the standard 50px-padded screens put it.
+ screen.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+
+ let title = LvLabel::new(&screen).unwrap();
+ title.set_width(380);
+ title.set_text("Recovery words").unwrap();
+ title.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
+ title.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ // The two word columns, vertically centred in the space between the title and the
+ // navigation row.
+ let columns = LvObj::with_parent(&screen).unwrap();
+ columns.set_width(CONTENT_WIDTH);
+ columns.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ columns.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ columns.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN, 0);
+ columns.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ // Centres the (single) track of columns vertically; `flex_cross_place` alone does not move
+ // content along the cross axis of this grown container.
+ columns.set_style_flex_track_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ columns.set_style_flex_grow(1, 0);
+ // Nudge the word block 10px above the exact centre of the title/navigation gap.
+ columns.set_style_translate_y(-10, 0);
+ columns.set_style_pad_top(0, 0);
+ columns.set_style_pad_bottom(0, 0);
+ columns.set_style_pad_left(0, 0);
+ columns.set_style_pad_right(0, 0);
+ columns.set_style_border_width(0, 0);
+ columns.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ let rows = words.len().div_ceil(2);
+ let (left, right) = words.split_at(rows);
+ build_column(&columns, left, 1);
+ build_column(&columns, right, rows + 1);
+
+ // Cancel and Next as full-size navigation buttons, in the exact positions the confirm
+ // screen uses (cancel left, advance right, spread over the standard 380px content width —
+ // narrower than this screen's word columns).
+ let actions = transparent_row(&screen, 380, 82);
+ actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN, 0);
+
+ let cancel_responder = responder.clone();
+ let cancel = build_nav_button(&actions, NavIcon::Cancel);
+ cancel
+ .add_click_cb(move || {
+ cancel_responder.resolve(RecoveryWordsAction::Cancel);
+ })
+ .expect("failed to register cancel callback");
+
+ let next = build_nav_button(&actions, NavIcon::Next);
+ next.add_click_cb(move || {
+ responder.resolve(RecoveryWordsAction::Continue);
+ })
+ .expect("failed to register continue callback");
+
+ screen
+}
+
+/// Asks the user to confirm abandoning the recovery-words workflow.
+pub(super) async fn confirm_recovery_words_cancel(
+ ui: &mut impl bitbox_hal::ui::Ui,
+) -> Result<(), UserAbort> {
+ ui.confirm(&bitbox_hal::ui::ConfirmParams {
+ title: "Recovery\nwords",
+ body: "Do you really\nwant to cancel?",
+ ..Default::default()
+ })
+ .await
+}
+
+#[cfg(test)]
+mod tests {
+ extern crate std;
+
+ use core::pin::Pin;
+ use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
+
+ use alloc::ffi::CString;
+ use alloc::format;
+ use alloc::vec::Vec;
+ use bitbox_lvgl::{LvColor, LvPart, class, ffi};
+ use util::futures::completion;
+
+ use super::super::test_util::{ScriptedTouch, coords, lock_and_init, pump_for};
+ use super::*;
+
+ const WORDS_24: [&str; 24] = [
+ "wisdom", "spoil", "tilt", "grocery", "acoustic", "shoot", "engage", "asset", "wave",
+ "cinnamon", "provide", "sadness", "budget", "gravity", "vault", "boring", "sunset", "mule",
+ "found", "auto", "sponsor", "salon", "faint", "patrol",
+ ];
+
+ /// Polls a completion future once with a no-op waker.
+ fn poll_once<T>(result: &mut completion::Result<T>) -> Option<T> {
+ fn noop(_: *const ()) {}
+ fn clone(_: *const ()) -> RawWaker {
+ RawWaker::new(core::ptr::null(), &VTABLE)
+ }
+ static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
+ let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) };
+ let mut cx = Context::from_waker(&waker);
+ match Pin::new(result).poll(&mut cx) {
+ Poll::Ready(value) => Some(value),
+ Poll::Pending => None,
+ }
+ }
+
+ struct Harness {
+ touch: ScriptedTouch,
+ screen: LvObj,
+ result: completion::Result<RecoveryWordsAction>,
+ }
+
+ impl Harness {
+ fn new(words: &[&str]) -> Self {
+ let touch = ScriptedTouch::new();
+ let (responder, result) = completion::completion();
+ let screen = build_recovery_words_screen(words, responder);
+ unsafe { ffi::lv_screen_load(screen.as_ptr()) };
+ pump_for(60); // layout + first render
+ Self {
+ touch,
+ screen,
+ result,
+ }
+ }
+
+ fn columns(&self) -> LvObj {
+ self.screen.child(1).expect("columns container")
+ }
+
+ /// The numbers (`0`) or words (`1`) label of the left (`0`) or right (`1`) column.
+ fn label_text(&self, column: usize, label: usize) -> String {
+ let label = self
+ .columns()
+ .child(column as i32)
+ .expect("column")
+ .child(label as i32)
+ .expect("column label")
+ .try_downcast::<class::LabelTag>()
+ .expect("column child is a label");
+ String::from(label.get_text().unwrap().to_str().unwrap())
+ }
+
+ fn label_color(&self, column: usize, label: usize) -> LvColor {
+ let label = self
+ .columns()
+ .child(column as i32)
+ .expect("column")
+ .child(label as i32)
+ .expect("column label");
+ let value = unsafe {
+ ffi::lv_obj_get_style_prop(
+ label.as_ptr(),
+ LvPart::LV_PART_MAIN,
+ ffi::_lv_style_id_t::LV_STYLE_TEXT_COLOR as ffi::lv_style_prop_t,
+ )
+ };
+ unsafe { value.color }
+ }
+
+ fn cancel_button(&self) -> LvObj {
+ self.screen
+ .child(2)
+ .expect("actions row")
+ .child(0)
+ .expect("cancel button")
+ }
+
+ fn next_button(&self) -> LvObj {
+ self.screen
+ .child(2)
+ .expect("actions row")
+ .child(1)
+ .expect("next button")
+ }
+
+ fn tap(&mut self, button: &LvObj) {
+ let area = coords(button);
+ self.touch
+ .tap((area.x1 + area.x2) / 2, (area.y1 + area.y2) / 2);
+ }
+ }
+
+ impl Drop for Harness {
+ fn drop(&mut self) {
+ // Swap in a fresh empty screen so the tested screen can be deleted.
+ let blank = LvObj::new().unwrap();
+ unsafe {
+ ffi::lv_screen_load(blank.as_ptr());
+ }
+ pump_for(40);
+ unsafe { core::ptr::read(&self.screen).delete() };
+ }
+ }
+
+ /// The rendered width of `text` in `font`, in pixels.
+ fn text_width(text: &str, font: LvFont) -> i32 {
+ let text = CString::new(text).unwrap();
+ let mut size = ffi::lv_point_t { x: 0, y: 0 };
+ unsafe {
+ ffi::lv_text_get_size(
+ &mut size,
+ text.as_ptr(),
+ font.as_ptr(),
+ 0,
+ 0,
+ 10_000, // effectively unlimited: measure without wrapping
+ ffi::lv_text_flag_t::LV_TEXT_FLAG_NONE,
+ );
+ }
+ size.x
+ }
+
+ #[test]
+ fn test_split_numbering_and_colors() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24);
+
+ assert_eq!(
+ harness.label_text(0, 0),
+ (1..=12)
+ .map(|n| format!("{n}"))
+ .collect::<Vec<_>>()
+ .join("\n")
+ );
+ assert_eq!(
+ harness.label_text(1, 0),
+ (13..=24)
+ .map(|n| format!("{n}"))
+ .collect::<Vec<_>>()
+ .join("\n")
+ );
+ assert_eq!(harness.label_text(0, 1), WORDS_24[..12].join("\n"));
+ assert_eq!(harness.label_text(1, 1), WORDS_24[12..].join("\n"));
+
+ let expected_gray = gray();
+ for column in 0..2 {
+ let number_color = harness.label_color(column, 0);
+ assert_eq!(
+ (number_color.red, number_color.green, number_color.blue),
+ (expected_gray.red, expected_gray.green, expected_gray.blue)
+ );
+ let word_color = harness.label_color(column, 1);
+ assert_eq!(
+ (word_color.red, word_color.green, word_color.blue),
+ (0xff, 0xff, 0xff)
+ );
+ }
+ }
+
+ #[test]
+ fn test_12_words_split_in_half() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24[..12]);
+
+ assert_eq!(harness.label_text(0, 1), WORDS_24[..6].join("\n"));
+ assert_eq!(harness.label_text(1, 1), WORDS_24[6..12].join("\n"));
+ assert_eq!(harness.label_text(1, 0).lines().next(), Some("7"));
+ }
+
+ #[test]
+ fn test_next_resolves_continue() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(&WORDS_24);
+
+ assert!(poll_once(&mut harness.result).is_none());
+ let next = harness.next_button();
+ harness.tap(&next);
+ assert!(matches!(
+ poll_once(&mut harness.result).expect("next resolves"),
+ RecoveryWordsAction::Continue
+ ));
+ }
+
+ #[test]
+ fn test_cancel_resolves_cancel() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(&WORDS_24);
+
+ let cancel = harness.cancel_button();
+ harness.tap(&cancel);
+ assert!(matches!(
+ poll_once(&mut harness.result).expect("cancel resolves"),
+ RecoveryWordsAction::Cancel
+ ));
+ }
+
+ /// Every row must stay on one line: a wrapped word would shift all later rows and let the
+ /// user pair words with the wrong numbers. Check the whole BIP39 wordlist against the words
+ /// sub-column width, and every possible number against the number sub-column width.
+ #[test]
+ fn test_widest_word_and_number_fit_their_columns() {
+ let _lock = lock_and_init();
+
+ let words_width = COLUMN_WIDTH - NUMBER_WIDTH - NUMBER_WORD_GAP;
+ for word in bip39::Language::English.word_list() {
+ assert!(
+ text_width(word, WORD_FONT) <= words_width,
+ "{word} does not fit the words column"
+ );
+ }
+ for number in 1..=24 {
+ assert!(text_width(&format!("{number}"), NUMBER_FONT) <= NUMBER_WIDTH);
+ }
+ }
+
+ /// The 24-word layout must fit above the navigation row (no scrolling on a review screen).
+ #[test]
+ fn test_24_words_fit_above_navigation() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24);
+
+ let words_bottom = (0..2)
+ .map(|column| {
+ let label = harness
+ .columns()
+ .child(column)
+ .expect("column")
+ .child(1)
+ .expect("words label");
+ coords(&label).y2
+ })
+ .max()
+ .unwrap();
+ let actions = harness.screen.child(2).expect("actions row");
+ assert!(words_bottom < coords(&actions).y1);
+ }
+
+ /// The laid-out height of a `rows`-line label whose per-row advance is `ROW_ADVANCE`.
+ fn expected_label_height(rows: i32, font: LvFont) -> i32 {
+ (rows - 1) * ROW_ADVANCE + font.line_height()
+ }
+
+ /// Rows must stay level across the two sub-columns of the BUILT screen, even with every slot
+ /// holding the widest BIP39 word: both labels advance `ROW_ADVANCE` per row (no drift, and no
+ /// wrapped line — wrapping would inflate a label's height by a whole extra line), and the
+ /// numbers label sits exactly the baseline correction below the words label.
+ #[test]
+ fn test_rows_stay_level_at_widest_words() {
+ let _lock = lock_and_init();
+ let widest = ["mushroom"; 24];
+ let harness = Harness::new(&widest);
+
+ for column in 0..2 {
+ let column = harness.columns().child(column).expect("column");
+ let numbers = coords(&column.child(0).expect("numbers label"));
+ let words = coords(&column.child(1).expect("words label"));
+ assert_eq!(
+ numbers.y2 - numbers.y1 + 1,
+ expected_label_height(12, NUMBER_FONT)
+ );
+ assert_eq!(
+ words.y2 - words.y1 + 1,
+ expected_label_height(12, WORD_FONT)
+ );
+ assert_eq!(
+ numbers.y1 - words.y1,
+ baseline_from_top(WORD_FONT) - baseline_from_top(NUMBER_FONT)
+ );
+ }
+ }
+
+ /// The word block sits 10px above the vertical centre of the space between the title and the
+ /// navigation row (most visible with 12 words, where over half the area is slack).
+ #[test]
+ fn test_block_sits_10px_above_centre() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24[..12]);
+
+ let column = coords(&harness.columns().child(0).expect("left column"));
+ let title = coords(&harness.screen.child(0).expect("title"));
+ let actions = coords(&harness.screen.child(2).expect("actions row"));
+ let above = column.y1 - title.y2;
+ let below = actions.y1 - column.y2;
+ assert!(above > 100, "12-word screen should have plenty of slack");
+ assert!(
+ (below - above - 20).abs() <= 1,
+ "block should sit 10px above the centre (above {above}, below {below})"
+ );
+ }
+
+ /// Numbers right-align in their sub-column so all right edges line up (Inter digits are not
+ /// tabular: '1' is much narrower than '0').
+ #[test]
+ fn test_numbers_right_aligned() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24);
+
+ for column in 0..2 {
+ let numbers = harness
+ .columns()
+ .child(column)
+ .expect("column")
+ .child(0)
+ .expect("numbers label");
+ let value = unsafe {
+ ffi::lv_obj_get_style_prop(
+ numbers.as_ptr(),
+ LvPart::LV_PART_MAIN,
+ ffi::_lv_style_id_t::LV_STYLE_TEXT_ALIGN as ffi::lv_style_prop_t,
+ )
+ };
+ assert_eq!(
+ unsafe { value.num },
+ lvgl::LvTextAlign::LV_TEXT_ALIGN_RIGHT as i32
+ );
+ }
+ }
+
+ /// Cancel (bottom-left) and Next (bottom-right) sit exactly where the confirm screen puts
+ /// its reject/accept buttons: spread over the standard 380px content width (50px from the
+ /// display edges), flush above the standard 32px bottom padding.
+ #[test]
+ fn test_nav_buttons_match_confirm_screen_positions() {
+ let _lock = lock_and_init();
+ let harness = Harness::new(&WORDS_24);
+
+ let cancel = coords(&harness.cancel_button());
+ let next = coords(&harness.next_button());
+ assert_eq!(cancel.x1, 50);
+ assert_eq!(next.x2, 480 - 50 - 1);
+ assert_eq!(cancel.y2, 800 - 32 - 1);
+ assert_eq!(next.y2, 800 - 32 - 1);
+ }
+}
### src/rust/bitbox03/src/ui/status.rs
@@ -1,45 +1,55 @@
// SPDX-License-Identifier: Apache-2.0
use bitbox_lvgl::{
- self as lvgl, LabelExt, LvAlign, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, ObjExt,
+ self as lvgl, LabelExt, LvAlign, LvCanvas, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
+ ObjExt,
};
-pub(super) fn build_status_screen(title: &str, status_success: bool) -> LvObj {
+/// Checkmark shown inside the badge circle on success (white glyph on transparent).
+const SUCCESS_PNG: &[u8] = include_bytes!("../../icons/status_success.png");
+/// Cross shown inside the badge circle on failure/cancel (white glyph on transparent).
+const CANCEL_PNG: &[u8] = include_bytes!("../../icons/status_cancel.png");
+
+/// Diameter of the round status badge, in pixels (mockup viewBox).
+const BADGE_SIZE: i32 = 140;
+/// Stroke width of the badge circle (mockup stroke).
+const BADGE_BORDER_WIDTH: i32 = 5;
+
+pub fn build_status_screen(title: &str, status_success: bool) -> LvObj {
let screen = LvObj::new().unwrap();
screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
screen.set_style_bg_color(lvgl::color::black(), 0);
screen.set_style_text_color(lvgl::color::white(), 0);
- screen.set_style_pad_top(96, 0);
+ screen.set_style_pad_top(40, 0);
screen.set_style_pad_right(50, 0);
screen.set_style_pad_bottom(40, 0);
screen.set_style_pad_left(50, 0);
screen.set_style_pad_row(40, 0);
+ // Centre the badge + title block on the screen (equal top/bottom padding keeps it exact).
+ screen.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
screen.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
let badge = LvObj::with_parent(&screen).unwrap();
- badge.set_size(112, 112);
- badge.set_style_radius(56, 0);
- badge.set_style_bg_color(
- if status_success {
- lvgl::color::hex(0x0d8f4b)
- } else {
- lvgl::color::hex(0xb3261e)
- },
- 0,
- );
- badge.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- badge.set_style_border_width(0, 0);
-
- let badge_label = LvLabel::new(&badge).unwrap();
- badge_label
- .set_text(if status_success { "OK" } else { "ERR" })
- .unwrap();
- badge_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- badge_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+ badge.set_size(BADGE_SIZE, BADGE_SIZE);
+ badge.set_style_radius(lvgl::ffi::LV_RADIUS_CIRCLE as i32, 0);
+ badge.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ badge.set_style_border_width(BADGE_BORDER_WIDTH, 0);
+ badge.set_style_border_color(lvgl::color::white(), 0);
+
+ // `png_decoder` returns ARGB8888 pixels as RGBA; LVGL expects BGRA in memory.
+ let png = if status_success {
+ SUCCESS_PNG
+ } else {
+ CANCEL_PNG
+ };
+ let (header, mut data) = png_decoder::decode(png).expect("valid status icon png");
+ for px in data.iter_mut() {
+ px.swap(0, 2);
+ }
+ let glyph =
+ LvCanvas::new(&badge, data, header.width, header.height).expect("status icon canvas");
+ glyph.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
let title_label = LvLabel::new(&screen).unwrap();
title_label.set_width(380);
### src/ui/fonts/inter_medium_20.c
[binary or diff unavailable]
### src/ui/fonts/inter_medium_32.c
[binary or diff unavailable]
### test/simulator-graphical-bb03/Cargo.lock
@@ -545,6 +545,7 @@ dependencies = [
name = "bitbox03"
version = "0.1.0"
dependencies = [
+ "bip39",
"bitbox-hal",
"bitbox-lvgl",
"png-decoder",Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.