feat(core): enable menu to return arbitrary values
What changed, and why it matters
This commit is an internal user-interface refactor for Trezor firmware. It changes how on-screen menus are built so that each menu entry can carry an 'intent' (standard or danger) and can return arbitrary values, instead of only supporting a fixed cancel button. The commit message and diff show no new user-facing behavior; existing cancel buttons are converted into a new 'cancel leaf' abstraction. There is no direct evidence this introduces a security vulnerability, but any refactor that touches workflow cancellation paths deserves a careful look to ensure users can still abort dangerous actions.
Treat as a normal code-review item. Verify that every former `cancel=` path has been correctly converted to a `cancel_leaf` and that the resulting menu still raises the expected exception on user cancellation. Run UI/integration tests covering cancellation in signing workflows, especially on delizia and eckhart layouts where the intent-to-styling mapping is marked as temporary. No emergency action is warranted based on the supplied materials.
Security signals we found
Refactor of workflow cancellation UI paths
New 'danger' intent used to style destructive menu entries
Removal of dedicated cancel parameter in favor of generic leaf nodes
Changes to how menu results propagate (can now return arbitrary values)
No explicit security claim or CVE in commit message or references
Evidence from the diff
The patch rewrites the Python Menu/Details/Cancel classes into Menu/MenuLeaf/MenuResult and adds a Rust MenuItemIntent enum (Standard/Danger) propagated through the firmware UI trait (FirmwareUI::select_menu). Menu items are now (label, intent) tuples. The old cancel parameter of select_menu is removed; cancellation is now represented as a leaf node that raises ActionCancelled. All four layout implementations (bolt, caesar, delizia, eckhart) are updated, with bolt and delizia/eckhart mapping Danger to the existing cancel/dangerous styling. The refactor touches many call sites in caesar/__init__.py, delizia/__init__.py, and eckhart/__init__.py, replacing Menu.root(..., cancel=...) with Menu([...] + [cancel_leaf(...)]).
Changed components
core/embed/rust/src/ui/ui_firmware.rscore/embed/rust/src/ui/api/firmware_micropython.rscore/embed/rust/src/ui/layout_bolt/component/select_menu.rscore/embed/rust/src/ui/layout_bolt/ui_firmware.rscore/embed/rust/src/ui/layout_caesar/ui_firmware.rscore/embed/rust/src/ui/layout_delizia/ui_firmware.rscore/embed/rust/src/ui/layout_eckhart/ui_firmware.rscore/src/trezor/ui/layouts/menu.pycore/src/trezor/ui/layouts/caesar/__init__.pycore/src/trezor/ui/layouts/delizia/__init__.pycore/src/trezor/ui/layouts/eckhart/__init__.pyInspect captured patch +493 / −325
### core/embed/rust/librust_qstr.h
@@ -27,6 +27,7 @@ static void _librust_qstrs(void) {
MP_QSTR_CONFIRMED;
MP_QSTR_CheckBackup;
MP_QSTR_Close;
+ MP_QSTR_DANGER;
MP_QSTR_DIM;
MP_QSTR_DONE;
MP_QSTR_DeviceMenuResult;
@@ -48,6 +49,7 @@ static void _librust_qstrs(void) {
MP_QSTR_MESSAGE_READY;
MP_QSTR_MESSAGE_READY_ACK;
MP_QSTR_MESSAGE_WIRE_TYPE;
+ MP_QSTR_MenuItemIntent;
MP_QSTR_MessageType;
MP_QSTR_Msg;
MP_QSTR_MsgDef;
@@ -64,6 +66,7 @@ static void _librust_qstrs(void) {
MP_QSTR_RemoveWipeCode;
MP_QSTR_ReviewFailedBackup;
MP_QSTR_SEND_BUFFER_OVERHEAD;
+ MP_QSTR_STANDARD;
MP_QSTR_SUCCESS;
MP_QSTR_SWIPE_DOWN;
MP_QSTR_SWIPE_LEFT;
### core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -1,6 +1,6 @@
use heapless::Vec;
-use crate::error::Error;
+use crate::error::{value_error, Error};
use crate::io::BinaryData;
use crate::micropython::buffer::StrBuffer;
use crate::micropython::gc::Gc;
@@ -25,7 +25,8 @@ use crate::ui::layout::result::{BACK, CANCELLED, CONFIRMED, INFO};
use crate::ui::layout::util::{upy_disable_animation, RecoveryType};
use crate::ui::notification::{Notification, NotificationLevel, NOTIFICATION_LEVEL_OBJ};
use crate::ui::ui_firmware::{
- FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
+ MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS, MENU_ITEM_INTENT_OBJ,
};
use crate::ui::ModelUI;
@@ -739,14 +740,16 @@ extern "C" fn new_request_string(n_args: usize, args: *const Obj, kwargs: *mut M
extern "C" fn new_select_menu(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let items_iterable: Obj = kwargs.get(Qstr::MP_QSTR_items)?;
- let items = util::iter_into_vec(items_iterable)?;
+ let mut items = Vec::<(TString, MenuItemIntent), MAX_MENU_ITEMS>::new();
+ for item in IterBuf::new().try_iterate(items_iterable)? {
+ let [text, intent]: [Obj; 2] = util::iter_into_array(item)?;
+ items
+ .push((text.try_into()?, intent.try_into()?))
+ .map_err(|_| value_error!(c"Too many menu items"))?;
+ }
let current = kwargs.get(Qstr::MP_QSTR_current)?.try_into()?;
- let cancel = kwargs
- .get(Qstr::MP_QSTR_cancel)
- .and_then(Obj::try_into_option)
- .unwrap_or(None);
- let layout = ModelUI::select_menu(items, current, cancel)?;
+ let layout = ModelUI::select_menu(items, current)?;
Ok(LayoutObj::new_root(layout)?.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
@@ -1868,11 +1871,11 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def select_menu(
/// *,
- /// items: Iterable[str],
+ /// items: Iterable[tuple[str, int]],
/// current: int,
- /// cancel: str | None = None
- /// ) -> LayoutContext[int]:
- /// """Select an item from a menu. Returns index in range `0..len(items)`."""
+ /// ) -> LayoutContext[int | UiResult]:
+ /// """Select an item from a menu. Each item is its label and its
+ /// `MenuItemIntent`. Returns index in range `0..len(items)`."""
Qstr::MP_QSTR_select_menu => obj_fn_kw!(0, new_select_menu).as_obj(),
/// def select_word(
@@ -2191,6 +2194,12 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// SUCCESS: ClassVar[int]
Qstr::MP_QSTR_NotificationLevel => NOTIFICATION_LEVEL_OBJ.as_obj(),
+ /// class MenuItemIntent:
+ /// """What a menu entry means; each model renders it in its own way."""
+ /// STANDARD: ClassVar[int]
+ /// DANGER: ClassVar[int]
+ Qstr::MP_QSTR_MenuItemIntent => MENU_ITEM_INTENT_OBJ.as_obj(),
+
/// class LayoutState:
/// """Layout state."""
/// INITIAL: "ClassVar[LayoutState]"
### core/embed/rust/src/ui/layout_bolt/component/select_menu.rs
@@ -6,63 +6,55 @@ 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;
+use crate::ui::ui_firmware::{MenuItemIntent, 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).
+ /// Menu item selected (index into `items`).
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.
+/// Simple vertical menu of buttons, with a close button in the top-right
+/// corner. An entry asking for `MenuItemIntent::Danger` is styled accordingly.
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>>,
+ items: Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
) -> Result<Self, Error> {
- if items.len() + cancel.map_or(0, |_| 1) > 3 {
+ if items.len() > MAX_VISIBLE_BUTTONS {
return Err(Error::NotImplementedError);
}
let choice_buttons = items
.into_iter()
- .map(|text| Button::with_text(text).styled(theme::button_default()))
+ .map(|(text, intent)| {
+ Button::with_text(text).styled(match intent {
+ MenuItemIntent::Danger => theme::button_cancel(),
+ MenuItemIntent::Standard => 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.
+ /// Number of choice buttons that fit on the screen.
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)
+ self.choice_buttons.len().min(MAX_VISIBLE_BUTTONS)
}
}
@@ -91,10 +83,6 @@ impl Component for SelectMenu {
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
}
@@ -106,11 +94,6 @@ impl Component for SelectMenu {
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)
@@ -124,9 +107,6 @@ impl Component for SelectMenu {
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);
}
}
@@ -139,9 +119,6 @@ impl crate::trace::Trace for SelectMenu {
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
@@ -57,7 +57,6 @@ 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()),
### core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -32,8 +32,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
- MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
+ MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
@@ -719,11 +719,10 @@ impl FirmwareUI for UIBolt {
}
fn select_menu(
- items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
+ items: heapless::Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
_current: usize,
- cancel: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error> {
- let layout = RootComponent::new(SelectMenu::new(items, cancel)?);
+ let layout = RootComponent::new(SelectMenu::new(items)?);
Ok(layout)
}
### core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -32,8 +32,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
- MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
+ MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
@@ -912,17 +912,25 @@ impl FirmwareUI for UICaesar {
}
fn select_menu(
- items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
+ items: heapless::Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
current: usize,
- _cancel: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error> {
+ // the entry's intent is not rendered on this model
+ let mut labels = heapless::Vec::<TString<'static>, MAX_MENU_ITEMS>::new();
+ for (text, _intent) in items {
+ unwrap!(labels.push(text));
+ }
// Returning the index of the selected menu item
let layout = RootComponent::new(
- SimpleChoice::new(items, ChoiceControls::Cancellable, TR::buttons__view.into())
- .with_initial_page_counter(current)
- .with_show_incomplete()
- .with_return_index()
- .with_ignore_cancelled(),
+ SimpleChoice::new(
+ labels,
+ ChoiceControls::Cancellable,
+ TR::buttons__view.into(),
+ )
+ .with_initial_page_counter(current)
+ .with_show_incomplete()
+ .with_return_index()
+ .with_ignore_cancelled(),
);
Ok(layout)
}
### core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -39,8 +39,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ContentType, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
- MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
+ MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
@@ -718,34 +718,27 @@ impl FirmwareUI for UIDelizia {
}
fn select_menu(
- items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
- mut current: usize,
- cancel: Option<TString<'static>>,
+ items: heapless::Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
+ current: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut menu_items = VerticalMenuItems::new();
- if let Some(text) = cancel {
- unwrap!(menu_items.push(VerticalMenuItem::Cancel(text)));
- current += 1;
- }
- for text in items {
- unwrap!(menu_items.push(VerticalMenuItem::Item(text)));
+ for (text, intent) in items {
+ unwrap!(menu_items.push(match intent {
+ // TODO: mapping `Danger` onto the `Cancel` variant is temporary -
+ // the variant is named after what the entry did, not how it looks.
+ // `VerticalMenuItem` should carry the intent instead.
+ MenuItemIntent::Danger => VerticalMenuItem::Cancel(text),
+ MenuItemIntent::Standard => VerticalMenuItem::Item(text),
+ }));
}
let menu = ScrolledVerticalMenu::new(menu_items, current);
let frame = Frame::with_header(
Header::left_aligned(TString::empty()).with_cancel_button(),
menu,
);
- let layout = MsgMap::new(frame, move |msg| {
- let choice = match msg {
- FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => i,
- // `FlowMsg::Cancelled` should be sent only if `cancel` is not `None`
- FrameMsg::Button(_) => return Some(FlowMsg::Confirmed),
- };
- Some(match (choice, cancel) {
- (0, Some(_)) => FlowMsg::Cancelled,
- (1.., Some(_)) => FlowMsg::Choice(choice - 1),
- (_, None) => FlowMsg::Choice(choice),
- })
+ let layout = MsgMap::new(frame, move |msg| match msg {
+ FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
+ FrameMsg::Button(_) => Some(FlowMsg::Confirmed),
});
flow::util::single_page(layout)
}
### core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -41,8 +41,8 @@ use crate::ui::layout::util::{
};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
- MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
+ MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
use crate::util::interpolate;
@@ -878,30 +878,26 @@ impl FirmwareUI for UIEckhart {
}
fn select_menu(
- items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
+ items: heapless::Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
_current: usize,
- cancel: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut menu = VerticalMenu::<ShortMenuVec>::empty();
- for text in &items {
- menu.item(Button::new_menu_item(*text, theme::menu_item_title()));
- }
- if let Some(text) = cancel {
- menu.item(Button::new_cancel_menu_item(text));
+ for (text, intent) in &items {
+ menu.item(match intent {
+ // TODO: reusing the cancel button to render a dangerous entry is
+ // temporary - `Danger` describes how the entry looks, while
+ // `new_cancel_menu_item` names what it used to do. The styling
+ // should be lifted out of the cancel-specific constructor.
+ MenuItemIntent::Danger => Button::new_cancel_menu_item(*text),
+ MenuItemIntent::Standard => Button::new_menu_item(*text, theme::menu_item_title()),
+ });
}
let screen = VerticalMenuScreen::new(menu)
.with_header(Header::new(TString::empty()).with_close_button())
- .map(move |msg| {
- let choice = match msg {
- VerticalMenuScreenMsg::Selected(i) => i,
- VerticalMenuScreenMsg::Close => return Some(FlowMsg::Confirmed),
- _ => return None,
- };
- Some(if cancel.is_some() && choice == items.len() {
- FlowMsg::Cancelled
- } else {
- FlowMsg::Choice(choice)
- })
+ .map(move |msg| match msg {
+ VerticalMenuScreenMsg::Selected(i) => Some(FlowMsg::Choice(i)),
+ VerticalMenuScreenMsg::Close => Some(FlowMsg::Confirmed),
+ _ => None,
});
flow::util::single_page(screen)
### core/embed/rust/src/ui/ui_firmware.rs
@@ -8,6 +8,13 @@ use crate::micropython::buffer::StrBuffer;
use crate::micropython::gc::Gc;
use crate::micropython::list::List;
use crate::micropython::obj::Obj;
+#[cfg(feature = "micropython")]
+use crate::micropython::{
+ macros::{obj_dict, obj_map, obj_type},
+ qstr::Qstr,
+ simple_type::SimpleTypeObj,
+ typ::FullType,
+};
use crate::strutil::TString;
use crate::ui::notification::Notification;
@@ -18,6 +25,48 @@ pub const MAX_MENU_ITEMS: usize = 5;
pub const MAX_PAIRED_DEVICES: usize = 8; // Maximum number of paired devices in the device menu
+/// What a menu entry means, which each model renders in its own way.
+#[repr(u8)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum MenuItemIntent {
+ /// An ordinary entry.
+ Standard = 0,
+ /// An entry with destructive consequences, e.g. cancelling a signature.
+ Danger = 1,
+}
+
+impl TryFrom<u8> for MenuItemIntent {
+ type Error = Error;
+ fn try_from(value: u8) -> Result<Self, Self::Error> {
+ match value {
+ 0 => Ok(MenuItemIntent::Standard),
+ 1 => Ok(MenuItemIntent::Danger),
+ _ => Err(Error::OutOfRange),
+ }
+ }
+}
+
+#[cfg(feature = "micropython")]
+impl TryFrom<Obj> for MenuItemIntent {
+ type Error = Error;
+
+ fn try_from(obj: Obj) -> Result<Self, Self::Error> {
+ Self::try_from(u8::try_from(obj)?)
+ }
+}
+
+#[cfg(feature = "micropython")]
+static MENU_ITEM_INTENT_TYPE: FullType = obj_type! {
+ name: Qstr::MP_QSTR_MenuItemIntent,
+ locals: &obj_dict!(obj_map! {
+ Qstr::MP_QSTR_STANDARD => Obj::small_int(MenuItemIntent::Standard as u16),
+ Qstr::MP_QSTR_DANGER => Obj::small_int(MenuItemIntent::Danger as u16),
+ }),
+};
+
+#[cfg(feature = "micropython")]
+pub static MENU_ITEM_INTENT_OBJ: SimpleTypeObj = SimpleTypeObj::new(&MENU_ITEM_INTENT_TYPE);
+
pub trait FirmwareUI {
#[allow(clippy::too_many_arguments)]
fn confirm_action(
@@ -276,10 +325,10 @@ pub trait FirmwareUI {
prefill: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error>;
+ /// Each item is its label plus what the entry means.
fn select_menu(
- items: heapless::Vec<TString<'static>, MAX_MENU_ITEMS>,
+ items: heapless::Vec<(TString<'static>, MenuItemIntent), MAX_MENU_ITEMS>,
current: usize,
- cancel: Option<TString<'static>>,
) -> Result<impl LayoutMaybeTrace, Error>;
fn select_word(
### core/mocks/generated/trezorui_api.pyi
@@ -528,11 +528,11 @@ def request_string(
# rust/src/ui/api/firmware_micropython.rs
def select_menu(
*,
- items: Iterable[str],
+ items: Iterable[tuple[str, int]],
current: int,
- cancel: str | None = None
-) -> LayoutContext[int]:
- """Select an item from a menu. Returns index in range `0..len(items)`."""
+) -> LayoutContext[int | UiResult]:
+ """Select an item from a menu. Each item is its label and its
+ `MenuItemIntent`. Returns index in range `0..len(items)`."""
# rust/src/ui/api/firmware_micropython.rs
@@ -882,6 +882,13 @@ class NotificationLevel:
SUCCESS: ClassVar[int]
+# rust/src/ui/api/firmware_micropython.rs
+class MenuItemIntent:
+ """What a menu entry means; each model renders it in its own way."""
+ STANDARD: ClassVar[int]
+ DANGER: ClassVar[int]
+
+
# rust/src/ui/api/firmware_micropython.rs
class LayoutState:
"""Layout state."""
### core/src/apps/debug/n1w1_mock.py
@@ -68,7 +68,7 @@ async def confirm_connect(
"""Show a layout waiting for N1W1 connection, allowing cancellation."""
from trezor import TR
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
from trezorui_api import show_info
self_ctx: N1W1Context = self
@@ -97,7 +97,7 @@ async def _task() -> None:
) as main:
return await confirm_with_menu(
main,
- Menu.root(cancel=TR.buttons__cancel),
+ Menu([cancel_leaf(TR.buttons__cancel)]),
br_name=br_name,
layout_type=_Connect,
)
### core/src/trezor/ui/layouts/caesar/__init__.py
@@ -16,7 +16,7 @@
from apps.stellar.tokens import StellarToken
from ..common import ExceptionType, PropertyType, StrPropertyType
- from ..menu import Details
+ from ..menu import MenuLeaf
from ..properties import AboveThreshold
from ..slip24 import Refund, Trade
@@ -583,7 +583,9 @@ async def _task() -> None:
menu_items = []
if recipient_address is not None:
menu_items.append(
- create_details(TR.address__title_provider_address, recipient_address)
+ create_info_menu_leaf(
+ TR.address__title_provider_address, recipient_address
+ )
)
for refund in refunds:
refund_account_items: list[StrPropertyType] = [("", refund.address, None)]
@@ -594,13 +596,13 @@ async def _task() -> None:
(TR.address_details__derivation_path, refund.account_path, None)
)
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address__title_refund_address,
refund_account_items,
)
)
if menu_items:
- menu = Menu.root(menu_items)
+ menu = Menu(menu_items)
with trezorui_api.confirm_with_info(
title=title,
@@ -640,11 +642,11 @@ async def _task() -> None:
)
summary_menu_items = [
- create_details(TR.confirm_total__title_fee, fee_info_items),
- create_details(TR.address_details__account_info, account_items),
+ create_info_menu_leaf(TR.confirm_total__title_fee, fee_info_items),
+ create_info_menu_leaf(TR.address_details__account_info, account_items),
]
- summary_menu = Menu.root(summary_menu_items)
+ summary_menu = Menu(summary_menu_items)
with summary_ctx as summary_layout:
await confirm_with_menu(
@@ -967,7 +969,7 @@ async def confirm_value(
br_code,
)
- from trezor.ui.layouts.menu import Details, Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, confirm_with_menu, leaf_from_layout
if chunkify:
main_ctx = trezorui_api.confirm_address(
@@ -1001,8 +1003,8 @@ def item_factory(
chunkify=chunkify_info,
)
- menu = Menu.root(
- Details.from_layout(name or "", item_factory(name or "", value or ""))
+ menu = Menu(
+ leaf_from_layout(name or "", item_factory(name or "", value or ""))
for name, value, _is_data in info_items
)
with main_ctx as main:
@@ -1068,10 +1070,12 @@ async def confirm_trade(
account_items.append(
(TR.address_details__derivation_path, trade.account_path, None)
)
- menu_items = [create_details(TR.address__title_receive_address, account_items)]
+ menu_items = [
+ create_info_menu_leaf(TR.address__title_receive_address, account_items)
+ ]
for k, v in extra_menu_items:
- menu_items.append(create_details(k, v))
- menu = Menu.root(menu_items)
+ menu_items.append(create_info_menu_leaf(k, v))
+ menu = Menu(menu_items)
with trade_ctx as trade_layout:
await confirm_with_menu(trade_layout, menu, "confirm_trade")
@@ -1477,7 +1481,7 @@ async def confirm_ethereum_eip7702_auth(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
with trezorui_api.show_warning(
title=TR.words__warning,
@@ -1515,12 +1519,12 @@ async def confirm_ethereum_eip7702_auth(
)
)
children = [
- create_details(TR.address_details__account_info, account_info),
- create_details(TR.buttons__more_info, more_info),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.buttons__more_info, more_info),
]
await confirm_with_menu(
layout,
- Menu.root(children, cancel=TR.buttons__cancel),
+ Menu(children + [cancel_leaf(TR.buttons__cancel)]),
"ethereum/auth7702/details",
ButtonRequestType.SignTx,
)
@@ -1531,21 +1535,21 @@ async def confirm_ethereum_eip7702_revoke(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
account_info = with_colon(
(
(TR.words__account, account, False),
(TR.address_details__derivation_path, account_path, False),
)
)
- menu = Menu.root(
+ menu = Menu(
children=[
- create_details(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
# TODO: switch to non-Cardano specific string
- create_details(TR.cardano__nonce, str(nonce)),
+ create_info_menu_leaf(TR.cardano__nonce, str(nonce)),
+ cancel_leaf(TR.buttons__cancel),
],
- cancel=TR.buttons__cancel,
)
with trezorui_api.confirm_action(
@@ -1681,8 +1685,9 @@ async def confirm_solana_staking_tx(
fee_label="",
external_menu=True,
)
- menu = Menu.root(
- create_details(name or "", value or "") for name, value, _is_data in items
+ menu = Menu(
+ create_info_menu_leaf(name or "", value or "")
+ for name, value, _is_data in items
)
with main_ctx as main:
await confirm_with_menu(main, menu, br_name, br_code)
@@ -1704,7 +1709,7 @@ async def confirm_solana_staking_tx(
(TR.confirm_total__title_fee, fee_details),
(TR.address_details__account_info, account_details),
]
- menu = Menu.root(create_details(name, props) for name, props in iter)
+ menu = Menu(create_info_menu_leaf(name, props) for name, props in iter)
with main_ctx as main:
await confirm_with_menu(main, menu, br_name, br_code)
@@ -2573,9 +2578,11 @@ async def confirm_firmware_update(description: str, fingerprint: str) -> None:
)
-def create_details(name: str, value: Sequence[StrPropertyType] | str) -> Details:
- from trezor.ui.layouts.menu import Details
+def create_info_menu_leaf(
+ name: str, value: Sequence[StrPropertyType] | str
+) -> MenuLeaf[None]:
+ from trezor.ui.layouts.menu import leaf_from_layout
- return Details.from_layout(
+ return leaf_from_layout(
name, lambda: trezorui_api.show_properties(title=name, value=value)
)
### core/src/trezor/ui/layouts/delizia/__init__.py
@@ -21,7 +21,7 @@
from apps.stellar.tokens import StellarToken
from ..common import ExceptionType, PropertyType, StrPropertyType
- from ..menu import Details
+ from ..menu import MenuLeaf
from ..properties import AboveThreshold
from ..slip24 import Refund, Trade
@@ -53,7 +53,7 @@ async def confirm_action(
prompt_screen: bool = False,
prompt_title: str | None = None,
) -> ui.UiResult:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
if description is not None and description_param is not None:
description = description.format(description_param)
@@ -82,9 +82,7 @@ async def confirm_action(
exc,
)
else:
- menu = Menu.root(
- cancel=verb_cancel or TR.buttons__cancel,
- )
+ menu = Menu([cancel_leaf(verb_cancel or TR.buttons__cancel, exc)])
return await interact_with_menu(
flow,
@@ -488,7 +486,7 @@ async def confirm_payment_request(
fee_info_items: Sequence[StrPropertyType] | None,
extra_menu_items: list[tuple[str, str]] | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
from ..slip24 import is_swap
@@ -517,7 +515,7 @@ async def confirm_payment_request(
menu_items = []
if recipient_address is not None:
menu_items.append(
- create_details(TR.address__title_provider_address, recipient_address)
+ create_info_menu_leaf(TR.address__title_provider_address, recipient_address)
)
for refund in refunds:
refund_account_items: list[StrPropertyType] = [("", refund.address, None)]
@@ -528,12 +526,12 @@ async def confirm_payment_request(
(TR.address_details__derivation_path, refund.account_path, None)
)
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address__title_refund_address,
refund_account_items,
)
)
- menu = Menu.root(menu_items, TR.buttons__cancel_sign)
+ menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
with main_ctx as main_layout:
await confirm_with_menu(main_layout, menu, "confirm_payment_request")
@@ -839,7 +837,7 @@ async def confirm_value(
) -> ui.UiResult:
"""General confirmation dialog, used by many other confirm_* functions."""
- from trezor.ui.layouts.menu import Cancel, Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
main_ctx = trezorui_api.confirm_value(
title=title,
@@ -857,13 +855,15 @@ async def confirm_value(
menu_items = []
for name, p, page_title in info_items or []:
- menu_items.append(create_details(name, p, page_title))
- menu = Menu.root(
- menu_items,
- cancel=Cancel.from_layout(
- name=(cancel_text or TR.buttons__cancel),
- layout_factory=trezorui_api.confirm_cancel,
- ),
+ menu_items.append(create_info_menu_leaf(name, p, page_title))
+ menu = Menu(
+ menu_items
+ + [
+ cancel_leaf(
+ cancel_text or TR.buttons__cancel,
+ confirm=trezorui_api.confirm_cancel,
+ )
+ ]
)
with main_ctx as main_layout:
return await interact_with_menu(main_layout, menu, br_name, br_code)
@@ -968,7 +968,7 @@ async def confirm_trade(
trade: Trade,
extra_menu_items: list[tuple[str, str]],
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
trade_ctx = trezorui_api.confirm_trade(
title=title,
@@ -984,10 +984,12 @@ async def confirm_trade(
account_items.append(
(TR.address_details__derivation_path, trade.account_path, None)
)
- menu_items = [create_details(TR.address__title_receive_address, account_items)]
+ menu_items = [
+ create_info_menu_leaf(TR.address__title_receive_address, account_items)
+ ]
for k, v in extra_menu_items:
- menu_items.append(create_details(k, v))
- menu = Menu.root(menu_items, TR.buttons__cancel_sign)
+ menu_items.append(create_info_menu_leaf(k, v))
+ menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
with trade_ctx as trade_layout:
await confirm_with_menu(trade_layout, menu, "confirm_trade")
@@ -1345,13 +1347,13 @@ async def confirm_ethereum_vault_tx(
br_code: ButtonRequestType = ButtonRequestType.SignTx,
extra_data: str | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_info_items = _get_account_info_items(account, account_path)
if account_info_items:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_info_items[0][1],
title=TR.address_details__account_info,
@@ -1369,7 +1371,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/intro",
ButtonRequestType.SignTx,
)
@@ -1384,7 +1386,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/vault_name",
)
@@ -1400,7 +1402,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/amount",
br_code,
)
@@ -1418,7 +1420,7 @@ async def _step4() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/extra_data",
br_code,
)
@@ -1438,7 +1440,7 @@ async def _step5() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/summary",
br_code,
)
@@ -1458,13 +1460,13 @@ async def confirm_ethereum_vault_claim(
br_name: str,
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_info_items = _get_account_info_items(account, account_path)
if account_info_items:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_info_items[0][1],
title=TR.address_details__account_info,
@@ -1482,7 +1484,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/intro",
br_code,
)
@@ -1498,7 +1500,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/tokens",
br_code,
)
@@ -1516,7 +1518,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/summary",
br_code,
)
@@ -1531,7 +1533,7 @@ async def confirm_ethereum_eip7702_auth(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
with trezorui_api.show_warning(
title=TR.words__warning,
@@ -1561,12 +1563,12 @@ async def confirm_ethereum_eip7702_auth(
(TR.cardano__nonce, str(nonce), False),
]
children = [
- create_details(TR.address_details__account_info, account_info),
- create_details(TR.buttons__more_info, more_info),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.buttons__more_info, more_info),
]
await confirm_with_menu(
layout,
- Menu.root(children, cancel=TR.buttons__cancel),
+ Menu(children + [cancel_leaf(TR.buttons__cancel)]),
"ethereum/auth7702/details",
ButtonRequestType.SignTx,
)
@@ -1577,18 +1579,18 @@ async def confirm_ethereum_eip7702_revoke(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
account_info = [
(TR.words__account, account, True),
(TR.address_details__derivation_path, account_path, True),
]
- menu = Menu.root(
+ menu = Menu(
children=[
- create_details(TR.address_details__account_info, account_info),
- create_details(TR.cardano__nonce, str(nonce)),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.cardano__nonce, str(nonce)),
+ cancel_leaf(TR.buttons__cancel),
],
- cancel=TR.buttons__cancel,
)
with trezorui_api.confirm_action(
@@ -1854,13 +1856,13 @@ async def confirm_tron_claim(
br_name: str = "tron/claim",
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_info_items = _get_account_info_items(account, account_path)
if account_info_items:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_info_items[0][1],
title=TR.address_details__account_info,
@@ -1877,7 +1879,7 @@ async def confirm_tron_claim(
) as layout:
await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
br_name,
br_code,
)
@@ -2215,7 +2217,7 @@ async def confirm_signverify(
account: str | None = None,
chunkify: bool = False,
) -> None:
- from trezor.ui.layouts.menu import Cancel, Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
if verify:
address_title = TR.sign_message__verify_address
@@ -2233,26 +2235,28 @@ async def confirm_signverify(
external_menu=True,
)
- items: list[Details] = []
+ items: list[MenuLeaf] = []
if account is not None:
- items.append(create_details(TR.words__account, account))
+ items.append(create_info_menu_leaf(TR.words__account, account))
if path is not None:
- items.append(create_details(TR.address_details__derivation_path, path))
+ items.append(create_info_menu_leaf(TR.address_details__derivation_path, path))
items.append(
- create_details(
+ create_info_menu_leaf(
TR.sign_message__message_size,
TR.sign_message__bytes_template.format(len(message)),
)
)
- menu = Menu.root(
- items,
- cancel=Cancel.from_layout(
- name=TR.buttons__cancel,
- layout_factory=lambda: trezorui_api.show_mismatch(
- title=TR.addr_mismatch__mismatch
- ),
- ),
+ menu = Menu(
+ items
+ + [
+ cancel_leaf(
+ TR.buttons__cancel,
+ confirm=lambda: trezorui_api.show_mismatch(
+ title=TR.addr_mismatch__mismatch
+ ),
+ )
+ ]
)
with address_ctx as address_layout:
@@ -2481,14 +2485,14 @@ async def tutorial(br_code: ButtonRequestType = BR_CODE_OTHER) -> None:
return await raise_if_not_confirmed(layout, "tutorial", br_code)
-def create_details(
+def create_info_menu_leaf(
name: str,
value: Sequence[StrPropertyType] | str,
title: str | None = None,
-) -> Details:
- from trezor.ui.layouts.menu import Details
+) -> MenuLeaf[None]:
+ from trezor.ui.layouts.menu import leaf_from_layout
- return Details.from_layout(
+ return leaf_from_layout(
name,
lambda: trezorui_api.show_properties(title=(title or name), value=value),
)
### core/src/trezor/ui/layouts/eckhart/__init__.py
@@ -18,7 +18,7 @@
from collections.abc import Awaitable, Iterable, Sequence
from typing import NoReturn, TypeVar
- from trezor.ui.layouts.menu import Details
+ from trezor.ui.layouts.menu import MenuLeaf
from apps.stellar.tokens import StellarToken
@@ -71,9 +71,9 @@ async def confirm_action(
prompt_title=prompt_title or title,
external_menu=True,
) as layout:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
- menu = Menu.root(cancel=verb_cancel or TR.buttons__cancel)
+ menu = Menu([cancel_leaf(verb_cancel or TR.buttons__cancel, exc)])
return await confirm_with_menu(
layout, menu, br_name, br_code, raise_on_cancel=exc
)
@@ -452,7 +452,7 @@ async def confirm_payment_request(
fee_info_items: Sequence[StrPropertyType] | None,
extra_menu_items: list[tuple[str, str]] | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
from ..slip24 import is_swap
@@ -485,7 +485,7 @@ async def confirm_payment_request(
menu_items = []
if recipient_address is not None:
menu_items.append(
- create_details(TR.address__title_provider_address, recipient_address)
+ create_info_menu_leaf(TR.address__title_provider_address, recipient_address)
)
for refund in refunds:
refund_account_info: list[StrPropertyType] = [("", refund.address, True)]
@@ -496,12 +496,12 @@ async def confirm_payment_request(
(TR.address_details__derivation_path, refund.account_path, True)
)
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address__title_refund_address,
refund_account_info,
)
)
- menu = Menu.root(menu_items, TR.buttons__cancel_sign)
+ menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
with main_ctx as main_layout:
while True:
@@ -571,7 +571,7 @@ async def confirm_output(
cancel_text: str | None = None,
description: str | None = None,
) -> None:
- from trezor.ui.layouts.menu import Cancel, Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
if address_label is not None:
title = address_label
@@ -597,7 +597,7 @@ async def confirm_output(
)
if account_properties:
menu_items = [
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -607,12 +607,14 @@ async def confirm_output(
else:
menu_items = []
- menu = Menu.root(
- menu_items,
- cancel=Cancel.from_layout(
- name=TR.buttons__cancel,
- layout_factory=trezorui_api.confirm_cancel,
- ),
+ menu = Menu(
+ menu_items
+ + [
+ cancel_leaf(
+ TR.buttons__cancel,
+ confirm=trezorui_api.confirm_cancel,
+ )
+ ]
)
address_ctx = trezorui_api.confirm_value(
@@ -839,14 +841,18 @@ async def confirm_value(
footer: tuple[str, bool] | None = None,
) -> None:
"""General confirmation dialog, used by many other confirm_* functions."""
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
menu_items = (
- [create_details(info_title or TR.words__title_information, list(info_items))]
+ [
+ create_info_menu_leaf(
+ info_title or TR.words__title_information, list(info_items)
+ )
+ ]
if info_items
else []
)
- menu = Menu.root(menu_items, TR.buttons__cancel)
+ menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel)])
with trezorui_api.confirm_value(
title=title,
@@ -970,7 +976,7 @@ async def confirm_trade(
extra_menu_items: list[tuple[str, str]],
back_button: bool,
) -> ui.UiResult:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
trade_ctx = trezorui_api.confirm_trade(
title=title,
@@ -987,10 +993,12 @@ async def confirm_trade(
account_info.append(
(TR.address_details__derivation_path, trade.account_path, True)
)
- menu_items = [create_details(TR.address__title_receive_address, account_info)]
+ menu_items = [
+ create_info_menu_leaf(TR.address__title_receive_address, account_info)
+ ]
for k, v in extra_menu_items:
- menu_items.append(create_details(k, v))
- menu = Menu.root(menu_items, TR.buttons__cancel_sign)
+ menu_items.append(create_info_menu_leaf(k, v))
+ menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
with trade_ctx as trade_layout:
return await interact_with_menu(trade_layout, menu, "confirm_trade")
@@ -1082,7 +1090,7 @@ async def confirm_ethereum_tx(
chunkify: bool = False,
native_amount: str | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
if native_amount is not None:
# A non-zero native ETH value carried alongside a token transfer;
@@ -1099,7 +1107,7 @@ async def confirm_ethereum_tx(
account_properties = _get_account_info_items(account, account_path)
if account_properties:
menu_items = [
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -1124,7 +1132,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
"confirm_output",
br_code,
)
@@ -1337,13 +1345,13 @@ async def confirm_ethereum_vault_tx(
br_code: ButtonRequestType = ButtonRequestType.SignTx,
extra_data: str | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_properties = _get_account_info_items(account, account_path)
if account_properties:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -1361,7 +1369,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/intro",
br_code,
)
@@ -1375,7 +1383,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/vault_name",
br_code,
)
@@ -1392,7 +1400,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/amount",
br_code,
)
@@ -1410,7 +1418,7 @@ async def _step4() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/extra_data",
br_code,
)
@@ -1430,7 +1438,7 @@ async def _step5() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/summary",
br_code,
)
@@ -1451,13 +1459,13 @@ async def confirm_ethereum_vault_claim(
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_properties = _get_account_info_items(account, account_path)
if account_properties:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -1475,7 +1483,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/intro",
br_code,
)
@@ -1491,7 +1499,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/tokens",
br_code,
)
@@ -1509,7 +1517,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
f"{br_name}/summary",
br_code,
)
@@ -1524,7 +1532,7 @@ async def confirm_ethereum_eip7702_auth(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
with trezorui_api.show_warning(
title=TR.words__warning,
@@ -1556,12 +1564,12 @@ async def confirm_ethereum_eip7702_auth(
(TR.cardano__nonce, str(nonce), False),
]
children = [
- create_details(TR.address_details__account_info, account_info),
- create_details(TR.buttons__more_info, more_info),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.buttons__more_info, more_info),
]
await confirm_with_menu(
layout,
- Menu.root(children, cancel=TR.buttons__cancel),
+ Menu(children + [cancel_leaf(TR.buttons__cancel)]),
"ethereum/auth7702/details",
ButtonRequestType.SignTx,
)
@@ -1572,19 +1580,19 @@ async def confirm_ethereum_eip7702_revoke(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
account_info = [
(TR.words__account, account, False),
(TR.address_details__derivation_path, account_path, False),
]
- menu = Menu.root(
+ menu = Menu(
children=[
- create_details(TR.address_details__account_info, account_info),
+ create_info_menu_leaf(TR.address_details__account_info, account_info),
# TODO: switch to non-Cardano specific string
- create_details(TR.cardano__nonce, str(nonce)),
+ create_info_menu_leaf(TR.cardano__nonce, str(nonce)),
+ cancel_leaf(TR.buttons__cancel),
],
- cancel=TR.buttons__cancel,
)
with trezorui_api.confirm_action(
@@ -1627,18 +1635,18 @@ async def confirm_ethereum_staking_tx(
br_name: str = "confirm_ethereum_staking_tx",
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
assert verb in (
TR.ethereum__staking_claim,
TR.ethereum__staking_stake,
TR.ethereum__staking_unstake,
)
- menu_items = [create_details(address_title, address, None)]
+ menu_items = [create_info_menu_leaf(address_title, address, None)]
account_properties = _get_account_info_items(account, account_path)
if account_properties:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -1663,7 +1671,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
br_name,
ButtonRequestType.SignTx,
)
@@ -1744,10 +1752,10 @@ async def confirm_solana_staking_tx(
br_name: str = "confirm_solana_staking_tx",
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = [
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
_get_account_info_items(account, account_path),
title=TR.address_details__account_info,
@@ -1756,14 +1764,14 @@ async def confirm_solana_staking_tx(
]
if stake_item:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
stake_item[0] or "", [(None, stake_item[1], stake_item[2])], None
)
)
summary_menu_items = [
- create_details(blockhash_item[0] or "", [blockhash_item], None),
- create_details(TR.confirm_total__title_fee, list(fee_details), None),
+ create_info_menu_leaf(blockhash_item[0] or "", [blockhash_item], None),
+ create_info_menu_leaf(TR.confirm_total__title_fee, list(fee_details), None),
]
extra = TR.words__provider if vote_account else ""
@@ -1793,7 +1801,7 @@ async def _step1() -> trezorui_api.UiResult:
with ctx as layout:
return await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
br_name,
br_code,
)
@@ -1810,7 +1818,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu.root(summary_menu_items, TR.buttons__cancel),
+ Menu(summary_menu_items + [cancel_leaf(TR.buttons__cancel)]),
br_name,
br_code,
)
@@ -1961,13 +1969,13 @@ async def confirm_tron_claim(
br_name: str = "tron/claim",
br_code: ButtonRequestType = ButtonRequestType.SignTx,
) -> None:
- from trezor.ui.layouts.menu import Menu, interact_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, interact_with_menu
menu_items = []
account_properties = _get_account_info_items(account, account_path)
if account_properties:
menu_items.append(
- create_details(
+ create_info_menu_leaf(
TR.address_details__account_info,
account_properties,
title=TR.address_details__account_info,
@@ -1984,7 +1992,7 @@ async def confirm_tron_claim(
) as layout:
await interact_with_menu(
layout,
- Menu.root(menu_items, TR.buttons__cancel_sign),
+ Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
br_name,
br_code,
)
@@ -2606,15 +2614,15 @@ async def tutorial(br_code: ButtonRequestType = BR_CODE_OTHER) -> None:
return await raise_if_not_confirmed(layout, "tutorial", br_code)
-def create_details(
+def create_info_menu_leaf(
name: str,
value: Sequence[StrPropertyType] | str,
title: str | None = None,
subtitle: str | None = None,
-) -> Details:
- from trezor.ui.layouts.menu import Details
+) -> MenuLeaf[None]:
+ from trezor.ui.layouts.menu import leaf_from_layout
- return Details.from_layout(
+ return leaf_from_layout(
name,
lambda: trezorui_api.show_properties(
title=(title or name), subtitle=subtitle, value=value
### core/src/trezor/ui/layouts/menu.py
@@ -8,130 +8,239 @@
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable, Sequence
- from typing import TypeVar
+ from typing import Generic, overload
- from typing_extensions import Self
+ from typing_extensions import Never, TypeAlias, TypeVar
from .common import ExceptionType
T = TypeVar("T")
+ # value produced by a menu leaf and propagated out of the tree
+ R = TypeVar("R", default=None, covariant=True)
+ # a node of the menu tree: either a subtree, or a leaf
+ MenuNode: TypeAlias = "Menu[R] | MenuLeaf[R]"
+else:
+ R = 0
+ Generic = {R: object}
-async def _cancel_default() -> trezorui_api.UiResult:
- return trezorui_api.CONFIRMED
+class Menu(Generic[R]):
+ # a subtree is always an ordinary entry (see `MenuLeaf.intent`)
+ intent = trezorui_api.MenuItemIntent.STANDARD
-class Menu:
def __init__(
self,
- name: str,
- children: Sequence["Details"],
- cancel: "Cancel | None" = None,
+ children: "Iterable[MenuNode[R]]" = (),
+ name: str = "",
) -> None:
self.name = name
- self.children = children
- self.cancel = cancel
+ self.children: "Sequence[MenuNode[R]]" = tuple(children)
+
- @classmethod
- def root(
- cls, children: Iterable["Details"] = (), cancel: "str | Cancel | None" = None
- ) -> Self:
- if isinstance(cancel, str):
- cancel = Cancel(cancel, _cancel_default)
- return cls("", children=tuple(children), cancel=cancel)
+class MenuLeaf(Generic[R]):
+ """A leaf node of the menu tree.
+ `interact()` returns `None` to indicate that the menu tree should be resumed
+ (one level up), or a value of type `R`, which is propagated out of the whole
+ tree by `show_menu()`.
+ """
-class Details:
- def __init__(self, name: str, interact: Callable[[], Awaitable[T]]) -> None:
+ def __init__(
+ self,
+ name: str,
+ interact: Callable[[], Awaitable[R | None]],
+ *,
+ intent: int = trezorui_api.MenuItemIntent.STANDARD,
+ ) -> None:
self.name = name
self._interact = interact
+ # what this entry means; each layout renders it in its own way
+ self.intent = intent
- @classmethod
- def from_layout(
- cls, name: str, layout_factory: Callable[[], trezorui_api.LayoutContext[T]]
- ) -> Self:
- """IMPORTANT: `layout_factory()` MUST create a new layout on each invocation."""
- async def _interact() -> T:
- with layout_factory() as obj:
- # details' layout is de-allocated after interact() returns.
- return await interact(obj, br_name=None, raise_on_cancel=None)
+def leaf_from_layout(
+ name: str,
+ layout_factory: Callable[[], trezorui_api.LayoutContext[R]],
+ *,
+ return_result: bool = False,
+ br_name: str | None = None,
+ br_code: ButtonRequestType = ButtonRequestType.Other,
+ raise_on_cancel: ExceptionType | None = None,
+) -> "MenuLeaf[R]":
+ """IMPORTANT: `layout_factory()` MUST create a new layout on each invocation.
+
+ Unless `return_result` is set, the layout's result is discarded and the menu
+ tree is resumed. Otherwise the result is returned by `show_menu()`, so
+ `layout_factory()` must produce a layout whose result type matches the tree's.
+ """
+
+ async def _interact() -> "R | None":
+ with layout_factory() as obj:
+ # the leaf's layout is de-allocated after interact() returns.
+ result = await interact(obj, br_name, br_code, raise_on_cancel)
+ if result is trezorui_api.CANCELLED:
+ # `raise_on_cancel` is None by default, so cancelling returns the
+ # sentinel instead of raising. Resume the tree rather than handing
+ # `CANCELLED` to the caller as if it were a value of type `R`.
+ #
+ # TODO: `CANCELLED` is the wrong signal here. Dismissing a leaf's
+ # layout - the close button in its header - means "go back to the
+ # menu", not "abort the workflow", but the screens have no way to
+ # say so: `TextScreenMsg` only has `Cancelled`, and `BACK` (which
+ # already exists next to CONFIRMED/CANCELLED/INFO) is never
+ # produced. So every caller has to re-interpret the same sentinel
+ # for itself. Fixing it properly means teaching the action
+ # vocabulary to distinguish "dismissed" from "aborted".
+ return None
+ return result if return_result else None
- return cls(name, _interact)
+ return MenuLeaf(name, _interact)
-class Cancel(Details):
- pass
+def cancel_leaf(
+ name: str,
+ exc: ExceptionType = ActionCancelled,
+ *,
+ confirm: "Callable[[], trezorui_api.LayoutContext[trezorui_api.UiResult]] | None" = None,
+) -> "MenuLeaf[Never]":
+ """A menu entry that aborts the workflow.
+
+ Selecting it raises `exc`. If `confirm` is given, that layout is shown first
+ and the workflow is aborted only if the user confirms it; otherwise the menu
+ is resumed, as with any leaf that returns `None`.
+ """
+
+ async def _interact() -> None:
+ if confirm is not None:
+ with confirm() as obj:
+ result = await interact(obj, br_name=None, raise_on_cancel=None)
+ if result is not trezorui_api.CONFIRMED:
+ return None # declined - back to the menu
+ raise exc
+
+ return MenuLeaf(name, _interact, intent=trezorui_api.MenuItemIntent.DANGER)
+
+
+class MenuResult(Generic[R]):
+ """A value produced by a menu leaf, paired with the leaf that produced it."""
+
+ def __init__(self, leaf: "MenuLeaf[R]", value: R) -> None:
+ self.leaf = leaf
+ self.value = value
async def show_menu(
- root: Menu,
- raise_on_cancel: ExceptionType = ActionCancelled,
-) -> None:
- menu_path = []
+ root: Menu[R],
+) -> MenuResult[R] | None:
+ """Walk the menu tree until a leaf produces a value, or the user leaves the root.
+
+ Returns the leaf and the value it produced, so that the caller can tell the
+ leaves apart, or `None` if the tree was left without producing a value.
+ """
+ menu_path: list[int] = []
current_item = 0
while True:
- menu = root
+ menu: MenuNode[R] = root
for i in menu_path:
+ assert isinstance(menu, Menu)
menu = menu.children[i]
if isinstance(menu, Menu):
with trezorui_api.select_menu(
- items=[child.name for child in menu.children],
+ items=[(child.name, child.intent) for child in menu.children],
current=current_item,
- cancel=menu.cancel and menu.cancel.name,
) as layout:
choice = await interact(layout, br_name=None, raise_on_cancel=None)
- if choice is trezorui_api.CANCELLED:
- if menu.cancel:
- result = await menu.cancel._interact()
- assert result in (trezorui_api.CONFIRMED, trezorui_api.CANCELLED)
- if result is trezorui_api.CONFIRMED:
- # cancellation is confirmed - raise an exception
- raise raise_on_cancel
- # cancellation is not confirmed - back to the menu
- continue
- elif isinstance(choice, int):
+ if isinstance(choice, int):
# go one level down
menu_path.append(choice)
current_item = 0
continue
else:
- assert isinstance(menu, Details)
- # Details' layout is created on-demand (saving memory)
- await menu._interact() # the result is ignored
+ # the leaf's layout is created on-demand (saving memory)
+ leaf_result = await menu._interact()
+ if leaf_result is not None:
+ # the leaf produced a value - leave the whole tree
+ return MenuResult(menu, leaf_result)
- # go one level up, or exit the menu
+ # go one level up, or exit the tree
if menu_path:
current_item = menu_path.pop()
else:
- return
+ return None
+
+
+if TYPE_CHECKING:
+ # TEMPORARY COMPATIBILITY SHIM - to be removed.
+ #
+ # `interact_with_menu()` returns `T | MenuResult[R]`, but most call sites still
+ # declare `-> UiResult` and hand the result straight back, so the union does not
+ # typecheck there. Every one of them passes an info-only menu (`create_info_menu_leaf()`
+ # leaves, which discard their layout's result), so the first overload keeps them
+ # on the old plain-`T` typing while the second serves menus that do produce a
+ # value.
+ #
+ # Drop both overloads once those call sites handle `MenuResult` themselves.
+ # To find them, delete the overloads and let the typechecker enumerate the
+ # failures - every value-less menu today.
+ #
+ # Note the overload is picked from the menu's *declared* type: annotating a
+ # value-less menu as anything but `Menu[None]` pushes its caller onto the
+ # second overload.
+
+ @overload
+ async def interact_with_menu(
+ main: trezorui_api.LayoutObj[T],
+ menu: "Menu[None]",
+ br_name: str | None,
+ br_code: ButtonRequestType = ButtonRequestType.Other,
+ raise_on_cancel: ExceptionType = ActionCancelled,
+ *,
+ layout_type: type[Layout] = Layout,
+ ) -> T:
+ """The menu cannot produce a value, so only the main layout's result."""
+
+ @overload
+ async def interact_with_menu(
+ main: trezorui_api.LayoutObj[T],
+ menu: "Menu[R]",
+ br_name: str | None,
+ br_code: ButtonRequestType = ButtonRequestType.Other,
+ raise_on_cancel: ExceptionType = ActionCancelled,
+ *,
+ layout_type: type[Layout] = Layout,
+ ) -> T | MenuResult[R]:
+ """Either the main layout's result, or a leaf and the value it produced."""
async def interact_with_menu(
main: trezorui_api.LayoutObj[T],
- menu: Menu,
+ menu: Menu[R],
br_name: str | None,
br_code: ButtonRequestType = ButtonRequestType.Other,
raise_on_cancel: ExceptionType = ActionCancelled,
*,
layout_type: type[Layout] = Layout,
-) -> T:
+) -> T | MenuResult[R]:
while True:
result = await interact(
main, br_name, br_code, raise_on_cancel, layout_type=layout_type
)
br_name = None # ButtonRequest should be sent once (for the main layout)
if result is trezorui_api.INFO:
- await show_menu(menu, raise_on_cancel)
+ menu_result = await show_menu(menu)
+ if menu_result is not None:
+ return menu_result
+ # the tree was left without a value - back to the main layout
else:
return result
async def confirm_with_menu(
main: trezorui_api.LayoutObj[T],
- menu: Menu,
+ menu: Menu[None],
br_name: str | None,
br_code: ButtonRequestType = ButtonRequestType.Other,
raise_on_cancel: ExceptionType = ActionCancelled,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.