bb03 ui: navigation icon buttons (Back/Next/Confirm/Cancel)
What changed, and why it matters
This commit is a user-interface redesign for the BitBox03 hardware wallet: text buttons like 'Yes/No' and 'Back/Next' are replaced with icon buttons. It also adds a hidden demo screen that can only be opened in the simulator by setting the device name to a special test value. There is no direct security vulnerability visible in the code change, but it touches code that handles user confirmation and cancellation, so any bug here could affect whether a user correctly approves or rejects a sensitive action.
Treat as a normal UI refactor. Review the new nav_button.rs for correct event handling and ensure the press/release/press-lost callbacks cannot leave the icon in an inconsistent visual state. Verify that the simulator-only demo sentinel cannot be compiled into production firmware (the `simulator-graphical` feature gate appears to do this). No immediate security patch is required.
Security signals we found
UI code that renders security-critical approval/rejection actions was modified
New PNG assets are decoded and embedded; malformed assets could affect runtime behaviour, though they are compile-time constants
Simulator-only demo entry point is gated by a feature flag and a sentinel string, reducing production exposure
No changes to input validation, memory allocation bounds, or cryptographic checks are present in the diff
Evidence from the diff
The change refactors confirmation, menu, string-entry and status screens to use new vector-outline + PNG-icon navigation buttons (Back, Next, Confirm, Cancel, plus a corner close button). It extends the bitbox-lvgl Rust wrapper with style transitions, object flags/states, child access and a line widget, and adds a simulator-only demo reachable via the sentinel device name ‘demo_nav’ guarded by #[cfg(feature = "simulator-graphical")]. The icon bitmaps are decoded from embedded PNGs, swapped from RGBA to BGRA, placed on an LvCanvas, and recoloured white/black on press/release via event callbacks. No cryptographic, memory-safety, or input-validation flaws are evident in the diff.
Changed components
bitbox03/src/ui/confirm.rsbitbox03/src/ui/menu.rsbitbox03/src/ui/enter_string.rsbitbox03/src/ui/nav_button.rsbitbox03/src/ui/demo.rsbitbox-lvgl style/object/line bindingsbitbox-hal Ui traitInspect captured patch +524 / −166
diff --git a/src/rust/bitbox-hal/src/ui.rs b/src/rust/bitbox-hal/src/ui.rs
index 29eb0ab..ed7d035 100644
--- a/src/rust/bitbox-hal/src/ui.rs
+++ b/src/rust/bitbox-hal/src/ui.rs
@@ -96,6 +96,10 @@ pub trait Ui {
async fn status(&mut self, title: &str, status_success: bool);
+ /// Demo/testing only: show a screen with all navigation icon buttons. Defaults to a no-op so
+ /// only platforms that implement it (BitBox03) do anything.
+ async fn show_demo_nav_buttons(&mut self) {}
+
/// Render a debug/error message directly to the screen.
/// If `duration` is zero, the message remains visible indefinitely.
fn print_screen(&mut self, duration: Duration, msg: &str);
diff --git a/src/rust/bitbox-lvgl/src/lib.rs b/src/rust/bitbox-lvgl/src/lib.rs
index 2261000..d42d4a7 100644
--- a/src/rust/bitbox-lvgl/src/lib.rs
+++ b/src/rust/bitbox-lvgl/src/lib.rs
@@ -38,8 +38,10 @@ pub use ffi::lv_keyboard_mode_t as LvKeyboardMode;
pub use ffi::lv_label_long_mode_t as LvLabelLongMode;
pub use ffi::lv_layout_t as LvLayout;
pub use ffi::lv_log_level_t as LvLogLevel;
+pub use ffi::lv_obj_flag_t as LvObjFlag;
pub use ffi::lv_opa_t as LvOpa;
pub use ffi::lv_part_t as LvPart;
+pub use ffi::lv_point_precise_t as LvPointPrecise;
pub use ffi::lv_point_t as LvPoint;
pub use ffi::lv_slider_mode_t as LvSliderMode;
pub use ffi::lv_slider_orientation_t as LvSliderOrientation;
@@ -56,6 +58,7 @@ pub mod color;
pub mod display;
pub mod indev;
pub mod log;
+pub mod style;
pub mod system;
#[cfg(test)]
mod test_util;
@@ -68,6 +71,7 @@ pub use display::{LvDisplay, LvDisplayBufferError};
pub use font::LvFont;
pub use font::fonts;
pub use indev::LvIndev;
+pub use style::LvStyleTransition;
pub use widgets::arc::{ArcExt, LvArc};
pub use widgets::bar::{BarExt, LvBar};
pub use widgets::button::{ButtonExt, LvButton};
@@ -78,6 +82,7 @@ pub use widgets::class;
pub use widgets::image::ImageExt;
pub use widgets::keyboard::{KeyboardExt, LvKeyboard, LvKeyboardMapEntry, keyboard_def_event_cb};
pub use widgets::label::{LabelExt, LvLabel, LvLabelTextError};
+pub use widgets::line::{LvLine, LvLineError};
pub use widgets::obj;
pub use widgets::obj::LvObj;
pub use widgets::obj::{LvHandle, LvTypeError, ObjExt};
diff --git a/src/rust/bitbox-lvgl/src/style.rs b/src/rust/bitbox-lvgl/src/style.rs
new file mode 100644
index 0000000..c7328df
--- /dev/null
+++ b/src/rust/bitbox-lvgl/src/style.rs
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use crate::ffi;
+
+/// Style property ids, for building a [`LvStyleTransition`] property list. Values match LVGL's
+/// `lv_style_prop_t`.
+pub mod prop {
+ use crate::ffi::_lv_style_id_t;
+
+ /// Terminator for a property list.
+ pub const INV: u8 = _lv_style_id_t::LV_STYLE_PROP_INV as u8;
+ pub const BG_COLOR: u8 = _lv_style_id_t::LV_STYLE_BG_COLOR as u8;
+ pub const BG_OPA: u8 = _lv_style_id_t::LV_STYLE_BG_OPA as u8;
+ pub const TEXT_COLOR: u8 = _lv_style_id_t::LV_STYLE_TEXT_COLOR as u8;
+ pub const LINE_COLOR: u8 = _lv_style_id_t::LV_STYLE_LINE_COLOR as u8;
+}
+
+/// A style transition descriptor. When an object changes state, the listed `props` animate over
+/// `time_ms` (linear easing).
+///
+/// LVGL stores the pointer to this descriptor (and to its property list) rather than copying them,
+/// so both must live for `'static`. Declare it as a `static` and pass it to
+/// [`crate::ObjExt::set_style_transition`].
+pub struct LvStyleTransition(ffi::lv_style_transition_dsc_t);
+
+// LVGL runs single-threaded; the descriptor is immutable after construction.
+unsafe impl Sync for LvStyleTransition {}
+
+impl LvStyleTransition {
+ /// `props` must be a `'static` slice terminated by [`prop::INV`].
+ pub const fn new(props: &'static [u8], time_ms: u32, delay_ms: u32) -> Self {
+ Self(ffi::lv_style_transition_dsc_t {
+ props: props.as_ptr(),
+ user_data: core::ptr::null_mut(),
+ path_xcb: Some(ffi::lv_anim_path_linear),
+ time: time_ms,
+ delay: delay_ms,
+ })
+ }
+
+ /// Borrows the underlying descriptor for [`crate::ObjExt::set_style_transition`]. When `self` is
+ /// a `static`, the returned reference is `'static`.
+ pub fn as_dsc(&self) -> &ffi::lv_style_transition_dsc_t {
+ &self.0
+ }
+}
diff --git a/src/rust/bitbox-lvgl/src/widgets/class.rs b/src/rust/bitbox-lvgl/src/widgets/class.rs
index 4d05ba8..ea21c91 100644
--- a/src/rust/bitbox-lvgl/src/widgets/class.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/class.rs
@@ -46,6 +46,9 @@ pub struct ButtonmatrixTag;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyboardTag;
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct LineTag;
+
macro_rules! impl_lv_class {
($class:ty, $ffi_symbol:ident) => {
impl LvClass for $class {
@@ -69,6 +72,7 @@ impl_lv_class!(SpangroupTag, lv_spangroup_class);
impl_lv_class!(TextareaTag, lv_textarea_class);
impl_lv_class!(ButtonmatrixTag, lv_buttonmatrix_class);
impl_lv_class!(KeyboardTag, lv_keyboard_class);
+impl_lv_class!(LineTag, lv_line_class);
#[cfg(test)]
mod tests {
diff --git a/src/rust/bitbox-lvgl/src/widgets/line.rs b/src/rust/bitbox-lvgl/src/widgets/line.rs
new file mode 100644
index 0000000..4eea4bf
--- /dev/null
+++ b/src/rust/bitbox-lvgl/src/widgets/line.rs
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use alloc::vec::Vec;
+use core::ptr::NonNull;
+
+use super::util;
+use crate::{LvHandle, LvObj, LvPointPrecise, class, ffi};
+
+pub type LvLine = LvHandle<class::LineTag>;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LvLineError {
+ PointAttachmentFailed,
+}
+
+impl LvHandle<class::LineTag> {
+ pub fn new<P: class::LvClass>(parent: &LvHandle<P>) -> Option<Self> {
+ NonNull::new(unsafe { ffi::lv_line_create(parent.as_ptr()) }).map(LvHandle::from_ptr)
+ }
+
+ /// Sets the polyline points.
+ ///
+ /// LVGL only retains the pointer to the points (it does not copy them), so the points are
+ /// stored alongside the object and freed together with it. The line's color, width and rounded
+ /// caps are controlled with the `set_style_line_*` methods from [`crate::ObjExt`].
+ pub fn set_points(&self, points: Vec<LvPointPrecise>) -> Result<(), LvLineError> {
+ let point_num = points.len() as u32;
+ let attachment =
+ util::attach_to_object(self, points).map_err(|_| LvLineError::PointAttachmentFailed)?;
+ unsafe {
+ ffi::lv_line_set_points(self.as_ptr(), (*attachment.as_ptr()).as_ptr(), point_num)
+ }
+ Ok(())
+ }
+
+ pub fn to_obj(self) -> LvObj {
+ self.cast()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ObjExt;
+
+ #[test]
+ fn test_line_set_points_keeps_points_alive() {
+ let _lock = crate::test_util::lock_and_init();
+
+ let display = crate::LvDisplay::new(64, 64).unwrap();
+ let screen = display.screen_active().unwrap();
+ let line = LvLine::new(&screen).unwrap();
+ line.set_points(alloc::vec![
+ LvPointPrecise { x: 0, y: 0 },
+ LvPointPrecise { x: 10, y: 20 },
+ LvPointPrecise { x: 30, y: 5 },
+ ])
+ .unwrap();
+
+ // Deleting the object frees the attached points without leaking or double-freeing.
+ unsafe { line.delete() };
+ }
+}
diff --git a/src/rust/bitbox-lvgl/src/widgets/mod.rs b/src/rust/bitbox-lvgl/src/widgets/mod.rs
index a37cd6c..abceaee 100644
--- a/src/rust/bitbox-lvgl/src/widgets/mod.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/mod.rs
@@ -9,6 +9,7 @@ pub mod class;
pub mod image;
pub mod keyboard;
pub mod label;
+pub mod line;
pub mod obj;
pub mod slider;
pub mod span;
diff --git a/src/rust/bitbox-lvgl/src/widgets/obj.rs b/src/rust/bitbox-lvgl/src/widgets/obj.rs
index 720ece8..e4330fd 100644
--- a/src/rust/bitbox-lvgl/src/widgets/obj.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/obj.rs
@@ -8,7 +8,8 @@ use core::ptr::NonNull;
use crate::{
LvAlign, LvBaseDir, LvBlendMode, LvBorderSide, LvColor, LvEventCode, LvFlexAlign, LvFlexFlow,
- LvFont, LvGradDir, LvGridAlign, LvOpa, LvStyleSelector, LvTextAlign, LvTextDecor, class, ffi,
+ LvFont, LvGradDir, LvGridAlign, LvObjFlag, LvOpa, LvState, LvStyleSelector, LvTextAlign,
+ LvTextDecor, class, ffi,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -133,6 +134,32 @@ pub trait ObjExt {
unsafe { ffi::lv_obj_set_height(self.as_ptr(), height) }
}
+ /// Adds an object flag (e.g. [`LvObjFlag::LV_OBJ_FLAG_FLOATING`] to take the object out of its
+ /// parent's layout so it can be positioned absolutely). Other flags are unchanged.
+ fn add_flag(&self, flag: LvObjFlag) {
+ unsafe { ffi::lv_obj_add_flag(self.as_ptr(), flag) }
+ }
+
+ /// Removes an object flag. Other flags are unchanged.
+ fn remove_flag(&self, flag: LvObjFlag) {
+ unsafe { ffi::lv_obj_remove_flag(self.as_ptr(), flag) }
+ }
+
+ /// Adds one or more states (e.g. [`LvState::LV_STATE_PRESSED`]). Other state bits are unchanged.
+ fn add_state(&self, state: LvState) {
+ unsafe { ffi::lv_obj_add_state(self.as_ptr(), state) }
+ }
+
+ /// Removes one or more states. Other state bits are unchanged.
+ fn remove_state(&self, state: LvState) {
+ unsafe { ffi::lv_obj_remove_state(self.as_ptr(), state) }
+ }
+
+ /// Returns the child at `index`, or `None` if out of range.
+ fn child(&self, index: i32) -> Option<LvObj> {
+ NonNull::new(unsafe { ffi::lv_obj_get_child(self.as_ptr(), index) }).map(LvHandle::from_ptr)
+ }
+
fn add_event_cb<F>(&self, filter: LvEventCode, cb: F) -> Result<(), LvEventRegistrationError>
where
F: FnMut() + 'static,
diff --git a/src/rust/bitbox02-rust/src/hww/api/set_device_name.rs b/src/rust/bitbox02-rust/src/hww/api/set_device_name.rs
index 3c4217c..66dfa46 100644
--- a/src/rust/bitbox02-rust/src/hww/api/set_device_name.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/set_device_name.rs
@@ -12,6 +12,14 @@ pub async fn process(
hal: &mut impl crate::hal::Hal,
pb::SetDeviceNameRequest { name }: &pb::SetDeviceNameRequest,
) -> Result<Response, Error> {
+ // Simulator-only: a sentinel name shows the navigation-button demo screen instead of setting a
+ // name. Gated behind `simulator-graphical` so it cannot reach production firmware.
+ #[cfg(feature = "simulator-graphical")]
+ if name == "__demo_nav__" {
+ hal.ui().show_demo_nav_buttons().await;
+ return Ok(Response::Success(pb::Success {}));
+ }
+
if !util::name::validate(name, bitbox_hal::memory::DEVICE_NAME_MAX_LEN) {
return Err(Error::InvalidInput);
}
diff --git a/src/rust/bitbox03/icons/back.png b/src/rust/bitbox03/icons/back.png
new file mode 100644
index 0000000..348edbd
Binary files /dev/null and b/src/rust/bitbox03/icons/back.png differ
diff --git a/src/rust/bitbox03/icons/cancel.png b/src/rust/bitbox03/icons/cancel.png
new file mode 100644
index 0000000..3cfcdb3
Binary files /dev/null and b/src/rust/bitbox03/icons/cancel.png differ
diff --git a/src/rust/bitbox03/icons/cancel2.png b/src/rust/bitbox03/icons/cancel2.png
new file mode 100644
index 0000000..6e24695
Binary files /dev/null and b/src/rust/bitbox03/icons/cancel2.png differ
diff --git a/src/rust/bitbox03/icons/confirm.png b/src/rust/bitbox03/icons/confirm.png
new file mode 100644
index 0000000..b910e2a
Binary files /dev/null and b/src/rust/bitbox03/icons/confirm.png differ
diff --git a/src/rust/bitbox03/icons/next.png b/src/rust/bitbox03/icons/next.png
new file mode 100644
index 0000000..750facf
Binary files /dev/null and b/src/rust/bitbox03/icons/next.png differ
diff --git a/src/rust/bitbox03/src/ui.rs b/src/rust/bitbox03/src/ui.rs
index f4d9814..67e89b0 100644
--- a/src/rust/bitbox03/src/ui.rs
+++ b/src/rust/bitbox03/src/ui.rs
@@ -12,9 +12,11 @@ use tracing::info;
use util::futures::completion;
mod choice;
-mod confirm;
-mod enter_string;
-mod menu;
+pub mod confirm;
+pub mod demo;
+pub mod enter_string;
+pub mod menu;
+pub mod nav_button;
mod status;
const LOGO: &[u8] = include_bytes!("../splash.png");
@@ -59,6 +61,10 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
.await
}
+ async fn show_demo_nav_buttons(&mut self) {
+ self.with_result_screen(demo::build_demo_screen).await
+ }
+
async fn confirm_swap(
&mut self,
title: &str,
diff --git a/src/rust/bitbox03/src/ui/confirm.rs b/src/rust/bitbox03/src/ui/confirm.rs
index e6dc341..c98f526 100644
--- a/src/rust/bitbox03/src/ui/confirm.rs
+++ b/src/rust/bitbox03/src/ui/confirm.rs
@@ -2,12 +2,13 @@
use bitbox_hal::ui::{ConfirmParams, UserAbort};
use bitbox_lvgl::{
- self as lvgl, LabelExt, LvAlign, LvButton, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
- ObjExt,
+ self as lvgl, LabelExt, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, ObjExt,
};
use util::futures::completion::Responder;
-pub(super) fn build_confirm_screen(
+use super::nav_button::{NavIcon, build_nav_button};
+
+pub fn build_confirm_screen(
params: &ConfirmParams<'_>,
responder: Responder<Result<(), UserAbort>>,
) -> LvObj {
@@ -43,54 +44,28 @@ pub(super) fn build_confirm_screen(
let actions = LvObj::with_parent(&screen).unwrap();
actions.set_width(380);
- actions.set_height(72);
+ 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_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_pad_column(20, 0);
actions.set_style_margin_top(16, 0);
actions.set_style_border_width(0, 0);
actions.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
- let reject = LvButton::new(&actions).unwrap();
- reject.set_size(180, 72);
- reject.set_style_bg_color(lvgl::color::hex(0x30333a), 0);
- reject.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- reject.set_style_border_width(2, 0);
- reject.set_style_border_color(lvgl::color::white(), 0);
let reject_responder = responder.clone();
+ let reject = build_nav_button(&actions, NavIcon::Cancel);
reject
.add_click_cb(move || reject_responder.resolve(Err(UserAbort)))
.expect("failed to register reject callback");
- let reject_label = LvLabel::new(&reject).unwrap();
- reject_label.set_text("No").unwrap();
- reject_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- reject_label.set_style_text_color(lvgl::color::white(), 0);
- reject_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
- let accept = LvButton::new(&actions).unwrap();
- accept.set_size(180, 72);
- accept.set_style_bg_color(lvgl::color::white(), 0);
- accept.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- accept.set_style_border_width(2, 0);
- accept.set_style_border_color(lvgl::color::black(), 0);
+ let accept = build_nav_button(&actions, NavIcon::Confirm);
accept
.add_click_cb(move || responder.resolve(Ok(())))
.expect("failed to register accept callback");
- let accept_label = LvLabel::new(&accept).unwrap();
- accept_label.set_text("Yes").unwrap();
- accept_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- accept_label.set_style_text_color(lvgl::color::black(), 0);
- accept_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
screen
}
diff --git a/src/rust/bitbox03/src/ui/demo.rs b/src/rust/bitbox03/src/ui/demo.rs
new file mode 100644
index 0000000..976da59
--- /dev/null
+++ b/src/rust/bitbox03/src/ui/demo.rs
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! A demo screen showing all the navigation icon buttons, for visual/interaction testing in the
+//! simulator. The four nav buttons (Back / Next / Confirm / Cancel) just demonstrate their
+//! press-invert feedback and take no action; the top-right corner close button dismisses the demo.
+
+use bitbox_lvgl::{
+ self as lvgl, LabelExt, LvLabel, LvLabelLongMode, LvObj, LvObjFlag, LvOpacityLevel, ObjExt,
+};
+use util::futures::completion::Responder;
+
+use super::nav_button::{NavIcon, build_close_button, build_nav_button};
+
+pub fn build_demo_screen(responder: Responder<()>) -> 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_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ 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_left(50, 0);
+ screen.set_style_pad_right(50, 0);
+ screen.set_style_pad_row(40, 0);
+
+ let title = LvLabel::new(&screen).unwrap();
+ title.set_width(380);
+ title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ 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::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ // The four nav buttons in a row. They only demonstrate the press feedback (no callback), so the
+ // demo stays open until the corner close button is tapped.
+ let row = LvObj::with_parent(&screen).unwrap();
+ row.set_width(380);
+ row.set_height(82);
+ row.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ row.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ row.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN, 0);
+ row.set_style_flex_cross_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_CENTER, 0);
+ row.set_style_pad_top(0, 0);
+ row.set_style_pad_bottom(0, 0);
+ row.set_style_pad_left(0, 0);
+ row.set_style_pad_right(0, 0);
+ row.set_style_border_width(0, 0);
+ row.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ for icon in [
+ NavIcon::Back,
+ NavIcon::Next,
+ NavIcon::Confirm,
+ NavIcon::Cancel,
+ ] {
+ build_nav_button(&row, icon);
+ }
+
+ // A second row below: the corner-close (cancel2) glyph, for preview only. Clear its floating
+ // flag so it sits in the column flow (centred) instead of the corner, and wire no action.
+ build_close_button(&screen).remove_flag(LvObjFlag::LV_OBJ_FLAG_FLOATING);
+
+ let hint = LvLabel::new(&screen).unwrap();
+ hint.set_width(380);
+ hint.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ hint.set_text("Tap to preview. Close with the X.").unwrap();
+ hint.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
+ hint.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ // The corner close button is the only way out: it resolves the screen's responder.
+ let close = build_close_button(&screen);
+ close
+ .add_click_cb(move || responder.resolve(()))
+ .expect("failed to register close callback");
+
+ screen
+}
diff --git a/src/rust/bitbox03/src/ui/enter_string.rs b/src/rust/bitbox03/src/ui/enter_string.rs
index e58ca38..97fe252 100644
--- a/src/rust/bitbox03/src/ui/enter_string.rs
+++ b/src/rust/bitbox03/src/ui/enter_string.rs
@@ -10,6 +10,8 @@ use bitbox_lvgl::{
};
use util::futures::completion::Responder;
+use super::nav_button::{NavIcon, build_nav_button};
+
fn snapshot_text(textarea: &LvTextarea) -> String {
textarea
.get_text()
@@ -276,7 +278,7 @@ fn add_button<F>(
button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
}
-pub(super) fn build_enter_string_screen(
+pub fn build_enter_string_screen(
params: &EnterStringParams<'_>,
can_cancel: CanCancel,
preset: &str,
@@ -419,9 +421,11 @@ pub(super) fn build_enter_string_screen(
let actions = LvObj::with_parent(&screen).unwrap();
actions.set_width(380);
- actions.set_height(72);
+ 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);
@@ -434,42 +438,55 @@ pub(super) fn build_enter_string_screen(
LvPart::LV_PART_MAIN as u32,
);
- if matches!(can_cancel, CanCancel::Yes) {
- let cancel_label = if params.cancel_is_backbutton {
- "Back"
+ let cancel_present = matches!(can_cancel, CanCancel::Yes);
+ if cancel_present {
+ // Cancel / Back is always a tap action -> icon button.
+ let icon = if params.cancel_is_backbutton {
+ NavIcon::Back
} else {
- "Cancel"
+ NavIcon::Cancel
};
let reject_responder = responder.clone();
- add_button(&actions, 180, 72, cancel_label, false, false, move || {
- reject_responder.resolve(Err(UserAbort));
- });
+ let cancel = build_nav_button(&actions, icon);
+ cancel
+ .add_click_cb(move || {
+ reject_responder.resolve(Err(UserAbort));
+ })
+ .expect("failed to register cancel callback");
}
- let accept_label = if params.longtouch && matches!(can_cancel, CanCancel::No) {
- "Hold to confirm"
- } else if params.longtouch {
- "Hold"
- } else {
- "Confirm"
- };
- add_button(
- &actions,
- if matches!(can_cancel, CanCancel::Yes) {
- 180
+ if params.longtouch {
+ // The long-press confirm keeps its text instruction; an icon can't convey "hold".
+ let accept_label = if matches!(can_cancel, CanCancel::No) {
+ "Hold to confirm"
} else {
- 380
- },
- 72,
- accept_label,
- true,
- params.longtouch,
- move || {
- responder.resolve(Ok(zeroize::Zeroizing::new(snapshot_text(
- textarea.as_ref(),
- ))));
- },
- );
+ "Hold"
+ };
+ let accept_width = if cancel_present { 180 } else { 380 };
+ add_button(
+ &actions,
+ accept_width,
+ 72,
+ accept_label,
+ true,
+ true,
+ move || {
+ responder.resolve(Ok(zeroize::Zeroizing::new(snapshot_text(
+ textarea.as_ref(),
+ ))));
+ },
+ );
+ } else {
+ // Plain tap confirm -> icon button.
+ let accept = build_nav_button(&actions, NavIcon::Confirm);
+ accept
+ .add_click_cb(move || {
+ responder.resolve(Ok(zeroize::Zeroizing::new(snapshot_text(
+ textarea.as_ref(),
+ ))));
+ })
+ .expect("failed to register confirm callback");
+ }
screen
}
diff --git a/src/rust/bitbox03/src/ui/menu.rs b/src/rust/bitbox03/src/ui/menu.rs
index 90b851a..54d37bd 100644
--- a/src/rust/bitbox03/src/ui/menu.rs
+++ b/src/rust/bitbox03/src/ui/menu.rs
@@ -4,13 +4,14 @@ use alloc::format;
use bitbox_hal::ui::UserAbort;
use bitbox_lvgl::{
- self as lvgl, LabelExt, LvAlign, LvButton, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
- ObjExt,
+ self as lvgl, LabelExt, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel, ObjExt,
};
use util::futures::completion::Responder;
+use super::nav_button::{NavIcon, build_close_button, build_nav_button};
+
#[derive(Clone, Copy)]
-pub(super) enum MenuAction {
+pub enum MenuAction {
Previous,
Next,
Select,
@@ -24,51 +25,6 @@ pub(super) enum MenuResult {
Cancel(usize),
}
-fn add_button<F>(parent: &LvObj, width: i32, height: i32, label: &str, primary: bool, cb: F)
-where
- F: FnMut() + 'static,
-{
- let button = LvButton::new(parent).unwrap();
- button.set_size(width, height);
- button.set_style_bg_color(
- if primary {
- lvgl::color::white()
- } else {
- lvgl::color::hex(0x30333a)
- },
- 0,
- );
- button.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- button.set_style_border_width(2, 0);
- button.set_style_border_color(
- if primary {
- lvgl::color::black()
- } else {
- lvgl::color::white()
- },
- 0,
- );
- button
- .add_click_cb(cb)
- .expect("failed to register menu callback");
-
- let button_label = LvLabel::new(&button).unwrap();
- button_label.set_text(label).unwrap();
- button_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- button_label.set_style_text_color(
- if primary {
- lvgl::color::black()
- } else {
- lvgl::color::white()
- },
- 0,
- );
- button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
-}
-
fn transparent_row(parent: &LvObj, height: i32) -> LvObj {
let row = LvObj::with_parent(parent).unwrap();
row.set_width(380);
@@ -85,7 +41,7 @@ fn transparent_row(parent: &LvObj, height: i32) -> LvObj {
row
}
-pub(super) fn build_menu_screen(
+pub fn build_menu_screen(
words: &[&str],
title: Option<&str>,
index: usize,
@@ -134,65 +90,64 @@ pub(super) fn build_menu_screen(
let can_go_previous = index > 0;
let can_go_next = index + 1 < words.len();
if can_go_previous || can_go_next {
- let navigation = transparent_row(&screen, 64);
- let navigation_button_width = if can_go_previous && can_go_next {
- 180
- } else {
- 380
- };
+ let navigation = transparent_row(&screen, 82);
+ // Keep Back on the left and Next on the right, whichever are present.
+ navigation.set_style_flex_main_place(
+ match (can_go_previous, can_go_next) {
+ (true, true) => lvgl::LvFlexAlign::LV_FLEX_ALIGN_SPACE_BETWEEN,
+ (false, true) => lvgl::LvFlexAlign::LV_FLEX_ALIGN_END,
+ _ => lvgl::LvFlexAlign::LV_FLEX_ALIGN_START,
+ },
+ 0,
+ );
if can_go_previous {
let previous_responder = responder.clone();
- add_button(
- &navigation,
- navigation_button_width,
- 64,
- "Back",
- false,
- move || {
- previous_responder.resolve(MenuAction::Previous);
- },
- );
+ let back = build_nav_button(&navigation, NavIcon::Back);
+ back.add_click_cb(move || {
+ previous_responder.resolve(MenuAction::Previous);
+ })
+ .expect("failed to register previous callback");
}
if can_go_next {
let next_responder = responder.clone();
- add_button(
- &navigation,
- navigation_button_width,
- 64,
- "Next",
- false,
- move || {
- next_responder.resolve(MenuAction::Next);
- },
- );
+ let next = build_nav_button(&navigation, NavIcon::Next);
+ next.add_click_cb(move || {
+ next_responder.resolve(MenuAction::Next);
+ })
+ .expect("failed to register next callback");
}
}
- let actions = transparent_row(&screen, 72);
- let show_continue = continue_on_last && index + 1 == words.len();
- let show_primary = select_word || show_continue;
- let action_button_width = if show_primary { 180 } else { 380 };
-
+ // Cancel lives in the top-right corner so it doesn't crowd the bottom navigation.
let cancel_responder = responder.clone();
- add_button(
- &actions,
- action_button_width,
- 72,
- "Cancel",
- false,
- move || {
+ let close = build_close_button(&screen);
+ close
+ .add_click_cb(move || {
cancel_responder.resolve(MenuAction::Cancel);
- },
- );
+ })
+ .expect("failed to register cancel callback");
- if select_word {
- add_button(&actions, 180, 72, "Select", true, move || {
- responder.resolve(MenuAction::Select);
- });
- } else if show_continue {
- add_button(&actions, 180, 72, "Continue", true, move || {
- responder.resolve(MenuAction::Continue);
- });
+ let show_continue = continue_on_last && index + 1 == words.len();
+ if select_word || show_continue {
+ let actions = transparent_row(&screen, 82);
+ // Primary action sits on the right, under the Next button.
+ actions.set_style_flex_main_place(lvgl::LvFlexAlign::LV_FLEX_ALIGN_END, 0);
+ if select_word {
+ // Confirming the highlighted word.
+ let select = build_nav_button(&actions, NavIcon::Confirm);
+ select
+ .add_click_cb(move || {
+ responder.resolve(MenuAction::Select);
+ })
+ .expect("failed to register select callback");
+ } else {
+ // Advancing to the next step of the workflow.
+ let cont = build_nav_button(&actions, NavIcon::Next);
+ cont.add_click_cb(move || {
+ responder.resolve(MenuAction::Continue);
+ })
+ .expect("failed to register continue callback");
+ }
}
screen
diff --git a/src/rust/bitbox03/src/ui/nav_button.rs b/src/rust/bitbox03/src/ui/nav_button.rs
new file mode 100644
index 0000000..d8cc42a
--- /dev/null
+++ b/src/rust/bitbox03/src/ui/nav_button.rs
@@ -0,0 +1,165 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Square navigation icon buttons (Back / Next / Confirm / Cancel) plus a circular corner close
+//! button. The frame is a drawn LVGL outline (rounded square / circle); the glyph inside is a
+//! bitmap (PNG decoded to an `LvCanvas`). The bitmap is recoloured white normally and black when
+//! the button is pressed (the frame fills white on press), so the icon stays visible. The caller
+//! wires the behaviour, e.g. `add_click_cb`.
+
+use alloc::rc::Rc;
+use alloc::vec;
+use alloc::vec::Vec;
+
+use bitbox_lvgl::{
+ self as lvgl, LvButton, LvCanvas, LvEventCode, LvObj, LvOpacityLevel, LvState,
+ LvStyleTransition, ObjExt, style::prop,
+};
+
+/// Style selector for the pressed state.
+const PRESSED_SELECTOR: u32 = LvState::LV_STATE_PRESSED 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];
+/// Effectively instant (1ms) transition, overriding the default theme's ~80ms fade so the press
+/// highlight appears/clears immediately even on a very short tap.
+static PRESS_TRANSITION: LvStyleTransition = LvStyleTransition::new(&PRESS_TRANSITION_PROPS, 1, 0);
+
+/// Which navigation icon to show inside the button frame.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum NavIcon {
+ /// Left-pointing chevron — go back a step in a workflow.
+ Back,
+ /// Right-pointing chevron — advance to the next step in a workflow.
+ Next,
+ /// Checkmark — confirm at the end of a workflow.
+ Confirm,
+ /// Cross — cancel / abort.
+ Cancel,
+}
+
+impl NavIcon {
+ /// The icon bitmap (white glyph on a transparent background).
+ fn png(self) -> &'static [u8] {
+ match self {
+ NavIcon::Back => include_bytes!("../../icons/back.png"),
+ NavIcon::Next => include_bytes!("../../icons/next.png"),
+ NavIcon::Confirm => include_bytes!("../../icons/confirm.png"),
+ NavIcon::Cancel => include_bytes!("../../icons/cancel.png"),
+ }
+ }
+}
+
+/// Side length of the (square) navigation button, in pixels. Matches the 82×82 mockup viewBox.
+const SIZE: i32 = 82;
+/// Side length of the circular corner close button, in pixels (mockup viewBox).
+const CLOSE_SIZE: i32 = 37;
+/// The corner close button's icon (small cross).
+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 {
+ // `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() {
+ px.swap(0, 2);
+ }
+ let canvas = LvCanvas::new(button, data, header.width, header.height).expect("icon canvas");
+ canvas.align(lvgl::LvAlign::LV_ALIGN_CENTER, 0, 0);
+ // The PNG is a white glyph; recolour it white normally and black when pressed.
+ canvas.set_style_image_recolor_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ canvas.set_style_image_recolor(lvgl::color::white(), 0);
+ canvas.set_style_image_recolor(lvgl::color::black(), PRESSED_SELECTOR);
+ canvas.to_obj()
+}
+
+/// 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>) {
+ 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.
+ button.set_style_recolor_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, PRESSED_SELECTOR);
+ // The default theme also grows pressed objects; cancel it.
+ button.set_style_transform_width(0, PRESSED_SELECTOR);
+ button.set_style_transform_height(0, PRESSED_SELECTOR);
+ // Override the theme's ~80ms fade so the fill appears/clears instantly.
+ button.set_style_transition(Some(PRESS_TRANSITION.as_dsc()), PRESSED_SELECTOR);
+ button.set_style_transition(Some(PRESS_TRANSITION.as_dsc()), 0);
+
+ let parts = Rc::new(parts);
+ let on_press = Rc::clone(&parts);
+ button
+ .add_event_cb(LvEventCode::LV_EVENT_PRESSED, move || {
+ for part in on_press.iter() {
+ part.add_state(LvState::LV_STATE_PRESSED);
+ }
+ })
+ .expect("failed to register press callback");
+ let on_release = Rc::clone(&parts);
+ button
+ .add_event_cb(LvEventCode::LV_EVENT_RELEASED, move || {
+ for part in on_release.iter() {
+ part.remove_state(LvState::LV_STATE_PRESSED);
+ }
+ })
+ .expect("failed to register release callback");
+ button
+ .add_event_cb(LvEventCode::LV_EVENT_PRESS_LOST, move || {
+ for part in parts.iter() {
+ part.remove_state(LvState::LV_STATE_PRESSED);
+ }
+ })
+ .expect("failed to register press-lost callback");
+}
+
+/// 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) {
+ 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);
+ button.set_style_shadow_width(0, 0); // drop the default-theme shadow
+ button.set_style_pad_top(0, 0);
+ button.set_style_pad_bottom(0, 0);
+ button.set_style_pad_left(0, 0);
+ button.set_style_pad_right(0, 0);
+}
+
+/// Builds a navigation icon button and appends it to `parent`. Returns the button so the caller can
+/// attach a click handler and position it.
+pub fn build_nav_button(parent: &LvObj, icon: NavIcon) -> LvButton {
+ let button = LvButton::new(parent).unwrap();
+ button.set_size(SIZE, SIZE);
+ button.set_style_radius(19, 0); // mockup rx = 18.5
+ style_outline_button(&button, 3);
+
+ let icon_obj = add_icon(&button, icon.png());
+ enable_press_invert(&button, vec![icon_obj]);
+
+ button
+}
+
+/// Builds the small circular "close" (cancel) button for the top-right corner of workflow screens
+/// that already carry bottom navigation, so cancel doesn't crowd the Back/Next/Confirm row. It is
+/// marked floating (taken out of the parent's layout) and aligned to the parent's top-right corner.
+/// The caller wires the click handler.
+pub fn build_close_button(parent: &LvObj) -> LvButton {
+ let button = LvButton::new(parent).unwrap();
+ button.set_size(CLOSE_SIZE, CLOSE_SIZE);
+ button.set_style_radius(lvgl::ffi::LV_RADIUS_CIRCLE as i32, 0); // full circle
+ style_outline_button(&button, 2);
+
+ // Take it out of the parent's (flex) layout and pin it to the screen's top-right corner,
+ // ~12px from the edges. The offsets push it past the standard 50px side / 40px top screen
+ // padding (50-12 right, 12-40 up) so it hugs the real corner, clear of centred title text.
+ // Callers on screens with different padding can re-`align` the returned button.
+ button.add_flag(lvgl::LvObjFlag::LV_OBJ_FLAG_FLOATING);
+ button.align(lvgl::LvAlign::LV_ALIGN_TOP_RIGHT, 38, -28);
+
+ let icon_obj = add_icon(&button, CLOSE_PNG);
+ enable_press_invert(&button, vec![icon_obj]);
+
+ button
+}
Why this scored 19/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.