1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-02-21 03:52:04 +00:00

feat(eckhart): Add full-screen device menu component

device menu cont
This commit is contained in:
Lukas Bielesch 2025-02-12 11:05:12 +01:00
parent b84a983678
commit 4c7aec0635
17 changed files with 499 additions and 6 deletions

View File

@ -208,6 +208,7 @@ static void _librust_qstrs(void) {
MP_QSTR_confirm_value;
MP_QSTR_confirm_value_intro;
MP_QSTR_confirm_with_info;
MP_QSTR_connections;
MP_QSTR_continue_recovery_homepage;
MP_QSTR_count;
MP_QSTR_current;
@ -220,6 +221,19 @@ static void _librust_qstrs(void) {
MP_QSTR_deinit;
MP_QSTR_description;
MP_QSTR_details_title;
MP_QSTR_device_menu;
MP_QSTR_device_menu__1_connection;
MP_QSTR_device_menu__about;
MP_QSTR_device_menu__active_connections;
MP_QSTR_device_menu__backup_failed_description;
MP_QSTR_device_menu__backup_failed_title;
MP_QSTR_device_menu__battery_low_description;
MP_QSTR_device_menu__battery_low_title;
MP_QSTR_device_menu__bluetooth;
MP_QSTR_device_menu__brightness;
MP_QSTR_device_menu__connections_title;
MP_QSTR_device_menu__fw_version;
MP_QSTR_device_menu__language;
MP_QSTR_device_name__change_template;
MP_QSTR_device_name__title;
MP_QSTR_disable_animation;
@ -235,6 +249,7 @@ static void _librust_qstrs(void) {
MP_QSTR_extra;
MP_QSTR_extra_items;
MP_QSTR_extra_title;
MP_QSTR_failed_backup;
MP_QSTR_fee;
MP_QSTR_fee_items;
MP_QSTR_fee_label;
@ -317,6 +332,7 @@ static void _librust_qstrs(void) {
MP_QSTR_lockscreen__tap_to_unlock;
MP_QSTR_lockscreen__title_locked;
MP_QSTR_lockscreen__title_not_connected;
MP_QSTR_low_battery;
MP_QSTR_max_count;
MP_QSTR_max_feerate;
MP_QSTR_max_len;

View File

@ -1383,6 +1383,18 @@ pub enum TranslatedString {
#[cfg(feature = "universal_fw")]
ethereum__unknown_contract_address_short = 974, // "Unknown contract address."
reset__share_words_first = 975, // "Write down the first word from the backup."
device_menu__1_connection = 976, // "1 active connection"
device_menu__about = 977, // "About"
device_menu__active_connections = 978, // "{0} active connections"
device_menu__backup_failed_description = 979, // "Review"
device_menu__backup_failed_title = 980, // "Backup failed"
device_menu__battery_low_description = 981, // "Recharge soon"
device_menu__battery_low_title = 982, // "Battery low"
device_menu__bluetooth = 983, // "Bluetooth management"
device_menu__brightness = 984, // "Brightness"
device_menu__connections_title = 985, // "Pair & Connect"
device_menu__fw_version = 986, // "Firmware version"
device_menu__language = 987, // "Language"
}
impl TranslatedString {
@ -2776,6 +2788,18 @@ impl TranslatedString {
#[cfg(feature = "universal_fw")]
Self::ethereum__unknown_contract_address_short => "Unknown contract address.",
Self::reset__share_words_first => "Write down the first word from the backup.",
Self::device_menu__1_connection => "1 active connection",
Self::device_menu__about => "About",
Self::device_menu__active_connections => "{0} active connections",
Self::device_menu__backup_failed_description => "Review",
Self::device_menu__backup_failed_title => "Backup failed",
Self::device_menu__battery_low_description => "Recharge soon",
Self::device_menu__battery_low_title => "Battery low",
Self::device_menu__bluetooth => "Bluetooth management",
Self::device_menu__brightness => "Brightness",
Self::device_menu__connections_title => "Pair & Connect",
Self::device_menu__fw_version => "Firmware version",
Self::device_menu__language => "Language",
}
}
@ -4154,6 +4178,18 @@ impl TranslatedString {
#[cfg(feature = "universal_fw")]
Qstr::MP_QSTR_ethereum__unknown_contract_address_short => Some(Self::ethereum__unknown_contract_address_short),
Qstr::MP_QSTR_reset__share_words_first => Some(Self::reset__share_words_first),
Qstr::MP_QSTR_device_menu__1_connection => Some(Self::device_menu__1_connection),
Qstr::MP_QSTR_device_menu__about => Some(Self::device_menu__about),
Qstr::MP_QSTR_device_menu__active_connections => Some(Self::device_menu__active_connections),
Qstr::MP_QSTR_device_menu__backup_failed_description => Some(Self::device_menu__backup_failed_description),
Qstr::MP_QSTR_device_menu__backup_failed_title => Some(Self::device_menu__backup_failed_title),
Qstr::MP_QSTR_device_menu__battery_low_description => Some(Self::device_menu__battery_low_description),
Qstr::MP_QSTR_device_menu__battery_low_title => Some(Self::device_menu__battery_low_title),
Qstr::MP_QSTR_device_menu__bluetooth => Some(Self::device_menu__bluetooth),
Qstr::MP_QSTR_device_menu__brightness => Some(Self::device_menu__brightness),
Qstr::MP_QSTR_device_menu__connections_title => Some(Self::device_menu__connections_title),
Qstr::MP_QSTR_device_menu__fw_version => Some(Self::device_menu__fw_version),
Qstr::MP_QSTR_device_menu__language => Some(Self::device_menu__language),
_ => None,
}
}

View File

@ -419,6 +419,19 @@ extern "C" fn new_continue_recovery_homepage(
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_device_menu(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let failed_backup: bool = kwargs.get(Qstr::MP_QSTR_failed_backup)?.try_into()?;
let low_battery: bool = kwargs.get(Qstr::MP_QSTR_low_battery)?.try_into()?;
let connections: TString = kwargs.get(Qstr::MP_QSTR_connections)?.try_into()?;
let layout = ModelUI::device_menu(failed_backup, low_battery, connections)?;
Ok(LayoutObj::new_root(layout)?.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_flow_confirm_output(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let title: Option<TString> = kwargs.get(Qstr::MP_QSTR_title)?.try_into_option()?;
@ -1318,6 +1331,16 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// """Device recovery homescreen."""
Qstr::MP_QSTR_continue_recovery_homepage => obj_fn_kw!(0, new_continue_recovery_homepage).as_obj(),
/// def device_menu(
/// *,
/// failed_backup: bool,
/// low_battery: bool,
/// connections: str | None,
/// ) -> LayoutObj[int]:
/// """Show eckhart device menu."""
Qstr::MP_QSTR_device_menu => obj_fn_kw!(0, new_device_menu).as_obj(),
/// def flow_confirm_output(
/// *,
/// title: str | None,

View File

@ -536,6 +536,14 @@ impl FirmwareUI for UIBolt {
}
}
fn device_menu(
_failed_backup: bool,
_low_battery: bool,
_connections: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<Gc<LayoutObj>, Error>(Error::ValueError(c"not implemented"))
}
fn flow_confirm_output(
_title: Option<TString<'static>>,
_subtitle: Option<TString<'static>>,

View File

@ -671,6 +671,14 @@ impl FirmwareUI for UICaesar {
LayoutObj::new_root(layout)
}
fn device_menu(
_failed_backup: bool,
_low_battery: bool,
_connections: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<Gc<LayoutObj>, Error>(Error::ValueError(c"not implemented"))
}
fn flow_confirm_output(
_title: Option<TString<'static>>,
_subtitle: Option<TString<'static>>,

View File

@ -507,6 +507,14 @@ impl FirmwareUI for UIDelizia {
LayoutObj::new_root(flow)
}
fn device_menu(
_failed_backup: bool,
_low_battery: bool,
_connections: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<Gc<LayoutObj>, Error>(Error::ValueError(c"not implemented"))
}
fn flow_confirm_output(
title: Option<TString<'static>>,
subtitle: Option<TString<'static>>,

View File

@ -0,0 +1,143 @@
use crate::ui::{
component::{base::AttachType, Component, Event, EventCtx},
geometry::Rect,
layout_eckhart::{
component::{Button, VerticalMenuScreen, VerticalMenuScreenMsg, MENU_MAX_ITEMS},
constant::SCREEN,
theme,
},
shape::{self, Renderer},
};
use heapless::Vec;
// Max number of menu screens
const MAX_MENUS: usize = 5;
type VerticalMenus = Vec<Button, MAX_MENUS>;
pub enum DeviceMenuMsg {
Selected(usize),
/// Right header button clicked
Close,
}
/// Linear map of vertical menus.
pub struct DeviceMenuScreen {
/// Bounds of the menu screen
bounds: Rect,
/// Index of the currently active menu
active_menu: usize,
/// Stack of parent menus for back navigation
menu_parents: Vec<usize, MAX_MENUS>,
/// Stack of menu screens and their children
menu_stack: Vec<(VerticalMenuScreen, Vec<Option<usize>, MENU_MAX_ITEMS>), 5>,
}
impl DeviceMenuScreen {
pub fn empty() -> Self {
Self {
bounds: Rect::zero(),
active_menu: 0, // Start with the first menu by default
menu_stack: Vec::new(),
menu_parents: Vec::new(),
}
}
// Add an internal menu screen with children and return the index of the new
// menu within the stack. The children are optional indices of the sub-menus in
// the stack
pub fn add_inner_menu(
&mut self,
menu: VerticalMenuScreen,
children: Vec<Option<usize>, MENU_MAX_ITEMS>,
) -> usize {
unwrap!(self.menu_stack.push((menu, children)));
self.menu_stack.len() - 1
}
// Add a leaf menu screen (without any children)
pub fn add_leaf_menu(&mut self, menu: VerticalMenuScreen) -> usize {
let mut children = Vec::new();
for _ in 0..MENU_MAX_ITEMS {
unwrap!(children.push(None));
}
self.add_inner_menu(menu, children)
}
// Set the index of the active menu
pub fn set_active_menu(&mut self, menu: usize) {
assert!(menu < self.menu_stack.len());
self.active_menu = menu;
}
// Navigate to a different menu based on selection.
fn handle_menu_selection(&mut self, ctx: &mut EventCtx, index: usize) -> Option<DeviceMenuMsg> {
if self.menu_stack[self.active_menu].1[index].is_some() {
unwrap!(self.menu_parents.push(self.active_menu));
self.active_menu = self.menu_stack[self.active_menu].1[index].unwrap();
self.place(self.bounds);
self.menu_stack[self.active_menu].0.update_menu(ctx);
}
None
}
// Handle back navigation to previously active menu
fn handle_back_navigation(&mut self) -> Option<DeviceMenuMsg> {
if self.menu_parents.is_empty() {
Some(DeviceMenuMsg::Close)
} else {
self.active_menu = self.menu_parents.pop().unwrap();
self.place(self.bounds);
None
}
}
}
impl Component for DeviceMenuScreen {
type Msg = DeviceMenuMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// assert full screen
debug_assert_eq!(bounds.height(), SCREEN.height());
debug_assert_eq!(bounds.width(), SCREEN.width());
self.bounds = bounds;
// Place the active menu
self.menu_stack[self.active_menu].0.place(bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Update the menu when the screen is attached
if let Event::Attach(AttachType::Initial) = event {}
// Handle the event for the active menu
match self.menu_stack[self.active_menu].0.event(ctx, event) {
Some(VerticalMenuScreenMsg::Selected(index)) => {
// Navigate to the selected menu (if any)
return self.handle_menu_selection(ctx, index);
}
Some(VerticalMenuScreenMsg::Back) => {
return self.handle_back_navigation();
}
Some(VerticalMenuScreenMsg::Close) => {
return Some(DeviceMenuMsg::Close);
}
_ => {}
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// Render the active menu if any
self.menu_stack[self.active_menu].0.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for DeviceMenuScreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("DeviceMenuScreen");
}
}

View File

@ -1,6 +1,7 @@
mod action_bar;
pub mod bl_confirm;
mod button;
mod device_menu_screen;
mod error;
mod header;
mod hint;
@ -14,6 +15,7 @@ mod welcome_screen;
pub use action_bar::{ActionBar, ActionBarMsg};
pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, IconText};
pub use device_menu_screen::{DeviceMenuMsg, DeviceMenuScreen};
pub use error::ErrorScreen;
pub use header::{Header, HeaderMsg};
pub use hint::Hint;

View File

@ -14,7 +14,8 @@ use crate::{
};
use super::component::{
AllowedTextContent, SelectWordMsg, SelectWordScreen, TextScreen, TextScreenMsg,
AllowedTextContent, DeviceMenuMsg, DeviceMenuScreen, SelectWordMsg, SelectWordScreen,
TextScreen, TextScreenMsg, VerticalMenuScreen, VerticalMenuScreenMsg,
};
// Clippy/compiler complains about conflicting implementations
@ -61,3 +62,22 @@ impl ComponentMsgObj for SelectWordScreen {
}
}
}
impl ComponentMsgObj for VerticalMenuScreen {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
VerticalMenuScreenMsg::Back => Ok(CANCELLED.as_obj()),
VerticalMenuScreenMsg::Close => Ok(CANCELLED.as_obj()),
VerticalMenuScreenMsg::Selected(_) => Ok(CONFIRMED.as_obj()),
}
}
}
impl ComponentMsgObj for DeviceMenuScreen {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
DeviceMenuMsg::Close => Ok(CANCELLED.as_obj()),
DeviceMenuMsg::Selected(_) => Ok(CONFIRMED.as_obj()),
}
}
}

View File

@ -12,6 +12,7 @@ use crate::{
},
Empty, FormattedText,
},
geometry::Alignment,
layout::{
obj::{LayoutMaybeTrace, LayoutObj, RootComponent},
util::{ConfirmValueParams, RecoveryType, StrOrBytes},
@ -24,10 +25,15 @@ use crate::{
};
use super::{
component::{ActionBar, Button, Header, HeaderMsg, Hint, SelectWordScreen, TextScreen},
component::{
ActionBar, Button, ButtonStyleSheet, DeviceMenuScreen, Header, HeaderMsg, Hint,
SelectWordScreen, TextScreen, VerticalMenu, VerticalMenuScreen, MENU_MAX_ITEMS,
},
flow, fonts, theme, UIEckhart,
};
use heapless::Vec;
impl FirmwareUI for UIEckhart {
fn confirm_action(
title: TString<'static>,
@ -285,6 +291,148 @@ impl FirmwareUI for UIEckhart {
Err::<Gc<LayoutObj>, Error>(Error::ValueError(c"not implemented"))
}
fn device_menu(
failed_backup: bool,
low_battery: bool,
connections: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
const BUTTON_RADIUS: u8 = 12;
const BUTTON_ALIGNMENT: Alignment = Alignment::Start;
// Function to add a menu item with optional subtext to the VerticalMenu
fn add_menu_item(
menu: VerticalMenu,
text: TString<'static>,
subtext: Option<TString<'static>>,
style: ButtonStyleSheet,
include: bool,
) -> VerticalMenu {
if include {
let button = match subtext {
Some(sub) => Button::with_text_and_subtext(text, sub)
.with_text_align(BUTTON_ALIGNMENT)
.styled(style)
.with_radius(BUTTON_RADIUS),
None => Button::with_text(text)
.with_text_align(BUTTON_ALIGNMENT)
.styled(style)
.with_radius(BUTTON_RADIUS),
};
menu.item(button)
} else {
menu
}
}
// Create the device menu screen
let mut device_menu = DeviceMenuScreen::empty();
// Define menu items for settings
let settings_menu_items = [
(TR::device_menu__language, None, true),
(TR::device_menu__bluetooth, None, true),
(TR::device_menu__brightness, None, true),
(TR::device_menu__fw_version, None, true),
(TR::device_menu__about, None, true),
];
// Build the settings menu dynamically
let settings_menu = settings_menu_items
.iter()
.fold(
VerticalMenu::empty().with_separators(),
|menu, &(text, subtext, include)| {
add_menu_item(
menu,
text.into(),
subtext.into(),
theme::menu_item_title(),
include,
)
},
)
.with_separators();
// Create the settings screen
let settings_screen = VerticalMenuScreen::new(settings_menu).with_header(
Header::new(TR::words__settings.into())
.with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled)
.with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back),
);
let setting_index = device_menu.add_leaf_menu(settings_screen);
// Determine battery color based on low_battery flag
let battery_color = if low_battery {
theme::YELLOW
} else {
theme::GREEN_LIME
};
// Define root menu items
let root_menu_items = [
(
TR::device_menu__backup_failed_title,
Some(TR::device_menu__backup_failed_description.into()),
theme::menu_item_title_red(),
failed_backup,
),
(
TR::device_menu__battery_low_title,
Some(TR::device_menu__battery_low_description.into()),
theme::menu_item_title_yellow(),
low_battery,
),
(
TR::device_menu__connections_title,
Some(connections.into()),
theme::menu_item_title(),
true,
),
(TR::words__settings, None, theme::menu_item_title(), true),
];
// Build the root menu dynamically
let root_menu = root_menu_items.iter().fold(
VerticalMenu::empty().with_separators(),
|menu, &(text, subtext, style, include)| {
add_menu_item(menu, text.into(), subtext.into(), style, include)
},
);
// Initialize root children
let mut root_children: Vec<Option<usize>, MENU_MAX_ITEMS> = Vec::new();
// Optional failed backup child
if failed_backup {
root_children.push(None).unwrap();
}
// Optional low battery child
if low_battery {
root_children.push(None).unwrap();
}
// Remaining children
root_children
.extend_from_slice(&[None, Some(setting_index)])
.unwrap();
// Create the root screen
let root_screen = VerticalMenuScreen::new(root_menu).with_header(
Header::new("".into())
.with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled)
.with_icon(theme::ICON_BATTERY_ZAP, battery_color),
);
let root_index: usize = device_menu.add_inner_menu(root_screen, root_children);
// Set root menu as active
device_menu.set_active_menu(root_index);
// Create and return the layout
let layout = RootComponent::new(device_menu);
Ok(layout)
}
fn flow_confirm_output(
_title: Option<TString<'static>>,
_subtitle: Option<TString<'static>>,

View File

@ -156,6 +156,12 @@ pub trait FirmwareUI {
fn check_homescreen_format(image: BinaryData, accept_toif: bool) -> bool;
fn device_menu(
failed_backup: bool,
low_battery: bool,
connections: TString<'static>,
) -> Result<impl LayoutMaybeTrace, Error>;
#[allow(clippy::too_many_arguments)]
fn flow_confirm_output(
title: Option<TString<'static>>,

View File

@ -306,6 +306,16 @@ def continue_recovery_homepage(
"""Device recovery homescreen."""
# rust/src/ui/api/firmware_micropython.rs
def device_menu(
*,
failed_backup: bool,
low_battery: bool,
connections: str | None,
) -> LayoutObj[int]:
"""Show eckhart device menu."""
# rust/src/ui/api/firmware_micropython.rs
def flow_confirm_output(
*,

View File

@ -251,6 +251,18 @@ class TR:
confirm_total__title_sending_from: str = "Sending from"
debug__loading_seed: str = "Loading seed"
debug__loading_seed_not_recommended: str = "Loading private seed is not recommended."
device_menu__1_connection: str = "1 active connection"
device_menu__about: str = "About"
device_menu__active_connections: str = "{0} active connections"
device_menu__backup_failed_description: str = "Review"
device_menu__backup_failed_title: str = "Backup failed"
device_menu__battery_low_description: str = "Recharge soon"
device_menu__battery_low_title: str = "Battery low"
device_menu__bluetooth: str = "Bluetooth management"
device_menu__brightness: str = "Brightness"
device_menu__connections_title: str = "Pair & Connect"
device_menu__fw_version: str = "Firmware version"
device_menu__language: str = "Language"
device_name__change_template: str = "Change device name to {0}?"
device_name__title: str = "Device name"
entropy__send: str = "Do you really want to send entropy?"

View File

@ -919,6 +919,35 @@ async def confirm_signverify(
raise NotImplemented
async def device_menu(
failed_backup: bool,
battery_level: int,
connections: int,
) -> None:
# Currently arbitrary value
if battery_level < 20:
low_battery = True
else:
low_battery = False
if connections == 1:
connections_str = TR.device_menu__1_connection
elif connections == 0:
connections_str = TR.device_menu__connections_title.format("No")
else:
connections_str = TR.device_menu__connections_title.format(connections)
await interact(
trezorui_api.device_menu(
failed_backup=failed_backup,
low_battery=low_battery,
connections=connections_str,
),
None,
)
def error_popup(
title: str,
description: str,

View File

@ -253,6 +253,18 @@
"confirm_total__title_sending_from": "Sending from",
"debug__loading_seed": "Loading seed",
"debug__loading_seed_not_recommended": "Loading private seed is not recommended.",
"device_menu__brightness": "Brightness",
"device_menu__language": "Language",
"device_menu__bluetooth": "Bluetooth management",
"device_menu__fw_version": "Firmware version",
"device_menu__about": "About",
"device_menu__backup_failed_title": "Backup failed",
"device_menu__backup_failed_description": "Review",
"device_menu__battery_low_title": "Battery low",
"device_menu__battery_low_description": "Recharge soon",
"device_menu__connections_title": "Pair & Connect",
"device_menu__1_connection": "1 active connection",
"device_menu__active_connections": "{0} active connections",
"device_name__change_template": "Change device name to {0}?",
"device_name__title": "Device name",
"entropy__send": "Do you really want to send entropy?",

View File

@ -974,5 +974,17 @@
"972": "ethereum__interaction_contract",
"973": "misc__enable_labeling",
"974": "ethereum__unknown_contract_address_short",
"975": "reset__share_words_first"
"975": "reset__share_words_first",
"976": "device_menu__1_connection",
"977": "device_menu__about",
"978": "device_menu__active_connections",
"979": "device_menu__backup_failed_description",
"980": "device_menu__backup_failed_title",
"981": "device_menu__battery_low_description",
"982": "device_menu__battery_low_title",
"983": "device_menu__bluetooth",
"984": "device_menu__brightness",
"985": "device_menu__connections_title",
"986": "device_menu__fw_version",
"987": "device_menu__language"
}

View File

@ -1,8 +1,8 @@
{
"current": {
"merkle_root": "43c08e81d71c1c28d77b1650fc96b0dfcd473fde0c922717e5588baeeb581bd3",
"datetime": "2025-02-14T14:18:48.730778",
"commit": "8ecb8718c89547bcc8c0bb2f2cf9e823a9a75be3"
"merkle_root": "f3c02816770393fd369280c0668d5d93812be27ba55413957d66a73abce201b5",
"datetime": "2025-02-16T11:06:40.774900",
"commit": "71e2c51c5b52f783f85a87f38d943750d7e2e332"
},
"history": [
{