feat(core/bolt): implement Rust select_menu
What changed, and why it matters
This commit adds a new Rust-based on-screen menu component for the Trezor hardware wallet's Bolt user interface. It is a straightforward feature implementation and does not fix or introduce any obvious security issue. The component is not yet wired into any user-facing flows.
No security action required. Treat as normal feature code review; ensure bounds checks and Micropython object conversions are validated when this component is later integrated into flows.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit implements FirmwareUI::select_menu in the layout_bolt Rust UI layer. It introduces a SelectMenu component that renders up to three vertical buttons, an optional cancel button, and a close button, mapping click events to Selected, Cancelled, or Closed messages. The implementation is gated behind the micropython feature and is currently unused in flows. It returns Error::NotImplementedError if more than three total items are requested, pending pagination support.
Changed components
core/embed/rust/src/ui/layout_bolt/component/select_menu.rscore/embed/rust/src/ui/layout_bolt/component/mod.rscore/embed/rust/src/ui/layout_bolt/component_msg_obj.rscore/embed/rust/src/ui/layout_bolt/ui_firmware.rsInspect captured patch +172 / −6
### core/embed/rust/src/ui/layout_bolt/component/mod.rs
@@ -27,6 +27,8 @@ mod page;
mod progress;
mod result;
mod scroll;
+#[cfg(feature = "micropython")]
+mod select_menu;
#[cfg(feature = "storage")]
mod set_brightness;
#[cfg(feature = "translations")]
@@ -64,6 +66,8 @@ pub use page::ButtonPage;
pub use progress::Progress;
pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use scroll::ScrollBar;
+#[cfg(feature = "micropython")]
+pub use select_menu::{SelectMenu, SelectMenuMsg};
#[cfg(feature = "storage")]
pub use set_brightness::SetBrightnessDialog;
#[cfg(feature = "translations")]
### core/embed/rust/src/ui/layout_bolt/component/select_menu.rs
@@ -0,0 +1,148 @@
+use heapless::Vec;
+
+use super::{theme, Button, ButtonMsg};
+use crate::error::Error;
+use crate::strutil::TString;
+use crate::ui::component::{Component, Event, EventCtx};
+use crate::ui::geometry::{Insets, Rect};
+use crate::ui::shape::Renderer;
+use crate::ui::ui_firmware::MAX_MENU_ITEMS;
+
+/// Maximum number of buttons shown on the screen at once.
+/// TODO: pagination for menus with more items.
+const MAX_VISIBLE_BUTTONS: usize = 3;
+
+#[cfg_attr(feature = "debug", derive(ufmt::derive::uDebug))]
+pub enum SelectMenuMsg {
+ /// Menu item selected (index into `items`, excluding the cancel item).
+ Selected(usize),
+ /// The cancel menu item was selected.
+ Cancelled,
+ /// The menu was closed without selecting anything.
+ Closed,
+}
+
+/// Simple vertical menu of buttons, with an optional cancel item at the
+/// bottom and a close button in the top-right corner.
+pub struct SelectMenu {
+ choice_buttons: Vec<Button, MAX_MENU_ITEMS>,
+ cancel_button: Option<Button>,
+ close_button: Button,
+}
+
+impl SelectMenu {
+ pub fn new(
+ items: Vec<TString<'static>, MAX_MENU_ITEMS>,
+ cancel: Option<TString<'static>>,
+ ) -> Result<Self, Error> {
+ if items.len() + cancel.map_or(0, |_| 1) > 3 {
+ return Err(Error::NotImplementedError);
+ }
+ let choice_buttons = items
+ .into_iter()
+ .map(|text| Button::with_text(text).styled(theme::button_default()))
+ .collect();
+ let cancel_button =
+ cancel.map(|text| Button::with_text(text).styled(theme::button_cancel()));
+ let close_button =
+ Button::with_icon(theme::ICON_CORNER_CANCEL).styled(theme::button_moreinfo());
+
+ Ok(Self {
+ choice_buttons,
+ cancel_button,
+ close_button,
+ })
+ }
+
+ /// Number of choice buttons that fit on the screen. The cancel button is
+ /// always visible, so it reserves a slot for itself.
+ fn visible_choices(&self) -> usize {
+ let max = if self.cancel_button.is_some() {
+ MAX_VISIBLE_BUTTONS - 1
+ } else {
+ MAX_VISIBLE_BUTTONS
+ };
+ self.choice_buttons.len().min(max)
+ }
+}
+
+impl Component for SelectMenu {
+ type Msg = SelectMenuMsg;
+
+ fn place(&mut self, bounds: Rect) -> Rect {
+ let bounds = bounds.inset(theme::borders());
+
+ // Close button in the top-right corner, same as in `Frame`.
+ let (_, button_area) = bounds.split_right(theme::CORNER_BUTTON_SIDE);
+ let (button_area, _) = button_area.split_top(theme::CORNER_BUTTON_SIDE);
+ self.close_button.place(button_area);
+
+ let content = bounds.inset(Insets::top(
+ theme::CORNER_BUTTON_SIDE + theme::BUTTON_SPACING,
+ ));
+
+ // Buttons are stacked from the top down in fixed-height slots (same
+ // height as in `select_word`), so they never stretch to fill the
+ // whole area.
+ let mut slots = content;
+ let n_choices = self.visible_choices();
+ for button in self.choice_buttons.iter_mut().take(n_choices) {
+ let (slot, rest) = slots.split_top(theme::BUTTON_HEIGHT);
+ button.place(slot);
+ slots = rest.inset(Insets::top(theme::BUTTON_SPACING));
+ }
+ if let Some(cancel) = &mut self.cancel_button {
+ let (slot, _) = slots.split_top(theme::BUTTON_HEIGHT);
+ cancel.place(slot);
+ }
+
+ bounds
+ }
+
+ fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ let n_choices = self.visible_choices();
+ for (i, button) in self.choice_buttons.iter_mut().take(n_choices).enumerate() {
+ if matches!(button.event(ctx, event), Some(ButtonMsg::Clicked)) {
+ return Some(SelectMenuMsg::Selected(i));
+ }
+ }
+ if let Some(cancel) = &mut self.cancel_button {
+ if matches!(cancel.event(ctx, event), Some(ButtonMsg::Clicked)) {
+ return Some(SelectMenuMsg::Cancelled);
+ }
+ }
+ if matches!(
+ self.close_button.event(ctx, event),
+ Some(ButtonMsg::Clicked)
+ ) {
+ return Some(SelectMenuMsg::Closed);
+ }
+ None
+ }
+
+ fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ for button in self.choice_buttons.iter().take(self.visible_choices()) {
+ button.render(target);
+ }
+ if let Some(cancel) = &self.cancel_button {
+ cancel.render(target);
+ }
+ self.close_button.render(target);
+ }
+}
+
+#[cfg(feature = "ui_debug")]
+impl crate::trace::Trace for SelectMenu {
+ fn trace(&self, t: &mut dyn crate::trace::Tracer) {
+ t.component("SelectMenu");
+ t.in_list("buttons", &|button_list| {
+ for button in self.choice_buttons.iter().take(self.visible_choices()) {
+ button_list.child(button);
+ }
+ if let Some(cancel) = &self.cancel_button {
+ button_list.child(cancel);
+ }
+ });
+ t.child("close_button", &self.close_button);
+ }
+}
### core/embed/rust/src/ui/layout_bolt/component_msg_obj.rs
@@ -5,7 +5,8 @@ use super::component::{
DialogMsg, FidoConfirm, FidoMsg, Frame, FrameMsg, Homescreen, HomescreenMsg, IconDialog,
Lockscreen, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputDialog,
NumberInputDialogMsg, PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg,
- Progress, SelectWordCountMsg, SelectWordMsg, SetBrightnessDialog, SimplePage,
+ Progress, SelectMenu, SelectMenuMsg, SelectWordCountMsg, SelectWordMsg, SetBrightnessDialog,
+ SimplePage,
};
use crate::error::Error;
use crate::micropython::obj::Obj;
@@ -52,6 +53,18 @@ impl TryFrom<SelectWordMsg> for Obj {
}
}
+impl ComponentMsgObj for SelectMenu {
+ fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
+ match msg {
+ SelectMenuMsg::Selected(i) => i.try_into(),
+ SelectMenuMsg::Cancelled => Ok(CANCELLED.as_obj()),
+ // Closing the menu without a choice is a confirmation, not a
+ // cancellation (same as on other layouts).
+ SelectMenuMsg::Closed => Ok(CONFIRMED.as_obj()),
+ }
+ }
+}
+
impl TryFrom<SelectWordCountMsg> for Obj {
type Error = Error;
### core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -4,8 +4,8 @@ use super::component::{
check_homescreen_format, AddressDetails, Bip39Input, Button, ButtonMsg, ButtonPage,
ButtonStyleSheet, CancelConfirmMsg, CoinJoinProgress, Dialog, FidoConfirm, Frame, Homescreen,
IconDialog, Lockscreen, MnemonicKeyboard, NumberInputDialog, PassphraseKeyboard, PinKeyboard,
- Progress, SelectWordCount, SelectWordCountLayout, SetBrightnessDialog, ShareWords, SimplePage,
- Slip39Input,
+ Progress, SelectMenu, SelectWordCount, SelectWordCountLayout, SetBrightnessDialog, ShareWords,
+ SimplePage, Slip39Input,
};
use super::{fonts, theme, UIBolt};
use crate::error::{value_error, Error};
@@ -719,11 +719,12 @@ impl FirmwareUI for UIBolt {
}
fn select_menu(
- _items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
+ items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
_current: usize,
- _cancel: Option<TString<'static>>,
+ cancel: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error> {
- Err::<RootComponent<Empty, ModelUI>, Error>(Error::NotImplementedError)
+ let layout = RootComponent::new(SelectMenu::new(items, cancel)?);
+ Ok(layout)
}
fn select_word(Why this scored 15/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.