What changed, and why it matters
This commit adds new touchscreen keyboard and PIN keypad screens for the BitBox03 hardware wallet, along with changes to how secret text (passphrases, PINs) is read from the on-screen buffer. The most notable security-relevant change is a fix for how the device copies sensitive text: it now reads the LVGL text buffer directly into a zeroizing string, avoiding an intermediate non-zeroized copy that could leave secret characters in memory. The commit also hardens touch handling so that sliding a finger off a key before releasing does not type the key or confirm an action, and it clears stale key selections to prevent accidental double-typing. Most of the rest is UI layout, fonts, and simulator tooling.
Treat this as a UI feature merge with embedded security hardening. Review the new BitBox03 keyboard/keypad input paths for additional touch-race issues, ensure the zeroizing snapshot path is used consistently wherever secrets are read back from LVGL, and run the included simulator tests (`test_*` in `enter_string.rs`, `keyboard.rs`, `keypad.rs`, `slide_to_confirm.rs`) before release. No immediate incident response is indicated, but a focused security review of the new input widgets is warranted because they handle device unlock PIN and BIP39 passphrase entry.
Security signals we found
Sensitive text snapshotting changed to zeroize-on-drop and in-place LVGL buffer read, reducing plaintext secret copies in memory
Click callback helper now disables LVGL press-lock to prevent release-outside-object from triggering actions
Keyboard clears selected button on RELEASED/PRESS_LOST to avoid stale-selection re-typing on slide-in presses
Keyboard uses CLICK_TRIG and NO_REPEAT to insert on release and suppress long-press auto-repeat
Passphrase/PIN input capped at 149 characters for cross-device parity
Masked display reveals only the last entered character and re-masks after deletion
Backspace/confirm buttons disabled when input empty; disabled-state theme animation overridden to avoid visual flicker
Evidence from the diff
The merge introduces BitBox03-specific passphrase and PIN entry UIs. Security-relevant code changes include: (1) snapshot_text in enter_string.rs now uses zeroize::Zeroizing<String> and reads lv_textarea_get_text in place via CStr, avoiding the previous CString intermediate that was dropped without zeroing; helper functions textarea_is_empty and textarea_len_and_last also read in place to avoid copying secrets. (2) ObjExt::add_click_cb removes LV_OBJ_FLAG_PRESS_LOCK so releasing outside the originally pressed object does not fire a click, preventing slide-off confirmations. (3) The QWERTY keyboard sets CLICK_TRIG (insert on release), NO_REPEAT, and clears LV_BUTTONMATRIX_BUTTON_NONE on RELEASED/PRESS_LOST to stop stale selections from re-typing when a press slides in from a neighboring key. (4) A 149-character max length is enforced for passphrase parity with BitBox02. (5) The masked display reveals only the last entered character and re-masks on deletion. The commit is large (+2212/-202) and primarily UI/feature work; the security fixes are partial hardening rather than a complete audit of the new input path.
Changed components
src/rust/bitbox-hal/src/ui.rssrc/rust/bitbox-lvgl/src/widgets/obj.rssrc/rust/bitbox02-rust/src/workflow/password.rssrc/rust/bitbox02/src/hal/ui.rssrc/rust/bitbox03/src/ui/enter_string.rssrc/rust/bitbox03/src/ui/keyboard.rssrc/rust/bitbox03/src/ui/keypad.rssrc/rust/bitbox03/src/ui/nav_button.rstest/simulator-graphical-bb03/src/main.rsInspect captured patch +2212 / −202
### src/rust/bitbox-hal/src/ui.rs
@@ -52,6 +52,13 @@ pub struct EnterStringParams<'a> {
pub longtouch: bool,
pub cancel_is_backbutton: bool,
pub default_to_digits: bool,
+ /// The string being entered is a BIP39 passphrase. On the BitBox03 this renders a full
+ /// QWERTY keyboard screen with a tap-to-confirm checkmark; the BitBox02 ignores it.
+ pub passphrase: bool,
+ /// The string being entered is the device unlock password. On the BitBox03 this renders a
+ /// digits-only PIN keypad screen (titles show "PIN" instead of "password"); the BitBox02
+ /// ignores it and keeps its full keyboard.
+ pub pin: bool,
}
#[derive(Copy, Clone, Eq, PartialEq)]
### src/rust/bitbox-lvgl/src/widgets/obj.rs
@@ -187,10 +187,20 @@ pub trait ObjExt {
util::add_event_cb(self.as_ptr(), filter, cb)
}
+ /// Registers `cb` to run when the object is tapped, with standard tap semantics: sliding the
+ /// finger off the object aborts the tap, so releasing outside it does not click. LVGL's
+ /// default `LV_OBJ_FLAG_PRESS_LOCK` keeps a press attached to the originally pressed object
+ /// wherever the finger goes, which would deliver the click on any release; removing it makes
+ /// LVGL track the object under the finger instead, so leaving the object sends it
+ /// `LV_EVENT_PRESS_LOST` (clearing `LV_STATE_PRESSED`) and no click fires.
+ ///
+ /// Do not combine with widgets that track their own press/drag gesture (sliders, button
+ /// matrices): those rely on keeping the press, so wire them via [`ObjExt::add_event_cb`].
fn add_click_cb<F>(&self, cb: F) -> Result<(), LvEventRegistrationError>
where
F: FnMut() + 'static,
{
+ self.remove_flag(LvObjFlag::LV_OBJ_FLAG_PRESS_LOCK);
self.add_event_cb(crate::LvEventCode::LV_EVENT_CLICKED, cb)
}
### src/rust/bitbox02-rust/src/workflow/password.rs
@@ -50,6 +50,14 @@ pub async fn enter(
PasswordType::DevicePassword => false,
PasswordType::Bip39Passphrase => true,
},
+ passphrase: match password_type {
+ PasswordType::DevicePassword => false,
+ PasswordType::Bip39Passphrase => true,
+ },
+ pin: match password_type {
+ PasswordType::DevicePassword => true,
+ PasswordType::Bip39Passphrase => false,
+ },
longtouch: true,
default_to_digits: match password_type {
PasswordType::DevicePassword => {
### src/rust/bitbox02/src/hal/ui.rs
@@ -361,6 +361,9 @@ mod tests {
longtouch: true,
cancel_is_backbutton: true,
default_to_digits: true,
+ // BitBox03-only rendering hints; the BitBox02 conversion drops them.
+ passphrase: true,
+ pin: true,
};
let output_without_wordlist =
to_bitbox02_trinary_input_string_params(&input_without_wordlist);
@@ -383,6 +386,8 @@ mod tests {
longtouch: false,
cancel_is_backbutton: false,
default_to_digits: false,
+ passphrase: false,
+ pin: false,
};
let output_with_wordlist = to_bitbox02_trinary_input_string_params(&input_with_wordlist);
assert_eq!(output_with_wordlist.title, "Seed");
### src/rust/bitbox03/icons/capslock.png
[binary or diff unavailable]
### src/rust/bitbox03/icons/key_preview.png
[binary or diff unavailable]
### src/rust/bitbox03/src/ui.rs
@@ -15,10 +15,14 @@ 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 slide_to_confirm;
mod status;
+#[cfg(test)]
+mod test_util;
const LOGO: &[u8] = include_bytes!("../splash.png");
### src/rust/bitbox03/src/ui/choice.rs
@@ -58,8 +58,9 @@ pub(super) fn build_trinary_choice_screen(
title.set_width(380);
title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
title.set_text(message).unwrap();
+ title.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
title.set_style_flex_grow(1, 0);
### src/rust/bitbox03/src/ui/confirm.rs
@@ -43,8 +43,9 @@ pub fn build_confirm_screen(
title.set_width(380);
title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
title.set_text(params.title).unwrap();
+ title.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
### src/rust/bitbox03/src/ui/demo.rs
@@ -29,7 +29,7 @@ pub fn build_demo_screen(responder: Responder<()>) -> LvObj {
title.set_text("Navigation buttons").unwrap();
title.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
### src/rust/bitbox03/src/ui/enter_string.rs
@@ -1,30 +1,70 @@
// SPDX-License-Identifier: Apache-2.0
-use alloc::{rc::Rc, string::String};
+use alloc::{rc::Rc, string::String, vec::Vec};
use bitbox_hal::ui::{CanCancel, EnterStringParams, UserAbort};
use bitbox_lvgl::{
self as lvgl, KeyboardExt, LabelExt, LvAlign, LvButton, LvButtonmatrixCtrl, LvKeyboard,
LvKeyboardMapEntry, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, LvPart, LvTextarea,
- ObjExt, TextareaExt,
+ ObjExt, TextareaExt, class,
};
use util::futures::completion::Responder;
+use super::keyboard::build_keyboard;
+use super::keypad::build_keypad;
use super::nav_button::{NavIcon, build_close_button, build_nav_button};
use super::slide_to_confirm::build_slide_to_confirm;
-fn snapshot_text(textarea: &LvTextarea) -> String {
- textarea
- .get_text()
- .map(|text| {
- text.as_c_str()
- .to_str()
- .expect("textarea content must be valid UTF-8")
- .into()
- })
- .unwrap_or_default()
+/// Snapshots the (possibly secret) textarea content into a zeroized-on-drop string. Reads LVGL's
+/// buffer in place — `TextareaExt::get_text` would copy the content into an intermediate
+/// `CString` that is dropped without zeroizing. The single `push_str` into the empty string
+/// allocates exactly once, so no reallocation leaves an unzeroized copy behind either.
+fn snapshot_text(textarea: &LvTextarea) -> zeroize::Zeroizing<String> {
+ let mut snapshot = zeroize::Zeroizing::new(String::new());
+ let text = unsafe { lvgl::ffi::lv_textarea_get_text(textarea.as_ptr()) };
+ if !text.is_null() {
+ let text = unsafe { core::ffi::CStr::from_ptr(text) };
+ snapshot.push_str(text.to_str().expect("textarea content must be valid UTF-8"));
+ }
+ snapshot
}
+/// Whether the textarea is empty, read in place from LVGL's buffer — unlike [`snapshot_text`]
+/// this does not copy the (possibly secret) content to the heap.
+pub(super) fn textarea_is_empty(textarea: &LvTextarea) -> bool {
+ let text = unsafe { lvgl::ffi::lv_textarea_get_text(textarea.as_ptr()) };
+ text.is_null() || unsafe { *text == 0 }
+}
+
+/// The textarea's length in bytes and its last byte, read in place from LVGL's buffer without
+/// copying the (possibly secret) content to the heap. The passphrase keyboard only enters ASCII,
+/// so bytes are characters.
+fn textarea_len_and_last(textarea: &LvTextarea) -> (usize, Option<u8>) {
+ let text = unsafe { lvgl::ffi::lv_textarea_get_text(textarea.as_ptr()) };
+ if text.is_null() {
+ return (0, None);
+ }
+ let mut len = 0usize;
+ let mut last = 0u8;
+ loop {
+ let byte = unsafe { *text.add(len) } as u8;
+ if byte == 0 {
+ break;
+ }
+ last = byte;
+ len += 1;
+ }
+ (len, (len > 0).then_some(last))
+}
+
+/// Diameter of a circle masking one entered passphrase character.
+const MASK_DOT_SIZE: i32 = 24;
+/// Gap between masking circles.
+const MASK_DOT_GAP: i32 = 10;
+/// Masking circles shown at most (what fits the display row). The count saturates here: the
+/// circles are identical, and real passphrases are far shorter than the 149-character cap.
+const MASK_DOT_COUNT_MAX: usize = 10;
+
#[derive(Clone, Copy)]
enum KeyboardMode {
LowerCase,
@@ -245,33 +285,22 @@ where
button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
}
-pub fn build_enter_string_screen(
- params: &EnterStringParams<'_>,
- can_cancel: CanCancel,
- preset: &str,
- responder: Responder<Result<zeroize::Zeroizing<String>, UserAbort>>,
-) -> 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(40, 0);
- screen.set_style_pad_right(50, 0);
- screen.set_style_pad_bottom(32, 0);
- screen.set_style_pad_left(50, 0);
- screen.set_style_pad_row(20, 0);
-
- let title = LvLabel::new(&screen).unwrap();
+/// Adds the standard entry-screen title label (32px regular, standard content width, wrapping).
+fn add_title(screen: &LvObj, text: &str) {
+ let title = LvLabel::new(screen).unwrap();
title.set_width(380);
title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
- title.set_text(params.title).unwrap();
+ title.set_text(text).unwrap();
+ title.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
+}
- let textarea = LvTextarea::new(&screen).unwrap();
+/// Adds the standard entry text field (380×72; masked with `*` bullets when `hide`).
+fn add_textarea(screen: &LvObj, preset: &str, hide: bool) -> LvTextarea {
+ let textarea = LvTextarea::new(screen).unwrap();
textarea.set_size(380, 72);
textarea.set_one_line(true);
textarea
@@ -291,14 +320,348 @@ pub fn build_enter_string_screen(
lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
- if params.hide {
+ if hide {
textarea.set_password_mode(true);
textarea
.set_password_bullet("*")
.expect("valid password bullet");
textarea.set_password_show_time(0);
}
+ textarea
+}
+
+/// Adds the bottom actions row: standard content width, nav-button height, children spread to
+/// the edges.
+fn add_actions_row(screen: &LvObj) -> LvObj {
+ let actions = LvObj::with_parent(screen).unwrap();
+ actions.set_width(380);
+ actions.set_height(82);
+ actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN, 0);
+ actions.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ actions.set_style_pad_top(0, 0);
+ actions.set_style_pad_bottom(0, 0);
+ actions.set_style_pad_left(0, 0);
+ actions.set_style_pad_right(0, 0);
+ actions.set_style_border_width(0, 0);
+ actions.set_style_bg_opa(
+ LvOpacityLevel::LV_OPA_TRANSP as u8,
+ LvPart::LV_PART_MAIN as u32,
+ );
+ actions
+}
+
+/// The BIP39 passphrase entry screen: title, a masked entry display (LVGL-drawn circles plus the
+/// last entered character in plaintext), the full QWERTY keyboard component and a
+/// backspace/confirm navigation row.
+///
+/// Unlike the generic entry screen, accepting is a plain tap on the checkmark even though the
+/// passphrase params request `longtouch` (the workflow visually confirms the passphrase on a
+/// separate screen right after). Backspace lives in the navigation row (grayed out while the
+/// input is empty); with `CanCancel::Yes` (not used by the passphrase workflow, which cannot be
+/// cancelled) a corner close button rejects.
+pub fn build_passphrase_screen(
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ 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);
+
+ let textarea = add_masked_display(&screen, preset);
+
+ // The keyboard and the navigation row are anchored to the bottom of the screen (taken out
+ // of the flex flow), so the Back/Confirm buttons sit exactly where the other workflows put
+ // them — flush above the standard 32px bottom padding — with the keyboard right above,
+ // independent of how many lines the title wraps to.
+ let keyboard = build_keyboard(&screen, Rc::clone(&textarea));
+ keyboard.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ keyboard.align(LvAlign::LV_ALIGN_BOTTOM_MID, 0, -(82 + 20));
+
+ let actions = add_actions_row(&screen);
+ actions.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ actions.align(LvAlign::LV_ALIGN_BOTTOM_MID, 0, 0);
+
+ // The flex-flow stand-in for the floating keyboard and navigation row: it makes the growing
+ // entry display above end a standard gap over the keyboard.
+ add_bottom_region_spacer(&screen, super::keyboard::KEYBOARD_HEIGHT + 20 + 82);
+
+ // 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 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);
+ }
+ });
+ refresh_backspace();
+ let refresh_backspace_cb = Rc::clone(&refresh_backspace);
+ textarea
+ .add_event_cb(lvgl::LvEventCode::LV_EVENT_VALUE_CHANGED, move || {
+ refresh_backspace_cb()
+ })
+ .expect("failed to register textarea change callback");
+
+ 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(UserAbort)))
+ .expect("failed to register cancel callback");
+ }
+ let accept = build_nav_button(&actions, NavIcon::Confirm);
+ accept
+ .add_click_cb(move || {
+ responder.resolve(Ok(snapshot_text(textarea.as_ref())));
+ })
+ .expect("failed to register confirm 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.
+fn build_entry_screen_frame() -> 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.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ // Centring needs both alignments: CROSS centres items within their flex track (which is
+ // only as wide as the widest child), TRACK centres that track on the screen.
+ screen.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ screen.set_style_flex_track_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(0, 0);
+ screen.set_style_pad_bottom(32, 0);
+ screen.set_style_pad_left(0, 0);
+ screen.set_style_pad_row(20, 0);
+ screen
+}
+
+/// Adds the invisible flex-flow stand-in for a bottom-anchored (floating) input widget region,
+/// so the growing entry display above it ends a standard gap over that region.
+fn add_bottom_region_spacer(screen: &LvObj, height: i32) {
+ let spacer = LvObj::with_parent(screen).unwrap();
+ spacer.set_size(0, height);
+ spacer.set_style_border_width(0, 0);
+ spacer.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ spacer.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+}
+
+/// The device PIN entry screen ("PIN entry mockup"): title, the masked entry display and a
+/// numeric 3×4 keypad whose bottom row carries backspace, 0 and a tap confirm.
+///
+/// The keypad is bottom-anchored so its bottom row sits exactly where the other workflows put
+/// their navigation buttons. The BitBox03's device unlock secret is a numeric PIN, so titles
+/// show "PIN" where the (BitBox02-shared) workflow strings say "password", and the input
+/// accepts digits only. As on the passphrase screen, accepting is a plain tap despite the
+/// params' `longtouch`; with `CanCancel::Yes` (set/repeat PIN) a corner close button rejects.
+pub fn build_pin_screen(
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ responder: Responder<Result<zeroize::Zeroizing<String>, UserAbort>>,
+) -> LvObj {
+ let screen = build_entry_screen_frame();
+
+ add_title(&screen, ¶ms.title.replace("password", "PIN"));
+
+ let textarea = add_masked_display(&screen, preset);
+ textarea.set_accepted_chars(Some(c"0123456789"));
+
+ let confirm_textarea = Rc::clone(&textarea);
+ let confirm_responder = responder.clone();
+ let keypad = build_keypad(&screen, Rc::clone(&textarea), move || {
+ confirm_responder.resolve(Ok(snapshot_text(confirm_textarea.as_ref())));
+ });
+ keypad.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ keypad.align(LvAlign::LV_ALIGN_BOTTOM_MID, 0, 0);
+ add_bottom_region_spacer(&screen, super::keypad::KEYPAD_HEIGHT);
+
+ 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(UserAbort)))
+ .expect("failed to register cancel callback");
+ }
+
+ screen
+}
+
+/// Adds the masked entry display: a bare centred row of one filled circle per masked character —
+/// drawn as LVGL objects, since the ASCII-only fonts have no bullet glyph — with the last
+/// entered character in plaintext until the next keystroke (deleting re-masks everything). The
+/// row has `flex_grow`, so it fills and centres within the space the screen's flex flow leaves
+/// between the title and whatever follows.
+///
+/// Returns the invisible storage/event textarea (the display row's child 0) that the input
+/// widgets operate on.
+fn add_masked_display(screen: &LvObj, preset: &str) -> Rc<LvTextarea> {
+ let display = LvObj::with_parent(screen).unwrap();
+ display.set_size(380, 72);
+ // Fill the whole area between the title and the (bottom-anchored) keyboard, so the centred
+ // circle row sits in the middle of it; a spacer below reserves the keyboard/nav region,
+ // which the flex flow cannot see (those widgets are floating).
+ 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: its height is that of the tallest visible child, so without
+ // this the circles shift vertically whenever the last-character label appears or hides.
+ 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_pad_column(MASK_DOT_GAP, 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);
+ textarea.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ // BitBox02 parity: its entry buffer caps input at 149 characters (INPUT_STRING_MAX_SIZE).
+ // A longer passphrase entered here could never be retyped on a BitBox02, so apply the same
+ // limit.
+ textarea.set_max_length(149);
+ let textarea = Rc::new(textarea);
+
+ // The circle pool plus the plaintext label for the last entered character. The circle count
+ // saturates at what fits the row: the dots are identical, so a saturated display is
+ // indistinguishable from a scrolled one.
+ let mut dots = Vec::with_capacity(MASK_DOT_COUNT_MAX);
+ for _ in 0..MASK_DOT_COUNT_MAX {
+ let dot = LvObj::with_parent(&display).unwrap();
+ dot.set_size(MASK_DOT_SIZE, MASK_DOT_SIZE);
+ dot.set_style_radius(lvgl::ffi::LV_RADIUS_CIRCLE as i32, 0);
+ dot.set_style_border_width(0, 0);
+ dot.set_style_bg_color(lvgl::color::white(), 0);
+ dot.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ dot.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ // The theme's default padding makes the empty object "overflow", drawing scrollbar
+ // stubs into the circle.
+ unsafe {
+ lvgl::ffi::lv_obj_set_scrollbar_mode(
+ dot.as_ptr(),
+ lvgl::ffi::lv_scrollbar_mode_t::LV_SCROLLBAR_MODE_OFF,
+ );
+ }
+ dot.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ dots.push(dot);
+ }
+ let last_char_label = LvLabel::new(&display).unwrap();
+ last_char_label.set_style_text_color(lvgl::color::white(), 0);
+ last_char_label.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ // Fixed box height with the same (even) parity as the circles: the flex track is as tall as
+ // its tallest visible child, and centring an odd-height track floors differently from an
+ // even one — the font's natural 59px line height would nudge the circles by 1px whenever
+ // the label appears or hides.
+ last_char_label.set_height(60);
+ last_char_label.set_text("").unwrap();
+ last_char_label.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+
+ let display_textarea = Rc::clone(&textarea);
+ // Starts at MAX so the initial refresh never reveals a preset's last character.
+ let prev_len = core::cell::Cell::new(usize::MAX);
+ let refresh_display = Rc::new(move || {
+ let (len, last) = textarea_len_and_last(display_textarea.as_ref());
+ // Only a just-entered character is readable; any other change (deletion) re-masks.
+ let reveal = len > 0 && prev_len.get() < len;
+ prev_len.set(len);
+ let shown = core::cmp::min(if reveal { len - 1 } else { len }, MASK_DOT_COUNT_MAX);
+ for (i, dot) in dots.iter().enumerate() {
+ if i < shown {
+ dot.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ } else {
+ dot.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ }
+ }
+ match last.filter(|_| reveal) {
+ Some(ch) => {
+ let text = [ch];
+ last_char_label
+ .set_text(core::str::from_utf8(&text).expect("entered text is ASCII"))
+ .expect("entered text contains no NUL");
+ last_char_label.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ }
+ None => last_char_label.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN),
+ }
+ });
+ refresh_display();
+ let refresh_display_cb = Rc::clone(&refresh_display);
+ textarea
+ .add_event_cb(lvgl::LvEventCode::LV_EVENT_VALUE_CHANGED, move || {
+ refresh_display_cb()
+ })
+ .expect("failed to register entry display callback");
+
+ textarea
+}
+
+pub fn build_enter_string_screen(
+ params: &EnterStringParams<'_>,
+ can_cancel: CanCancel,
+ preset: &str,
+ responder: Responder<Result<zeroize::Zeroizing<String>, UserAbort>>,
+) -> LvObj {
+ if params.pin && params.wordlist.is_none() {
+ return build_pin_screen(params, can_cancel, preset, responder);
+ }
+ if params.passphrase && params.wordlist.is_none() && !params.number_input {
+ return build_passphrase_screen(params, can_cancel, preset, responder);
+ }
+
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ // All content fits (the keyboard shrinks via flex_grow); scrolling must stay off so a
+ // vertical wobble during the slide-to-confirm drag cannot turn into a scroll-steal.
+ screen.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
+ 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(50, 0);
+ screen.set_style_pad_bottom(32, 0);
+ screen.set_style_pad_left(50, 0);
+ screen.set_style_pad_row(20, 0);
+
+ add_title(&screen, params.title);
+
+ let textarea = add_textarea(&screen, preset, params.hide);
if params.number_input {
textarea.set_accepted_chars(Some(c"0123456789"));
}
@@ -309,7 +672,8 @@ pub fn build_enter_string_screen(
keyboard.set_width(380);
keyboard.set_height(260);
keyboard.set_style_flex_grow(1, 0);
- keyboard.set_style_margin_top(4, 0);
+ // 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 initial_keyboard_mode = if params.number_input {
@@ -328,6 +692,33 @@ pub fn build_enter_string_screen(
// Safe because the textarea and keyboard are siblings on the same screen and remain alive
// until the whole screen is popped.
unsafe { keyboard.set_textarea(Some(textarea.as_ref())) };
+ // Discard the key selection once an interaction ends (after the class handler has processed
+ // a legitimate click): LVGL keeps the lastly clicked key selected forever, and a press
+ // sliding in from the Delete/switch buttons below reaches the keyboard without a PRESSED
+ // event (which is what re-derives the selection) — a stale selection would retype that key
+ // via the long-press repeat path.
+ for code in [
+ lvgl::LvEventCode::LV_EVENT_RELEASED,
+ lvgl::LvEventCode::LV_EVENT_PRESS_LOST,
+ ] {
+ // A second handle to the keyboard, for the `'static` callback (the keyboard is screen
+ // child 2, after the title and the textarea).
+ let keyboard_cb = screen
+ .child(2)
+ .expect("keyboard")
+ .try_downcast::<class::KeyboardTag>()
+ .expect("screen child 2 is the keyboard");
+ keyboard
+ .add_event_cb(code, move || {
+ // Fully qualified: importing `ButtonmatrixExt` would make the keyboard's
+ // `set_map` calls above ambiguous with `KeyboardExt::set_map`.
+ lvgl::ButtonmatrixExt::set_selected_button(
+ &keyboard_cb,
+ lvgl::ffi::LV_BUTTONMATRIX_BUTTON_NONE,
+ );
+ })
+ .expect("failed to register keyboard release callback");
+ }
let input_controls = LvObj::with_parent(&screen).unwrap();
input_controls.set_width(380);
@@ -397,32 +788,15 @@ pub fn build_enter_string_screen(
.expect("failed to register cancel callback");
}
let slide = build_slide_to_confirm(&screen, move || {
- responder.resolve(Ok(zeroize::Zeroizing::new(snapshot_text(
- textarea.as_ref(),
- ))));
+ responder.resolve(Ok(snapshot_text(textarea.as_ref())));
});
slide.set_style_margin_top(8, 0);
return screen;
}
- let actions = LvObj::with_parent(&screen).unwrap();
- actions.set_width(380);
- actions.set_height(82);
- actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
- actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
- actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN, 0);
- actions.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
- actions.set_style_pad_top(0, 0);
- actions.set_style_pad_bottom(0, 0);
- actions.set_style_pad_left(0, 0);
- actions.set_style_pad_right(0, 0);
+ let actions = add_actions_row(&screen);
actions.set_style_pad_column(20, 0);
actions.set_style_margin_top(8, 0);
- actions.set_style_border_width(0, 0);
- actions.set_style_bg_opa(
- LvOpacityLevel::LV_OPA_TRANSP as u8,
- LvPart::LV_PART_MAIN as u32,
- );
if cancel_present {
// Cancel / Back is always a tap action -> icon button.
@@ -444,11 +818,776 @@ pub fn build_enter_string_screen(
let accept = build_nav_button(&actions, NavIcon::Confirm);
accept
.add_click_cb(move || {
- responder.resolve(Ok(zeroize::Zeroizing::new(snapshot_text(
- textarea.as_ref(),
- ))));
+ responder.resolve(Ok(snapshot_text(textarea.as_ref())));
})
.expect("failed to register confirm callback");
screen
}
+
+#[cfg(test)]
+mod tests {
+ extern crate std;
+
+ use core::pin::Pin;
+ use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
+
+ use bitbox_lvgl::{class, ffi};
+ use util::futures::completion;
+
+ use super::super::keyboard;
+ use super::super::test_util::{ScriptedTouch, coords, lock_and_init, pump_for};
+ use super::*;
+
+ fn passphrase_params() -> EnterStringParams<'static> {
+ EnterStringParams {
+ title: "Optional passphrase",
+ hide: true,
+ special_chars: true,
+ // The passphrase screen deliberately uses a tap confirm despite `longtouch`.
+ longtouch: true,
+ passphrase: true,
+ ..Default::default()
+ }
+ }
+
+ /// The device password params as `password::enter` builds them (the PIN screen renders the
+ /// title with "password" replaced by "PIN").
+ fn pin_params() -> EnterStringParams<'static> {
+ EnterStringParams {
+ title: "Enter password",
+ hide: true,
+ longtouch: true,
+ pin: true,
+ ..Default::default()
+ }
+ }
+
+ /// 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<Result<zeroize::Zeroizing<String>, UserAbort>>,
+ }
+
+ impl Harness {
+ fn with_params(params: &EnterStringParams<'_>, can_cancel: CanCancel) -> Self {
+ let touch = ScriptedTouch::new();
+ let (responder, result) = completion::completion();
+ let screen = build_enter_string_screen(params, can_cancel, "", responder);
+ unsafe { ffi::lv_screen_load(screen.as_ptr()) };
+ pump_for(60); // layout + first render
+ Self {
+ touch,
+ screen,
+ result,
+ }
+ }
+
+ fn new(can_cancel: CanCancel) -> Self {
+ Self::with_params(&passphrase_params(), can_cancel)
+ }
+
+ fn new_pin(can_cancel: CanCancel) -> Self {
+ Self::with_params(&pin_params(), can_cancel)
+ }
+
+ /// The PIN keypad container (screen child 2 on the PIN screen).
+ fn keypad(&self) -> LvObj {
+ self.screen.child(2).expect("keypad container")
+ }
+
+ /// Taps the PIN keypad key at grid position (`row`, `col`).
+ fn tap_pin_key(&mut self, row: usize, col: usize) {
+ let area = coords(&self.keypad());
+ let (x, y) = super::super::keypad::key_center(row, col);
+ self.touch.tap(area.x1 + x, area.y1 + y);
+ }
+
+ /// The title label's current text.
+ fn title_text(&self) -> String {
+ let title = self
+ .screen
+ .child(0)
+ .expect("title")
+ .try_downcast::<class::LabelTag>()
+ .expect("child 0 is the title label");
+ String::from(title.get_text().unwrap().to_str().unwrap())
+ }
+
+ /// The entry display row (screen child 1): hidden textarea, `MASK_DOT_COUNT_MAX`
+ /// circles, last-character label.
+ fn entry_display(&self) -> LvObj {
+ self.screen.child(1).expect("entry display")
+ }
+
+ fn textarea(&self) -> LvTextarea {
+ self.entry_display()
+ .child(0)
+ .expect("textarea")
+ .try_downcast::<class::TextareaTag>()
+ .expect("display child 0 is the textarea")
+ }
+
+ /// The number of masking circles currently shown.
+ fn shown_dots(&self) -> usize {
+ let display = self.entry_display();
+ (0..MASK_DOT_COUNT_MAX)
+ .filter(|i| {
+ let dot = display.child(1 + *i as i32).expect("masking dot");
+ !Self::hidden(&dot)
+ })
+ .count()
+ }
+
+ /// The last-character label's text, or `None` while it is hidden.
+ fn revealed_char(&self) -> Option<String> {
+ let label = self
+ .entry_display()
+ .child(1 + MASK_DOT_COUNT_MAX as i32)
+ .expect("last-character label");
+ if Self::hidden(&label) {
+ return None;
+ }
+ let label = label
+ .try_downcast::<class::LabelTag>()
+ .expect("last child is the label");
+ Some(String::from(label.get_text().unwrap().to_str().unwrap()))
+ }
+
+ fn text(&self) -> zeroize::Zeroizing<String> {
+ snapshot_text(&self.textarea())
+ }
+
+ fn keyboard(&self) -> LvObj {
+ self.screen.child(2).expect("keyboard container")
+ }
+
+ /// Absolute screen coordinates for a point given in keyboard-container coordinates.
+ fn on_keyboard(&self, (x, y): (i32, i32)) -> (i32, i32) {
+ let area = coords(&self.keyboard());
+ (area.x1 + x, area.y1 + y)
+ }
+
+ fn tap_char_key(&mut self, symbols: bool, caps: bool, row: usize, col: usize) {
+ let (x, y) = self.on_keyboard(keyboard::char_key_center(symbols, caps, row, col));
+ self.touch.tap(x, y);
+ }
+
+ fn tap_capslock(&mut self) {
+ let (x, y) = self.on_keyboard(keyboard::capslock_center());
+ self.touch.tap(x, y);
+ }
+
+ fn tap_symbols(&mut self) {
+ let (x, y) = self.on_keyboard(keyboard::symbols_center());
+ self.touch.tap(x, y);
+ }
+
+ fn tap_space(&mut self) {
+ let (x, y) = self.on_keyboard(keyboard::space_center());
+ self.touch.tap(x, y);
+ }
+
+ fn actions(&self) -> LvObj {
+ self.screen.child(3).expect("actions row")
+ }
+
+ fn backspace(&self) -> LvObj {
+ self.actions().child(0).expect("backspace button")
+ }
+
+ fn confirm(&self) -> LvObj {
+ self.actions().child(1).expect("confirm button")
+ }
+
+ fn tap_button(&mut self, button: &LvObj) {
+ let area = coords(button);
+ self.touch
+ .tap((area.x1 + area.x2) / 2, (area.y1 + area.y2) / 2);
+ }
+
+ fn preview(&self) -> LvObj {
+ self.keyboard()
+ .child(keyboard::CHILD_INDEX_PREVIEW)
+ .expect("preview balloon")
+ }
+
+ fn hidden(obj: &LvObj) -> bool {
+ unsafe { ffi::lv_obj_has_flag(obj.as_ptr(), lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN) }
+ }
+
+ fn disabled(obj: &LvObj) -> bool {
+ unsafe { ffi::lv_obj_has_state(obj.as_ptr(), lvgl::LvState::LV_STATE_DISABLED) }
+ }
+
+ fn pressed(obj: &LvObj) -> bool {
+ unsafe { ffi::lv_obj_has_state(obj.as_ptr(), lvgl::LvState::LV_STATE_PRESSED) }
+ }
+
+ /// The resolved whole-widget recolor opacity (`LV_STYLE_RECOLOR_OPA`) in the object's
+ /// current state.
+ fn recolor_opa(obj: &LvObj) -> u8 {
+ let value = unsafe {
+ ffi::lv_obj_get_style_prop(
+ obj.as_ptr(),
+ LvPart::LV_PART_MAIN,
+ ffi::_lv_style_id_t::LV_STYLE_RECOLOR_OPA as ffi::lv_style_prop_t,
+ )
+ };
+ unsafe { value.num as u8 }
+ }
+ }
+
+ 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() };
+ }
+ }
+
+ #[test]
+ fn test_types_lowercase_and_digits() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ harness.tap_char_key(false, false, 1, 0); // q
+ harness.tap_char_key(false, false, 2, 0); // a
+ harness.tap_char_key(false, false, 3, 6); // m
+ harness.tap_char_key(false, false, 0, 9); // 0
+ harness.tap_space();
+
+ assert_eq!(harness.text().as_str(), "qam0 ");
+ }
+
+ #[test]
+ fn test_capslock_toggles_case() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ harness.tap_capslock();
+ harness.tap_char_key(false, true, 1, 0); // Q
+ harness.tap_char_key(false, true, 0, 0); // digits are unaffected by caps
+ harness.tap_capslock();
+ harness.tap_char_key(false, false, 1, 0); // q
+
+ assert_eq!(harness.text().as_str(), "Q1q");
+ }
+
+ #[test]
+ fn test_symbols_layout() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ harness.tap_symbols();
+ harness.tap_char_key(true, false, 1, 0); // !
+ harness.tap_char_key(true, false, 2, 1); // ,
+ harness.tap_char_key(true, false, 3, 9); // }
+ harness.tap_char_key(true, false, 0, 0); // the digit row stays on the symbols layout
+ // Caps lock is inert on the symbols layout.
+ assert!(Harness::disabled(
+ &harness
+ .keyboard()
+ .child(keyboard::CHILD_INDEX_CAPSLOCK)
+ .unwrap()
+ ));
+ harness.tap_capslock();
+ harness.tap_char_key(true, false, 1, 1); // still ", not W
+ harness.tap_symbols();
+ harness.tap_char_key(false, false, 1, 1); // w: back to (lowercase) letters
+
+ assert_eq!(harness.text().as_str(), "!,}1\"w");
+ }
+
+ #[test]
+ fn test_backspace_deletes_and_disables_when_empty() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ assert!(Harness::disabled(&harness.backspace()));
+ harness.tap_char_key(false, false, 1, 0); // q
+ harness.tap_char_key(false, false, 1, 1); // w
+ assert!(!Harness::disabled(&harness.backspace()));
+
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "q");
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ assert!(Harness::disabled(&harness.backspace()));
+
+ // Tapping the disabled button is inert.
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ }
+
+ /// Enabling/disabling a button must swap its whole look in a single style update. The
+ /// default theme dims disabled widgets with a 50% grey whole-widget recolor and animates
+ /// `RECOLOR`/`RECOLOR_OPA` on state changes with a delay — left in place, that overlay lands
+ /// ~150ms after the gray border/icon colors snap in, so the button visibly flickers
+ /// (regression test for the `style_outline_button` disabled-state override).
+ #[test]
+ fn test_disable_enable_is_not_animated() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Canary for the assumption the zero-assertions below rest on: the default theme dims a
+ // disabled widget with a (delayed) 50% whole-widget recolor. An unstyled button must
+ // show that overlay once the transition has settled — if an LVGL bump changes the
+ // mechanism, fail loudly here instead of letting the assertions below pass vacuously.
+ let canary = LvButton::new(&harness.screen).unwrap().to_obj();
+ canary.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ canary.add_state(lvgl::LvState::LV_STATE_DISABLED);
+ pump_for(300);
+ assert_eq!(
+ Harness::recolor_opa(&canary),
+ LvOpacityLevel::LV_OPA_50 as u8
+ );
+
+ // Backspace: enabled by typing, disabled again by deleting the only character. The tap
+ // pumps past the theme transition's 70ms delay, so with the transition in effect the
+ // overlay would already be fading in here; after a further 300ms it would be fully on.
+ let backspace = harness.backspace();
+ harness.tap_char_key(false, false, 1, 0); // q
+ harness.tap_button(&backspace);
+ assert!(Harness::disabled(&backspace));
+ assert_eq!(Harness::recolor_opa(&backspace), 0);
+ pump_for(300);
+ assert_eq!(Harness::recolor_opa(&backspace), 0);
+
+ // Caps lock: disabled by switching to the symbols layout.
+ let capslock = harness
+ .keyboard()
+ .child(keyboard::CHILD_INDEX_CAPSLOCK)
+ .expect("caps lock");
+ harness.tap_symbols();
+ assert!(Harness::disabled(&capslock));
+ assert_eq!(Harness::recolor_opa(&capslock), 0);
+ pump_for(300);
+ assert_eq!(Harness::recolor_opa(&capslock), 0);
+
+ // Re-enabling must not fade the overlay back out either.
+ harness.tap_symbols();
+ assert!(!Harness::disabled(&capslock));
+ assert_eq!(Harness::recolor_opa(&capslock), 0);
+ pump_for(300);
+ assert_eq!(Harness::recolor_opa(&capslock), 0);
+ }
+
+ #[test]
+ fn test_confirm_resolves_with_text() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ harness.tap_char_key(false, false, 1, 0); // q
+ harness.tap_capslock();
+ harness.tap_char_key(false, true, 1, 1); // W
+ assert!(poll_once(&mut harness.result).is_none());
+
+ 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(), "qW");
+ }
+
+ #[test]
+ fn test_confirm_empty_passphrase_allowed() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ 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(), "");
+ }
+
+ #[test]
+ fn test_press_preview_jets_out() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ let preview = harness.preview();
+ assert!(Harness::hidden(&preview));
+
+ // Press and hold 'w' (row 1, col 1): the preview balloon pops up over the key.
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(false, false, 1, 1));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(!Harness::hidden(&preview));
+ // The balloon straddles the pressed key: horizontally centred on it, its head reaching
+ // into the row above and its stem covering the key.
+ let (key_center_x, key_center_y) = (x, y); // the tap targeted the key centre
+ let balloon = coords(&preview);
+ // `x2` is inclusive (x1 + width - 1), so round the centre up.
+ assert_eq!(
+ (balloon.x1 + balloon.x2 + 1) / 2,
+ key_center_x,
+ "balloon not centred on the key"
+ );
+ let key_top = key_center_y - 30;
+ let key_bottom = key_center_y + 30;
+ assert!(balloon.y1 < key_top - 60, "balloon head not above the key");
+ assert!(
+ balloon.y2 >= key_bottom,
+ "balloon stem does not cover the key"
+ );
+ // The preview shows the pressed character.
+ 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(), "w");
+
+ harness.touch.push(x, y, false);
+ pump_for(120);
+ assert!(Harness::hidden(&preview));
+ assert_eq!(harness.text().as_str(), "w");
+ }
+
+ #[test]
+ fn test_masking_shows_last_character() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Every character but the most recently entered one is masked by a circle; the last one
+ // stays readable until the next keystroke.
+ assert_eq!(harness.shown_dots(), 0);
+ assert_eq!(harness.revealed_char(), None);
+ harness.tap_char_key(false, false, 1, 0); // q
+ assert_eq!(harness.shown_dots(), 0);
+ assert_eq!(harness.revealed_char().as_deref(), Some("q"));
+ harness.tap_char_key(false, false, 1, 1); // w
+ assert_eq!(harness.shown_dots(), 1);
+ assert_eq!(harness.revealed_char().as_deref(), Some("w"));
+ let first_dot = harness.entry_display().child(1).expect("first dot");
+ let dot_before = coords(&first_dot);
+ harness.tap_char_key(false, false, 0, 0); // 1
+ assert_eq!(harness.shown_dots(), 2);
+ assert_eq!(harness.revealed_char().as_deref(), Some("1"));
+ assert_eq!(harness.text().as_str(), "qw1");
+
+ // Deleting re-masks everything (the deleted character was the last one entered).
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.shown_dots(), 2);
+ assert_eq!(harness.revealed_char(), None);
+ assert_eq!(harness.text().as_str(), "qw");
+
+ // The circles must not shift vertically when the last-character label hides (the flex
+ // track shrinks to the tallest visible child; the track must stay centred).
+ let dot_after = coords(&first_dot);
+ assert_eq!(
+ (dot_before.y1, dot_before.y2),
+ (dot_after.y1, dot_after.y2),
+ "masking circles moved vertically"
+ );
+ }
+
+ #[test]
+ fn test_holding_a_key_types_once() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Hold 'w' well past LVGL's long-press threshold (400ms) and repeat period (100ms): the
+ // NO_REPEAT ctrl bit must keep the buttonmatrix from auto-repeating into the masked
+ // input; exactly one character is inserted, on release.
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(false, false, 1, 1));
+ 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(), "w");
+ }
+
+ #[test]
+ fn test_close_button_rejects_when_cancellable() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::Yes);
+
+ // With CanCancel::Yes the screen carries a corner close button (screen child after the
+ // actions row and the keyboard-region spacer) that rejects with UserAbort.
+ 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!(result.is_err(), "close button must reject");
+ }
+
+ #[test]
+ fn test_preview_overhang_not_clipped() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Press '5' on the digit row: the balloon head reaches above the keyboard container.
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(false, false, 0, 4));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ let preview = harness.preview();
+ assert!(!Harness::hidden(&preview));
+ let container = coords(&harness.keyboard());
+ let balloon = coords(&preview);
+ let overhang = container.y1 - balloon.y1;
+ assert!(
+ overhang > 0,
+ "digit-row preview must overhang the container"
+ );
+ // OVERFLOW_VISIBLE only widens the children clip rect by the container's ext draw size,
+ // so the overhang must be declared there or the balloon head is clipped away. Query it
+ // the way LVGL does: fire REFR_EXT_DRAW_SIZE with an i32 param the handlers max() into.
+ let mut ext_draw_size: i32 = 0;
+ unsafe {
+ ffi::lv_obj_send_event(
+ harness.keyboard().as_ptr(),
+ lvgl::LvEventCode::LV_EVENT_REFR_EXT_DRAW_SIZE,
+ (&mut ext_draw_size as *mut i32).cast(),
+ );
+ }
+ assert!(
+ ext_draw_size >= overhang,
+ "container ext draw size {ext_draw_size} does not cover the preview overhang {overhang}"
+ );
+ }
+
+ #[test]
+ fn test_slide_off_key_cancels() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Press 'w', slide off it (onto 'e'), release: the buttonmatrix discards its selection
+ // when the pointer leaves the pressed key, so the preview hides and nothing is typed.
+ let preview = harness.preview();
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(false, false, 1, 1));
+ let (x_next, _) = harness.on_keyboard(keyboard::char_key_center(false, false, 1, 2));
+ 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_slide_from_space_onto_previous_key_does_not_type() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ // Type 'x' with a normal tap; the buttonmatrix must not keep it armed afterwards.
+ harness.tap_char_key(false, false, 3, 1);
+ assert_eq!(harness.text().as_str(), "x");
+
+ // Press the space bar, slide along it until under 'x', then up into the key row
+ // directly over 'x', and release there. The press migrates onto the row without a
+ // PRESSED event, so a stale selection from the earlier tap must not fire: neither
+ // space nor 'x' may be typed.
+ let (space_x, space_y) = harness.on_keyboard(keyboard::space_center());
+ let (key_x, key_y) = harness.on_keyboard(keyboard::char_key_center(false, false, 3, 1));
+ harness.touch.push(space_x, space_y, true);
+ harness.touch.push(space_x, space_y, true);
+ harness.touch.push(key_x, space_y, true); // still on the space bar, under 'x'
+ harness.touch.push(key_x, key_y, true); // entered the key row directly over 'x'
+ harness.touch.push(key_x, key_y, true);
+ harness.touch.push(key_x, key_y, false);
+ pump_for(280);
+ assert_eq!(harness.text().as_str(), "x");
+ }
+
+ #[test]
+ fn test_preview_tracks_modes() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new(CanCancel::No);
+
+ harness.tap_capslock();
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(false, true, 1, 1));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ let label = harness
+ .preview()
+ .child(1)
+ .expect("preview label")
+ .try_downcast::<class::LabelTag>()
+ .expect("preview label class");
+ assert_eq!(label.get_text().unwrap().to_str().unwrap(), "W");
+ harness.touch.push(x, y, false);
+ pump_for(120);
+
+ harness.tap_capslock();
+ harness.tap_symbols();
+ let (x, y) = harness.on_keyboard(keyboard::char_key_center(true, false, 3, 0));
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert_eq!(label.get_text().unwrap().to_str().unwrap(), "?");
+ }
+
+ #[test]
+ fn test_pin_types_digits_and_confirms() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new_pin(CanCancel::No);
+
+ // Workflow titles say "password"; the PIN screen renders them with "PIN".
+ assert_eq!(harness.title_text(), "Enter PIN");
+
+ harness.tap_pin_key(0, 0); // 1
+ harness.tap_pin_key(1, 1); // 5
+ harness.tap_pin_key(2, 2); // 9
+ harness.tap_pin_key(3, 1); // 0
+ assert_eq!(harness.text().as_str(), "1590");
+ // The masked display shows circles plus the last entered digit.
+ assert_eq!(harness.shown_dots(), 3);
+ assert_eq!(harness.revealed_char().as_deref(), Some("0"));
+
+ assert!(poll_once(&mut harness.result).is_none());
+ harness.tap_pin_key(3, 2); // confirm
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "1590");
+ }
+
+ #[test]
+ fn test_pin_backspace_deletes_and_disables_when_empty() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new_pin(CanCancel::No);
+
+ let backspace = harness.keypad().child(9).expect("backspace key");
+ assert!(Harness::disabled(&backspace));
+
+ harness.tap_pin_key(0, 1); // 2
+ harness.tap_pin_key(0, 2); // 3
+ assert!(!Harness::disabled(&backspace));
+
+ harness.tap_pin_key(3, 0); // backspace
+ assert_eq!(harness.text().as_str(), "2");
+ harness.tap_pin_key(3, 0);
+ assert_eq!(harness.text().as_str(), "");
+ assert!(Harness::disabled(&backspace));
+
+ // Tapping the disabled key is inert.
+ harness.tap_pin_key(3, 0);
+ assert_eq!(harness.text().as_str(), "");
+ }
+
+ #[test]
+ fn test_pin_key_slide_off_does_not_type() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new_pin(CanCancel::No);
+
+ // Press '5', slide off the key into the grid gap, release: leaving the key clears its
+ // pressed state (LV_EVENT_PRESS_LOST) and the release must not type.
+ let area = coords(&harness.keypad());
+ let (x, y) = super::super::keypad::key_center(1, 1);
+ let (x, y) = (area.x1 + x, area.y1 + y);
+ let key = harness.keypad().child(4).expect("digit key 5");
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(Harness::pressed(&key));
+
+ // Two steps to the right: past the key edge (41px half-width), into the 50px gap.
+ harness.touch.push(x + 30, y, true);
+ harness.touch.push(x + 60, y, true);
+ pump_for(120);
+ assert!(!Harness::pressed(&key));
+
+ harness.touch.push(x + 60, y, false);
+ pump_for(120);
+ assert_eq!(harness.text().as_str(), "");
+
+ // Sliding back onto the key before releasing does not re-arm it either: once the press
+ // left the key, that tap is abandoned for good (same as the keyboard's character keys).
+ harness.touch.push(x, y, true);
+ harness.touch.push(x + 60, y, true);
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, false);
+ pump_for(200);
+ assert_eq!(harness.text().as_str(), "");
+
+ // A regular tap still types.
+ harness.tap_pin_key(1, 1);
+ assert_eq!(harness.text().as_str(), "5");
+ }
+
+ #[test]
+ fn test_pin_confirm_slide_off_does_not_resolve() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new_pin(CanCancel::No);
+
+ harness.tap_pin_key(0, 0); // 1
+
+ // Press the confirm key, slide off it, release: the workflow must not resolve.
+ let area = coords(&harness.keypad());
+ let (x, y) = super::super::keypad::key_center(3, 2);
+ let (x, y) = (area.x1 + x, area.y1 + y);
+ let key = harness.keypad().child(11).expect("confirm key");
+ harness.touch.push(x, y, true);
+ harness.touch.push(x, y, true);
+ pump_for(120);
+ assert!(Harness::pressed(&key));
+ harness.touch.push(x + 30, y, true);
+ harness.touch.push(x + 60, y, true);
+ harness.touch.push(x + 60, y, false);
+ pump_for(240);
+ assert!(poll_once(&mut harness.result).is_none());
+
+ // A regular tap on confirm still resolves.
+ harness.tap_pin_key(3, 2);
+ let result = poll_once(&mut harness.result).expect("confirm resolves");
+ let Ok(text) = result else {
+ panic!("unexpected abort")
+ };
+ assert_eq!(text.as_str(), "1");
+ }
+
+ #[test]
+ fn test_pin_close_button_rejects() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::new_pin(CanCancel::Yes);
+
+ // Children on the PIN screen: title, display, keypad, spacer, corner close button.
+ let close = harness.screen.child(4).expect("corner close button");
+ let area = coords(&close);
+ harness
+ .touch
+ .tap((area.x1 + area.x2) / 2, (area.y1 + area.y2) / 2);
+ let result = poll_once(&mut harness.result).expect("close resolves");
+ assert!(result.is_err(), "close must reject with UserAbort");
+ }
+}
### src/rust/bitbox03/src/ui/keyboard.rs
@@ -0,0 +1,633 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! On-screen QWERTY keyboard used for BIP39 passphrase 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.
+//!
+//! 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
+//! builtin memory pool (`LV_MEM_SIZE`).
+
+use alloc::rc::Rc;
+use alloc::vec;
+use core::cell::{Cell, RefCell};
+
+use bitbox_lvgl::{
+ self as lvgl, ButtonmatrixExt, LabelExt, LvButton, LvButtonmatrix, LvButtonmatrixCtrl,
+ LvButtonmatrixMapEntry, LvCanvas, LvEventCode, LvLabel, LvObj, LvOpacityLevel, LvPart, LvState,
+ LvTextarea, ObjExt, TextareaExt,
+};
+
+use super::nav_button::{add_icon, enable_press_invert, style_outline_button};
+
+/// Character-key size (mockup: 40×60 keys in a 45px-pitch grid with 5px gaps).
+const KEY_WIDTH: i32 = 40;
+const KEY_HEIGHT: i32 = 60;
+const KEY_GAP: i32 = 5;
+/// Horizontal distance between the left edges of adjacent keys.
+const KEY_PITCH_X: i32 = KEY_WIDTH + KEY_GAP;
+/// Vertical distance between the top edges of adjacent rows (60px key + 20px gap).
+const ROW_PITCH_Y: i32 = 80;
+/// Keyboard width: the widest (10-key) row.
+pub const KEYBOARD_WIDTH: i32 = 10 * KEY_PITCH_X - KEY_GAP; // 445
+/// The function row sits 29px (mockup) below the last character row.
+const FUNCTION_ROW_Y: i32 = 3 * ROW_PITCH_Y + KEY_HEIGHT + 29;
+/// Total component height (function row included).
+pub const KEYBOARD_HEIGHT: i32 = FUNCTION_ROW_Y + KEY_HEIGHT; // 389
+/// Caps-lock / symbols-toggle key width; the function row is inset from the grid edges and the
+/// space bar fills the rest (mockup: 55-wide toggles, 15px gaps, 24px insets).
+const FUNCTION_KEY_WIDTH: i32 = 55;
+const FUNCTION_ROW_INSET: i32 = 24;
+const FUNCTION_ROW_GAP: i32 = 15;
+const SPACE_X: i32 = FUNCTION_ROW_INSET + FUNCTION_KEY_WIDTH + FUNCTION_ROW_GAP;
+const SPACE_WIDTH: i32 = KEYBOARD_WIDTH - 2 * SPACE_X;
+
+const KEY_RADIUS: i32 = 10;
+const KEY_BORDER: i32 = 3;
+
+/// The "jet out" preview: an enlarged balloon-shaped copy of the pressed key, drawn from a
+/// pre-rendered bitmap (white outline, opaque black fill). Its stem covers the pressed key; the
+/// head reaches into the row above (mockup "Frame 150 - on click").
+const PREVIEW_PNG: &[u8] = include_bytes!("../../icons/key_preview.png");
+const PREVIEW_WIDTH: i32 = 72;
+const PREVIEW_HEIGHT: i32 = 132;
+/// Preview position relative to the pressed key's top-left corner.
+const PREVIEW_OFFSET_X: i32 = (KEY_WIDTH - PREVIEW_WIDTH) / 2;
+const PREVIEW_OFFSET_Y: i32 = -69;
+/// How far the preview reaches beyond the container bounds (balloon head above the top key row);
+/// declared as the container's ext draw size so the overhang is not clipped.
+const PREVIEW_OVERHANG: i32 = -PREVIEW_OFFSET_Y;
+/// Vertical offset of the preview character label inside the balloon head.
+const PREVIEW_LABEL_Y: i32 = 10;
+
+const CAPSLOCK_PNG: &[u8] = include_bytes!("../../icons/capslock.png");
+
+/// Number of character-key rows (digits + three letter/symbol rows).
+const ROWS: usize = 4;
+
+/// Child index of the caps-lock button inside the keyboard container (after the `ROWS` key-row
+/// buttonmatrices). The `CHILD_INDEX_*` constants document the container's child order for tests
+/// and dev tooling (render example).
+pub const CHILD_INDEX_CAPSLOCK: i32 = ROWS as i32;
+/// Child index of the space bar.
+pub const CHILD_INDEX_SPACE: i32 = CHILD_INDEX_CAPSLOCK + 1;
+/// Child index of the symbols toggle.
+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;
+
+/// 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),+ $(,)?]) => {
+ const $name: &[LvButtonmatrixMapEntry; $count + 1] = &[
+ $(LvButtonmatrixMapEntry::new($key)),+,
+ LvButtonmatrixMapEntry::new(c""),
+ ];
+ };
+}
+
+key_row_map!(
+ MAP_DIGITS,
+ 10,
+ [c"1", c"2", c"3", c"4", c"5", c"6", c"7", c"8", c"9", c"0"]
+);
+key_row_map!(
+ MAP_LOWER_1,
+ 10,
+ [c"q", c"w", c"e", c"r", c"t", c"y", c"u", c"i", c"o", c"p"]
+);
+key_row_map!(
+ MAP_LOWER_2,
+ 9,
+ [c"a", c"s", c"d", c"f", c"g", c"h", c"j", c"k", c"l"]
+);
+key_row_map!(MAP_LOWER_3, 7, [c"z", c"x", c"c", c"v", c"b", c"n", c"m"]);
+key_row_map!(
+ MAP_UPPER_1,
+ 10,
+ [c"Q", c"W", c"E", c"R", c"T", c"Y", c"U", c"I", c"O", c"P"]
+);
+key_row_map!(
+ MAP_UPPER_2,
+ 9,
+ [c"A", c"S", c"D", c"F", c"G", c"H", c"J", c"K", c"L"]
+);
+key_row_map!(MAP_UPPER_3, 7, [c"Z", c"X", c"C", c"V", c"B", c"N", c"M"]);
+// BitBox02's special characters (minus space, which has its own key), three rows of ten.
+key_row_map!(
+ MAP_SYMBOLS_1,
+ 10,
+ [c"!", c"\"", c"#", c"$", c"%", c"&", c"'", c"(", c")", c"*"]
+);
+key_row_map!(
+ MAP_SYMBOLS_2,
+ 10,
+ [c"+", c",", c"-", c".", c"/", c":", c";", c"<", c"=", c">"]
+);
+key_row_map!(
+ MAP_SYMBOLS_3,
+ 10,
+ [c"?", c"^", c"[", c"\\", c"]", c"@", c"_", c"{", c"|", c"}"]
+);
+
+/// Selector shorthands for state-dependent styles.
+const PRESSED: u32 = LvState::LV_STATE_PRESSED as u32;
+const CHECKED: u32 = LvState::LV_STATE_CHECKED as u32;
+const DISABLED: u32 = LvState::LV_STATE_DISABLED as u32;
+const ITEMS: u32 = LvPart::LV_PART_ITEMS as u32;
+
+/// Disabled/secondary gray, sampled from the mockup's inactive backspace button.
+pub(super) fn gray() -> lvgl::LvColor {
+ lvgl::color::hex(0x777777)
+}
+
+#[derive(Clone, Copy)]
+struct Mode {
+ caps: bool,
+ symbols: bool,
+}
+
+/// The buttonmatrix map of `row` in `mode`. Row 0 (digits) is mode-independent.
+fn row_map(mode: Mode, row: usize) -> &'static [LvButtonmatrixMapEntry] {
+ match (row, mode.symbols, mode.caps) {
+ (0, _, _) => MAP_DIGITS,
+ (1, true, _) => MAP_SYMBOLS_1,
+ (2, true, _) => MAP_SYMBOLS_2,
+ (3, true, _) => MAP_SYMBOLS_3,
+ (1, false, false) => MAP_LOWER_1,
+ (2, false, false) => MAP_LOWER_2,
+ (3, false, false) => MAP_LOWER_3,
+ (1, false, true) => MAP_UPPER_1,
+ (2, false, true) => MAP_UPPER_2,
+ (3, false, true) => MAP_UPPER_3,
+ _ => unreachable!("keyboard has four key rows"),
+ }
+}
+
+/// Number of keys in `row` for `mode`.
+fn row_count(mode: Mode, row: usize) -> usize {
+ row_map(mode, row).len() - 1 // minus the map terminator
+}
+
+/// Width of a key row of `count` keys.
+fn row_width(count: usize) -> i32 {
+ count as i32 * KEY_PITCH_X - KEY_GAP
+}
+
+/// X position of a key row of `count` keys (shorter rows are centred).
+fn row_x(count: usize) -> i32 {
+ (KEYBOARD_WIDTH - row_width(count)) / 2
+}
+
+/// X position of key `col` in a row of `count` keys.
+pub(super) fn key_x(count: usize, col: usize) -> i32 {
+ row_x(count) + col as i32 * KEY_PITCH_X
+}
+
+pub(super) fn key_y(row: usize) -> i32 {
+ row as i32 * ROW_PITCH_Y
+}
+
+/// Absolute-positioned centre of the caps-lock key, for tests.
+#[cfg(test)]
+pub(super) fn capslock_center() -> (i32, i32) {
+ (
+ FUNCTION_ROW_INSET + FUNCTION_KEY_WIDTH / 2,
+ FUNCTION_ROW_Y + KEY_HEIGHT / 2,
+ )
+}
+
+/// Absolute-positioned centre of the symbols-toggle key, for tests.
+#[cfg(test)]
+pub(super) fn symbols_center() -> (i32, i32) {
+ (
+ KEYBOARD_WIDTH - FUNCTION_ROW_INSET - FUNCTION_KEY_WIDTH / 2,
+ FUNCTION_ROW_Y + KEY_HEIGHT / 2,
+ )
+}
+
+/// Absolute-positioned centre of the space bar, for tests.
+#[cfg(test)]
+pub(super) fn space_center() -> (i32, i32) {
+ (KEYBOARD_WIDTH / 2, FUNCTION_ROW_Y + KEY_HEIGHT / 2)
+}
+
+/// Centre of character key (`row`, `col`) in the given layout, for tests.
+#[cfg(test)]
+pub(super) fn char_key_center(
+ mode_symbols: bool,
+ mode_caps: bool,
+ row: usize,
+ col: usize,
+) -> (i32, i32) {
+ let mode = Mode {
+ caps: mode_caps,
+ symbols: mode_symbols,
+ };
+ (
+ key_x(row_count(mode, row), col) + KEY_WIDTH / 2,
+ key_y(row) + KEY_HEIGHT / 2,
+ )
+}
+
+/// The widgets the mode toggles have to update.
+struct Widgets {
+ key_rows: [LvButtonmatrix; ROWS],
+ capslock: LvButton,
+ capslock_icon: LvObj,
+ symbols_label: LvLabel,
+}
+
+/// Applies `mode`: swaps the key-row maps and geometry, enables/disables caps lock (it has no
+/// meaning on the symbols layout) and relabels the symbols toggle.
+fn apply_mode(widgets: &Widgets, mode: Mode) {
+ for (row, matrix) in widgets.key_rows.iter().enumerate() {
+ let count = row_count(mode, row);
+ matrix.set_map(row_map(mode, row));
+ // 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.
+ matrix.set_button_ctrl_all(LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_CLICK_TRIG);
+ // CLICK_TRIG does not gate the long-press repeat path: without NO_REPEAT the
+ // buttonmatrix fires VALUE_CHANGED every repeat period while a key is held (encouraged
+ // by the preview balloon), silently duplicating characters in the masked input.
+ matrix.set_button_ctrl_all(LvButtonmatrixCtrl::LV_BUTTONMATRIX_CTRL_NO_REPEAT);
+ matrix.set_size(row_width(count), KEY_HEIGHT);
+ matrix.set_pos(row_x(count), key_y(row));
+ }
+
+ if mode.symbols {
+ widgets.capslock.add_state(LvState::LV_STATE_DISABLED);
+ widgets.capslock_icon.add_state(LvState::LV_STATE_DISABLED);
+ widgets
+ .capslock
+ .remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ } else {
+ widgets.capslock.remove_state(LvState::LV_STATE_DISABLED);
+ widgets
+ .capslock_icon
+ .remove_state(LvState::LV_STATE_DISABLED);
+ widgets
+ .capslock
+ .add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ }
+
+ // The toggle shows what pressing it switches to.
+ if mode.symbols {
+ widgets
+ .symbols_label
+ .set_style_text_font(lvgl::fonts::INTER_REGULAR_24, 0);
+ widgets.symbols_label.set_text("abc").unwrap();
+ } else {
+ widgets
+ .symbols_label
+ .set_style_text_font(lvgl::fonts::INTER_BOLD_32, 0);
+ widgets.symbols_label.set_text("!@").unwrap();
+ }
+}
+
+/// The pressed-key preview balloon (a canvas with the balloon bitmap plus the character label).
+struct Preview {
+ root: LvObj,
+ label: LvLabel,
+ /// The (row, key) the preview currently shows, to skip redundant updates from the
+ /// once-per-input-period `LV_EVENT_PRESSING` stream.
+ shown: Cell<Option<(usize, u32)>>,
+}
+
+impl Preview {
+ fn build(parent: &LvObj) -> Self {
+ let root = LvObj::with_parent(parent).unwrap();
+ root.set_size(PREVIEW_WIDTH, PREVIEW_HEIGHT);
+ root.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ root.set_style_border_width(0, 0);
+ root.set_style_radius(0, 0);
+ root.set_style_pad_top(0, 0);
+ root.set_style_pad_bottom(0, 0);
+ root.set_style_pad_left(0, 0);
+ root.set_style_pad_right(0, 0);
+ // The preview pops up underneath the finger; it must never grab input away from the keys.
+ root.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_CLICKABLE);
+ root.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
+ root.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+
+ // `png_decoder` returns ARGB8888 pixels as RGBA; LVGL expects BGRA in memory. (Same
+ // decode path as the nav-button icons, but without recolor: the bitmap's white outline
+ // and black fill are used as-is.)
+ let (header, mut data) = png_decoder::decode(PREVIEW_PNG).expect("valid key preview png");
+ for px in data.iter_mut() {
+ px.swap(0, 2);
+ }
+ let canvas =
+ LvCanvas::new(&root, data, header.width, header.height).expect("key preview canvas");
+ canvas.align(lvgl::LvAlign::LV_ALIGN_TOP_MID, 0, 0);
+
+ let label = LvLabel::new(&root).unwrap();
+ label.set_style_text_color(lvgl::color::white(), 0);
+ label.set_style_text_font(lvgl::fonts::INTER_BOLD_48, 0);
+ label.align(lvgl::LvAlign::LV_ALIGN_TOP_MID, 0, PREVIEW_LABEL_Y);
+ label.set_text("").unwrap();
+
+ Self {
+ root,
+ label,
+ shown: Cell::new(None),
+ }
+ }
+
+ /// 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) {
+ 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_y(row) + PREVIEW_OFFSET_Y,
+ );
+ self.root.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ }
+
+ fn hide(&self) {
+ self.shown.set(None);
+ self.root.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_HIDDEN);
+ }
+}
+
+/// Styles a function-key frame: white rounded outline, no fill, and no default-theme press
+/// effects (press feedback is the white fill from `enable_press_invert`).
+fn style_function_key(button: &LvButton) {
+ button.set_style_radius(KEY_RADIUS, 0);
+ style_outline_button(button, KEY_BORDER);
+ // Cancel the default theme's pressed-state grow and dim.
+ button.set_style_recolor_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, PRESSED);
+ button.set_style_transform_width(0, PRESSED);
+ button.set_style_transform_height(0, PRESSED);
+}
+
+/// Styles a key-row buttonmatrix: transparent background, 5px key gaps, and each key drawn as a
+/// white rounded outline with white bold text (unchanged while pressed — press feedback is the
+/// jet-out preview covering the key).
+fn style_key_row(matrix: &LvButtonmatrix) {
+ matrix.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ matrix.set_style_border_width(0, 0);
+ matrix.set_style_radius(0, 0);
+ matrix.set_style_pad_top(0, 0);
+ matrix.set_style_pad_bottom(0, 0);
+ matrix.set_style_pad_left(0, 0);
+ matrix.set_style_pad_right(0, 0);
+ matrix.set_style_pad_column(KEY_GAP, 0);
+
+ for selector in [ITEMS, ITEMS | PRESSED] {
+ matrix.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, selector);
+ matrix.set_style_border_width(KEY_BORDER, selector);
+ matrix.set_style_border_color(lvgl::color::white(), selector);
+ matrix.set_style_radius(KEY_RADIUS, selector);
+ matrix.set_style_shadow_width(0, selector);
+ matrix.set_style_text_color(lvgl::color::white(), selector);
+ matrix.set_style_text_font(lvgl::fonts::INTER_BOLD_32, selector);
+ }
+}
+
+/// A second handle to a key-row matrix, for use inside its own event callbacks.
+fn matrix_handle(container: &LvObj, row: usize) -> LvButtonmatrix {
+ container
+ .child(row as i32)
+ .expect("key row")
+ .try_downcast()
+ .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 {
+ let container = LvObj::with_parent(parent).unwrap();
+ container.set_size(KEYBOARD_WIDTH, KEYBOARD_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);
+ container.set_style_pad_top(0, 0);
+ container.set_style_pad_bottom(0, 0);
+ container.set_style_pad_left(0, 0);
+ container.set_style_pad_right(0, 0);
+ 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
+ // 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) {
+ unsafe { lvgl::ffi::lv_event_set_ext_draw_size(event, PREVIEW_OVERHANG) };
+ }
+ unsafe {
+ lvgl::ffi::lv_obj_add_event_cb(
+ container.as_ptr(),
+ Some(refresh_ext_draw_size_cb),
+ lvgl::LvEventCode::LV_EVENT_REFR_EXT_DRAW_SIZE,
+ core::ptr::null_mut(),
+ );
+ lvgl::ffi::lv_obj_refresh_ext_draw_size(container.as_ptr());
+ }
+
+ let mode = Rc::new(RefCell::new(Mode {
+ caps: false,
+ symbols: false,
+ }));
+
+ let key_rows: [LvButtonmatrix; ROWS] = core::array::from_fn(|_| {
+ let matrix = LvButtonmatrix::new(&container).unwrap();
+ style_key_row(&matrix);
+ matrix
+ });
+
+ // Caps lock: outline arrow icon; toggled = white fill with the icon inverted to black
+ // (`LV_STATE_CHECKED`, managed manually in the click handler); disabled (gray) on the symbols
+ // layout.
+ let capslock = LvButton::new(&container).unwrap();
+ capslock.set_size(FUNCTION_KEY_WIDTH, KEY_HEIGHT);
+ capslock.set_pos(FUNCTION_ROW_INSET, FUNCTION_ROW_Y);
+ style_function_key(&capslock);
+ capslock.set_style_bg_color(lvgl::color::white(), CHECKED);
+ capslock.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, CHECKED);
+ capslock.set_style_border_color(gray(), DISABLED);
+ let icon = add_icon(&capslock, CAPSLOCK_PNG);
+ enable_press_invert(&capslock, vec![icon]);
+ // A second handle to the icon canvas, for the checked/disabled recolors.
+ let capslock_icon = capslock.child(0).expect("caps lock icon");
+ capslock_icon.set_style_image_recolor(lvgl::color::black(), CHECKED);
+ capslock_icon.set_style_image_recolor(gray(), DISABLED);
+
+ // Space bar (no label; press feedback is the white fill).
+ let space = LvButton::new(&container).unwrap();
+ space.set_size(SPACE_WIDTH, KEY_HEIGHT);
+ space.set_pos(SPACE_X, FUNCTION_ROW_Y);
+ style_function_key(&space);
+ enable_press_invert(&space, vec![]);
+ {
+ let textarea = Rc::clone(&textarea);
+ space
+ .add_click_cb(move || textarea.add_char(u32::from(b' ')))
+ .expect("failed to register space callback");
+ }
+
+ // Symbols toggle ("!@" on the character layouts, "abc" on the symbols layout).
+ let symbols = LvButton::new(&container).unwrap();
+ symbols.set_size(FUNCTION_KEY_WIDTH, KEY_HEIGHT);
+ symbols.set_pos(
+ KEYBOARD_WIDTH - FUNCTION_ROW_INSET - FUNCTION_KEY_WIDTH,
+ FUNCTION_ROW_Y,
+ );
+ style_function_key(&symbols);
+ let symbols_label = LvLabel::new(&symbols).unwrap();
+ symbols_label.set_style_text_color(lvgl::color::white(), 0);
+ symbols_label.set_style_text_color(lvgl::color::black(), PRESSED);
+ symbols_label.align(lvgl::LvAlign::LV_ALIGN_CENTER, 0, 0);
+ symbols_label.set_text("").unwrap();
+ let symbols_label_part = symbols.child(0).expect("symbols toggle label");
+ enable_press_invert(&symbols, vec![symbols_label_part]);
+
+ // 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");
+ }
+ }
+
+ let widgets = Rc::new(Widgets {
+ key_rows,
+ capslock,
+ capslock_icon,
+ symbols_label,
+ });
+
+ {
+ let mode = Rc::clone(&mode);
+ let widgets_cb = Rc::clone(&widgets);
+ widgets
+ .capslock
+ .add_click_cb(move || {
+ let new_mode = {
+ let mut mode = mode.borrow_mut();
+ mode.caps = !mode.caps;
+ *mode
+ };
+ if new_mode.caps {
+ widgets_cb.capslock.add_state(LvState::LV_STATE_CHECKED);
+ widgets_cb
+ .capslock_icon
+ .add_state(LvState::LV_STATE_CHECKED);
+ } else {
+ widgets_cb.capslock.remove_state(LvState::LV_STATE_CHECKED);
+ widgets_cb
+ .capslock_icon
+ .remove_state(LvState::LV_STATE_CHECKED);
+ }
+ apply_mode(&widgets_cb, new_mode);
+ })
+ .expect("failed to register caps lock callback");
+ }
+
+ {
+ let mode = Rc::clone(&mode);
+ let widgets_cb = Rc::clone(&widgets);
+ symbols
+ .add_click_cb(move || {
+ let new_mode = {
+ let mut mode = mode.borrow_mut();
+ mode.symbols = !mode.symbols;
+ // Caps lock has no meaning for symbols; start over in lowercase.
+ mode.caps = false;
+ *mode
+ };
+ widgets_cb.capslock.remove_state(LvState::LV_STATE_CHECKED);
+ widgets_cb
+ .capslock_icon
+ .remove_state(LvState::LV_STATE_CHECKED);
+ apply_mode(&widgets_cb, new_mode);
+ })
+ .expect("failed to register symbols toggle callback");
+ }
+
+ apply_mode(&widgets, *mode.borrow());
+
+ container
+}
### src/rust/bitbox03/src/ui/keypad.rs
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Numeric keypad for PIN entry: a 3×4 grid of navigation-button-sized keys — digits 1–9, then a
+//! bottom row of backspace, 0 and confirm ("PIN entry mockup"). Digits are inserted into the
+//! textarea on release with the standard press-invert feedback; sliding off a key aborts the tap
+//! (see `ObjExt::add_click_cb`); backspace is grayed out and inert while the input is empty.
+
+use alloc::rc::Rc;
+use alloc::vec;
+
+use bitbox_lvgl::{self as lvgl, LabelExt, LvLabel, LvObj, LvTextarea, ObjExt, TextareaExt};
+
+use super::keyboard::gray;
+use super::nav_button::{NavIcon, build_nav_button, enable_press_invert, style_outline_button};
+
+/// Key side length; matches the navigation buttons.
+const KEY_SIZE: i32 = 82;
+/// Gap between keys (mockup: 50px both ways).
+const KEY_GAP: i32 = 50;
+const KEY_PITCH: i32 = KEY_SIZE + KEY_GAP;
+
+pub const KEYPAD_WIDTH: i32 = 3 * KEY_PITCH - KEY_GAP; // 346
+pub const KEYPAD_HEIGHT: i32 = 4 * KEY_PITCH - KEY_GAP; // 478
+
+/// Selector shorthand for the pressed state.
+const PRESSED: u32 = lvgl::LvState::LV_STATE_PRESSED as u32;
+
+/// Centre of the key at (`row`, `col`) in keypad-container coordinates, for tests.
+#[cfg(test)]
+pub(super) fn key_center(row: usize, col: usize) -> (i32, i32) {
+ (
+ col as i32 * KEY_PITCH + KEY_SIZE / 2,
+ row as i32 * KEY_PITCH + KEY_SIZE / 2,
+ )
+}
+
+/// Adds one digit key: the navigation-button frame with the digit as its label; inserts the
+/// digit on release.
+fn add_digit_key(container: &LvObj, textarea: &Rc<LvTextarea>, digit: u8, x: i32, y: i32) {
+ let key = lvgl::LvButton::new(container).unwrap();
+ key.set_size(KEY_SIZE, KEY_SIZE);
+ key.set_pos(x, y);
+ key.set_style_radius(19, 0); // navigation-button corner radius
+ style_outline_button(&key, 3);
+ let label = LvLabel::new(&key).unwrap();
+ label.set_style_text_color(lvgl::color::white(), 0);
+ label.set_style_text_color(lvgl::color::black(), PRESSED);
+ label.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ let text = [digit];
+ label
+ .set_text(core::str::from_utf8(&text).expect("keypad digits are ASCII"))
+ .expect("keypad digits contain no NUL");
+ label.align(lvgl::LvAlign::LV_ALIGN_CENTER, 0, 0);
+ let label_part = key.child(0).expect("digit label");
+ enable_press_invert(&key, vec![label_part]);
+ let textarea = Rc::clone(textarea);
+ key.add_click_cb(move || textarea.add_char(u32::from(digit)))
+ .expect("failed to register digit callback");
+}
+
+/// Builds the PIN keypad as a `KEYPAD_WIDTH`×`KEYPAD_HEIGHT` container appended to `parent`.
+/// Digit keys insert into `textarea`; the checkmark key calls `on_confirm`.
+///
+/// Child order is row-major over the grid: digits 1–9 (0..=8), backspace (9), 0 (10),
+/// confirm (11).
+pub fn build_keypad<F>(parent: &LvObj, textarea: Rc<LvTextarea>, on_confirm: F) -> LvObj
+where
+ F: FnMut() + 'static,
+{
+ let container = LvObj::with_parent(parent).unwrap();
+ container.set_size(KEYPAD_WIDTH, KEYPAD_HEIGHT);
+ container.set_style_bg_opa(lvgl::LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ container.set_style_border_width(0, 0);
+ container.set_style_radius(0, 0);
+ container.set_style_pad_top(0, 0);
+ container.set_style_pad_bottom(0, 0);
+ container.set_style_pad_left(0, 0);
+ container.set_style_pad_right(0, 0);
+ container.remove_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_SCROLLABLE);
+
+ for (i, digit) in (b'1'..=b'9').enumerate() {
+ let (row, col) = (i / 3, i % 3);
+ add_digit_key(
+ &container,
+ &textarea,
+ digit,
+ col as i32 * KEY_PITCH,
+ row as i32 * KEY_PITCH,
+ );
+ }
+
+ let bottom = 3 * KEY_PITCH;
+
+ // Backspace: the standard back icon button, grayed out and inert while the input is empty.
+ let backspace = build_nav_button(&container, NavIcon::Back);
+ backspace.set_pos(0, bottom);
+ let backspace_icon = backspace.child(0).expect("backspace icon");
+ backspace.set_style_border_color(gray(), lvgl::LvState::LV_STATE_DISABLED as u32);
+ backspace_icon.set_style_image_recolor(gray(), lvgl::LvState::LV_STATE_DISABLED as u32);
+ 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 = move || {
+ if super::enter_string::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);
+ }
+ };
+ refresh_backspace();
+ textarea
+ .add_event_cb(lvgl::LvEventCode::LV_EVENT_VALUE_CHANGED, refresh_backspace)
+ .expect("failed to register backspace state callback");
+
+ add_digit_key(&container, &textarea, b'0', KEY_PITCH, bottom);
+
+ let confirm = build_nav_button(&container, NavIcon::Confirm);
+ confirm.set_pos(2 * KEY_PITCH, bottom);
+ confirm
+ .add_click_cb(on_confirm)
+ .expect("failed to register confirm callback");
+
+ container
+}
### src/rust/bitbox03/src/ui/menu.rs
@@ -72,7 +72,7 @@ pub fn build_menu_screen(
title_label.set_text(&title_text).unwrap();
title_label.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
### src/rust/bitbox03/src/ui/nav_button.rs
@@ -17,6 +17,8 @@ use bitbox_lvgl::{
/// Style selector for the pressed state.
const PRESSED_SELECTOR: u32 = LvState::LV_STATE_PRESSED as u32;
+/// Style selector for the disabled state.
+const DISABLED_SELECTOR: u32 = LvState::LV_STATE_DISABLED as u32;
/// Properties that change between the normal and pressed look (the button's white fill).
const PRESS_TRANSITION_PROPS: [u8; 3] = [prop::BG_OPA, prop::BG_COLOR, prop::INV];
@@ -58,7 +60,7 @@ const CLOSE_PNG: &[u8] = include_bytes!("../../icons/cancel2.png");
/// Decodes an icon PNG, adds it centred in `button` as a canvas, and sets it to recolour white
/// normally and black in the pressed state. Returns the canvas as an [`LvObj`] for press wiring.
-fn add_icon(button: &LvButton, png: &[u8]) -> LvObj {
+pub(super) fn add_icon(button: &LvButton, png: &[u8]) -> LvObj {
// `png_decoder` returns ARGB8888 pixels as RGBA; LVGL expects BGRA in memory.
let (header, mut data) = png_decoder::decode(png).expect("valid icon png");
for px in data.iter_mut() {
@@ -76,7 +78,7 @@ fn add_icon(button: &LvButton, png: &[u8]) -> LvObj {
/// Wires the pressed-state look: the interior fills white (cancelling the theme's grow + dim, with
/// an instant transition) and the icon inverts to black. A child does not inherit the button's
/// pressed state, so it is propagated to the icon via press/release events.
-fn enable_press_invert(button: &LvButton, parts: Vec<LvObj>) {
+pub(super) fn enable_press_invert(button: &LvButton, parts: Vec<LvObj>) {
button.set_style_bg_color(lvgl::color::white(), PRESSED_SELECTOR);
button.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, PRESSED_SELECTOR);
// The default theme dims pressed objects (black recolor); disable so the fill is pure white.
@@ -116,7 +118,7 @@ fn enable_press_invert(button: &LvButton, parts: Vec<LvObj>) {
/// Common frame styling for an outline icon button (transparent fill, white border, no shadow,
/// no padding).
-fn style_outline_button(button: &LvButton, border_width: i32) {
+pub(super) fn style_outline_button(button: &LvButton, border_width: i32) {
button.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0); // fill: none
button.set_style_border_width(border_width, 0);
button.set_style_border_color(lvgl::color::white(), 0);
@@ -125,6 +127,14 @@ fn style_outline_button(button: &LvButton, border_width: i32) {
button.set_style_pad_bottom(0, 0);
button.set_style_pad_left(0, 0);
button.set_style_pad_right(0, 0);
+ // The default theme's disabled style recolors the button — and everything drawn inside it —
+ // 50% grey, and the theme's state-change transition animates RECOLOR/RECOLOR_OPA with a 70ms
+ // delay + 80ms fade. Callers style their own instant gray disabled look (border and icon), so
+ // the delayed overlay would arrive ~150ms late as a second visible change: the button
+ // flickers on every enable/disable. Pin both props to their default-state values so the
+ // disabled style differs in nothing the theme animates and the switch is a single frame.
+ button.set_style_recolor_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, DISABLED_SELECTOR);
+ button.set_style_recolor(lvgl::color::black(), DISABLED_SELECTOR);
}
/// Builds a navigation icon button and appends it to `parent`. Returns the button so the caller can
### src/rust/bitbox03/src/ui/slide_to_confirm.rs
@@ -406,149 +406,18 @@ pub fn build_slide_to_confirm(parent: &LvObj, on_confirm: impl FnMut() + 'static
mod tests {
extern crate std;
- use std::boxed::Box;
- use std::collections::VecDeque;
- use std::sync::{LazyLock, Mutex, MutexGuard, Once};
- use std::time::{Duration, Instant};
- use std::vec;
-
- use bitbox_lvgl::{
- LvArea, LvDisplay, LvDisplayRenderMode, LvIndev, LvIndevState, LvIndevType, LvPoint, ffi,
- };
+ use bitbox_lvgl::ffi;
+ use super::super::test_util::{ScriptedTouch, coords, lock_and_init, pump_for};
use super::*;
- const WIDTH: i32 = 480;
- const HEIGHT: i32 = 800;
-
- extern "C" fn now_ms() -> u32 {
- static START: LazyLock<Instant> = LazyLock::new(Instant::now);
- START.elapsed().as_millis() as u32
- }
-
- static LVGL_TEST_LOCK: Mutex<()> = Mutex::new(());
- static INIT: Once = Once::new();
-
- /// Serializes tests and lazily brings up LVGL with a headless 480×800 display and a tick
- /// source, so input processing, layouting and animations run for real.
- fn lock_and_init() -> MutexGuard<'static, ()> {
- // A failed test leaves the shared LVGL state usable, so ignore lock poisoning instead of
- // cascading one failure into every later test.
- let guard = LVGL_TEST_LOCK
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- INIT.call_once(|| {
- lvgl::system::init();
- lvgl::tick::set_cb(Some(now_ms));
- let draw_buf: &'static mut [u32] =
- Box::leak(vec![0u32; (WIDTH * HEIGHT) as usize].into_boxed_slice());
- let display = LvDisplay::new(WIDTH, HEIGHT).expect("create display");
- display
- .set_buffers(
- draw_buf,
- None,
- LvDisplayRenderMode::LV_DISPLAY_RENDER_MODE_PARTIAL,
- )
- .expect("set display buffers");
- // Dropping the handle does not delete the LVGL display; it lives for the whole
- // test process.
- display.set_flush_cb(|_display, _area, _px_map| {});
- });
- guard
- }
-
- /// Runs the LVGL timer loop (input reading, layout, animation, rendering) for `ms`.
- fn pump_for(ms: u64) {
- let deadline = Instant::now() + Duration::from_millis(ms);
- while Instant::now() < deadline {
- lvgl::timer::handler();
- std::thread::sleep(Duration::from_millis(2));
- }
- }
-
- struct TouchSample {
- x: i32,
- y: i32,
- pressed: bool,
- }
-
- /// A scripted LVGL pointer device (same read model as `io::touchscreen::TouchScreen`: the
- /// queue front is the current state; entries past the first are drained one per read). Unlike
- /// the production type it deletes its input device on drop, so a finished test cannot keep
- /// replaying its last sample into later tests.
- struct ScriptedTouch {
- indev: LvIndev,
- queue: NonNull<VecDeque<TouchSample>>,
- }
-
- extern "C" fn scripted_read_cb(indev: *mut ffi::lv_indev_t, data: *mut ffi::lv_indev_data_t) {
- let queue = unsafe { ffi::lv_indev_get_user_data(indev) };
- debug_assert!(!queue.is_null());
- let queue = unsafe { &mut *(queue as *mut VecDeque<TouchSample>) };
- let data = unsafe { &mut *data };
- if let Some(next) = queue.front() {
- data.point = LvPoint {
- x: next.x,
- y: next.y,
- };
- data.state = if next.pressed {
- LvIndevState::LV_INDEV_STATE_PRESSED
- } else {
- LvIndevState::LV_INDEV_STATE_RELEASED
- };
- }
- if queue.len() > 1 {
- queue.pop_front();
- data.continue_reading = !queue.is_empty();
- }
- }
-
- impl ScriptedTouch {
- fn new() -> Self {
- let queue: &'static mut VecDeque<TouchSample> = Box::leak(Box::new(VecDeque::new()));
- let queue_ptr = NonNull::from(&mut *queue);
- let indev = LvIndev::new().expect("create input device");
- indev.set_type(LvIndevType::LV_INDEV_TYPE_POINTER);
- indev.set_read_cb(Some(scripted_read_cb));
- indev.set_user_data(Some(queue));
- Self {
- indev,
- queue: queue_ptr,
- }
- }
-
- fn push(&mut self, x: i32, y: i32, pressed: bool) {
- unsafe { self.queue.as_mut() }.push_back(TouchSample { x, y, pressed });
- }
- }
-
- impl Drop for ScriptedTouch {
- fn drop(&mut self) {
- unsafe {
- ffi::lv_indev_delete(self.indev.as_ptr());
- drop(Box::from_raw(self.queue.as_ptr()));
- }
- }
- }
-
struct Harness {
touch: ScriptedTouch,
screen: LvObj,
slider: LvSlider,
confirmed: Rc<Cell<bool>>,
}
- fn coords(obj: &impl ObjExt) -> LvArea {
- let mut area = LvArea {
- x1: 0,
- y1: 0,
- x2: 0,
- y2: 0,
- };
- unsafe { ffi::lv_obj_get_coords(obj.as_ptr(), &mut area) };
- area
- }
-
impl Harness {
fn new() -> Self {
let touch = ScriptedTouch::new();
### src/rust/bitbox03/src/ui/status.rs
@@ -47,7 +47,7 @@ pub(super) fn build_status_screen(title: &str, status_success: bool) -> LvObj {
title_label.set_text(title).unwrap();
title_label.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
title_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
+ lvgl::fonts::INTER_REGULAR_32,
lvgl::LvState::LV_STATE_DEFAULT as u32,
);
### src/rust/bitbox03/src/ui/test_util.rs
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Shared test scaffolding for UI component tests: headless LVGL bring-up (a real 480×800
+//! display and tick source, so input processing, layouting and animations run for real) and a
+//! scripted touch pointer.
+
+extern crate std;
+
+use std::boxed::Box;
+use std::collections::VecDeque;
+use std::sync::{LazyLock, Mutex, MutexGuard, Once};
+use std::time::{Duration, Instant};
+use std::vec;
+
+use core::ptr::NonNull;
+
+use bitbox_lvgl::{
+ self as lvgl, LvArea, LvDisplay, LvDisplayRenderMode, LvIndev, LvIndevState, LvIndevType,
+ LvPoint, ObjExt, ffi,
+};
+
+const WIDTH: i32 = 480;
+const HEIGHT: i32 = 800;
+
+extern "C" fn now_ms() -> u32 {
+ static START: LazyLock<Instant> = LazyLock::new(Instant::now);
+ START.elapsed().as_millis() as u32
+}
+
+static LVGL_TEST_LOCK: Mutex<()> = Mutex::new(());
+static INIT: Once = Once::new();
+
+/// Serializes tests and lazily brings up LVGL with a headless 480×800 display and a tick
+/// source. Every test touching LVGL must hold the returned guard for its whole body.
+pub(crate) fn lock_and_init() -> MutexGuard<'static, ()> {
+ // A failed test leaves the shared LVGL state usable, so ignore lock poisoning instead of
+ // cascading one failure into every later test.
+ let guard = LVGL_TEST_LOCK
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ INIT.call_once(|| {
+ lvgl::system::init();
+ lvgl::tick::set_cb(Some(now_ms));
+ let draw_buf: &'static mut [u32] =
+ Box::leak(vec![0u32; (WIDTH * HEIGHT) as usize].into_boxed_slice());
+ let display = LvDisplay::new(WIDTH, HEIGHT).expect("create display");
+ display
+ .set_buffers(
+ draw_buf,
+ None,
+ LvDisplayRenderMode::LV_DISPLAY_RENDER_MODE_PARTIAL,
+ )
+ .expect("set display buffers");
+ // Dropping the handle does not delete the LVGL display; it lives for the whole
+ // test process.
+ display.set_flush_cb(|_display, _area, _px_map| {});
+ });
+ guard
+}
+
+/// Runs the LVGL timer loop (input reading, layout, animation, rendering) for `ms`.
+pub(crate) fn pump_for(ms: u64) {
+ let deadline = Instant::now() + Duration::from_millis(ms);
+ while Instant::now() < deadline {
+ lvgl::timer::handler();
+ std::thread::sleep(Duration::from_millis(2));
+ }
+}
+
+/// The absolute screen coordinates of `obj`.
+pub(crate) fn coords(obj: &impl ObjExt) -> LvArea {
+ let mut area = LvArea {
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 0,
+ };
+ unsafe { ffi::lv_obj_get_coords(obj.as_ptr(), &mut area) };
+ area
+}
+
+pub(crate) struct TouchSample {
+ x: i32,
+ y: i32,
+ pressed: bool,
+}
+
+/// A scripted LVGL pointer device (same read model as `io::touchscreen::TouchScreen`: the
+/// queue front is the current state; entries past the first are drained one per read). Unlike
+/// the production type it deletes its input device on drop, so a finished test cannot keep
+/// replaying its last sample into later tests.
+pub(crate) struct ScriptedTouch {
+ pub(crate) indev: LvIndev,
+ queue: NonNull<VecDeque<TouchSample>>,
+}
+
+extern "C" fn scripted_read_cb(indev: *mut ffi::lv_indev_t, data: *mut ffi::lv_indev_data_t) {
+ let queue = unsafe { ffi::lv_indev_get_user_data(indev) };
+ debug_assert!(!queue.is_null());
+ let queue = unsafe { &mut *(queue as *mut VecDeque<TouchSample>) };
+ let data = unsafe { &mut *data };
+ if let Some(next) = queue.front() {
+ data.point = LvPoint {
+ x: next.x,
+ y: next.y,
+ };
+ data.state = if next.pressed {
+ LvIndevState::LV_INDEV_STATE_PRESSED
+ } else {
+ LvIndevState::LV_INDEV_STATE_RELEASED
+ };
+ }
+ if queue.len() > 1 {
+ queue.pop_front();
+ data.continue_reading = !queue.is_empty();
+ }
+}
+
+impl ScriptedTouch {
+ pub(crate) fn new() -> Self {
+ let queue: &'static mut VecDeque<TouchSample> = Box::leak(Box::new(VecDeque::new()));
+ let queue_ptr = NonNull::from(&mut *queue);
+ let indev = LvIndev::new().expect("create input device");
+ indev.set_type(LvIndevType::LV_INDEV_TYPE_POINTER);
+ indev.set_read_cb(Some(scripted_read_cb));
+ indev.set_user_data(Some(queue));
+ Self {
+ indev,
+ queue: queue_ptr,
+ }
+ }
+
+ pub(crate) fn push(&mut self, x: i32, y: i32, pressed: bool) {
+ unsafe { self.queue.as_mut() }.push_back(TouchSample { x, y, pressed });
+ }
+
+ /// Queues a full tap (press, hold in place, release) at (`x`, `y`) and consumes it.
+ pub(crate) fn tap(&mut self, x: i32, y: i32) {
+ self.push(x, y, true);
+ self.push(x, y, true);
+ self.push(x, y, false);
+ pump_for(120);
+ }
+}
+
+impl Drop for ScriptedTouch {
+ fn drop(&mut self) {
+ unsafe {
+ ffi::lv_indev_delete(self.indev.as_ptr());
+ drop(Box::from_raw(self.queue.as_ptr()));
+ }
+ }
+}
### test/simulator-graphical-bb03/src/main.rs
@@ -141,10 +141,13 @@ impl FrameBuffer {
) -> FrameBuffer {
let front_buffer =
DynamicImage::ImageRgba8(RgbaImage::new(SCREEN_WIDTH as u32, SCREEN_HEIGHT as u32));
+ // Linear filtering: the screen is drawn scaled (the window opens at 50% and is freely
+ // resizable), and NEAREST sampling at non-integer scales drops different source rows per
+ // screen position — identical borders then render with visibly different thickness.
let screen_id = canvas
.create_image(
ImageSource::try_from(&front_buffer).unwrap(),
- ImageFlags::NEAREST,
+ ImageFlags::empty(),
)
.unwrap();
FrameBuffer {
@@ -206,7 +209,7 @@ fn my_flush_cb(display: lvgl::LvDisplay, _area: &lvgl::LvArea, _px_map: *mut u8)
fn init_hww(
bitbox: &mut BitBox03,
- preseed: bool,
+ args: &Args,
) -> Option<bitbox02_rust::hww::transport::HwwTransport<BitBox03>> {
//bitbox02::screen::init(pixel_fn, mirror_fn, clear_fn);
//bitbox02::screen::splash();
@@ -228,14 +231,38 @@ fn init_hww(
//bitbox02::memory::fake_nova();
info!("Memory setup: success");
- if preseed {
+ if args.preseed {
let mnemonic = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide";
let seed = bitbox02_rust::bip39::mnemonic_to_seed(mnemonic).unwrap();
block_on(bitbox02_rust::keystore::encrypt_and_store_seed(
bitbox, &seed, "",
))
.unwrap();
bitbox.memory().set_initialized().unwrap();
+
+ if args.unlock {
+ // Storing the seed retains it but does not unlock BIP39; the keystore counts as
+ // locked (and the first client connection prompts for the password) until the BIP39
+ // seed is retained too.
+ block_on(bitbox02_rust::keystore::unlock_bip39(
+ &mut bitbox02_rust::keystore::KeystoreHalImpl::from_hal(bitbox),
+ &seed,
+ "",
+ async || {},
+ ))
+ .unwrap();
+ }
+ }
+
+ if args.passphrase {
+ bitbox
+ .memory()
+ .set_mnemonic_passphrase_enabled(true)
+ .unwrap();
+ }
+
+ if !args.unlock {
+ bitbox02_rust::keystore::lock();
}
Some(bitbox02_rust::hww::transport::hww_transport::<BitBox03>())
@@ -722,6 +749,16 @@ struct Args {
/// Pre seed the simulated bitbox with empty password and the following bip39 seed phrase "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide"
#[arg(long)]
preseed: bool,
+
+ /// Enable the BIP39 mnemonic passphrase setting, so the unlock/setup/restore workflows prompt
+ /// for an optional passphrase.
+ #[arg(long)]
+ passphrase: bool,
+
+ /// Start with the keystore unlocked (requires --preseed). By default the simulator starts
+ /// locked and the first client connection triggers the on-device unlock workflow.
+ #[arg(long, requires = "preseed")]
+ unlock: bool,
}
pub fn main() -> Result<(), Box<dyn Error>> {
@@ -741,7 +778,7 @@ pub fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse();
let mut app = App::new(bitbox);
- app.transport = init_hww(&mut bitbox, args.preseed);
+ app.transport = init_hww(&mut bitbox, &args);
if app.transport.is_none() {
return Err(Box::new(AppError::new("Failed to init hww")));
}Why this scored 39/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.