1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-04-22 10:09:04 +00:00

feat(eckhart): introduce the device menu

This commit is contained in:
Ioan Bizău 2025-03-28 13:52:36 +01:00 committed by obrusvit
parent 487467c83e
commit 3836293a6e
16 changed files with 775 additions and 48 deletions

View File

@ -112,6 +112,7 @@ static void _librust_qstrs(void) {
MP_QSTR_backup__title_create_wallet_backup;
MP_QSTR_backup__title_skip;
MP_QSTR_backup__want_to_skip;
MP_QSTR_battery_percentage;
MP_QSTR_bitcoin__commitment_data;
MP_QSTR_bitcoin__confirm_locktime;
MP_QSTR_bitcoin__create_proof_of_ownership;
@ -258,6 +259,7 @@ static void _librust_qstrs(void) {
MP_QSTR_extra_item;
MP_QSTR_extra_items;
MP_QSTR_extra_title;
MP_QSTR_failed_backup;
MP_QSTR_fee;
MP_QSTR_fee_items;
MP_QSTR_fee_label;
@ -373,6 +375,7 @@ static void _librust_qstrs(void) {
MP_QSTR_page_counter;
MP_QSTR_pages;
MP_QSTR_paint;
MP_QSTR_paired_devices;
MP_QSTR_passphrase__access_wallet;
MP_QSTR_passphrase__always_on_device;
MP_QSTR_passphrase__continue_with_empty_passphrase;
@ -670,6 +673,7 @@ static void _librust_qstrs(void) {
MP_QSTR_show_address_details;
MP_QSTR_show_checklist;
MP_QSTR_show_danger;
MP_QSTR_show_device_menu;
MP_QSTR_show_error;
MP_QSTR_show_group_share_success;
MP_QSTR_show_homescreen;

View File

@ -802,6 +802,19 @@ extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_show_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 battery_percentage: u8 = kwargs.get_or(Qstr::MP_QSTR_battery_percentage, 0)?;
let paired_devices: Obj = kwargs.get(Qstr::MP_QSTR_paired_devices)?;
let paired_devices: Vec<TString, 1> = util::iter_into_vec(paired_devices)?;
let layout = ModelUI::show_device_menu(failed_backup, battery_percentage, paired_devices)?;
let layout_obj = LayoutObj::new_root(layout)?;
Ok(layout_obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_show_info(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
@ -1561,6 +1574,15 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// """Idle homescreen."""
Qstr::MP_QSTR_show_homescreen => obj_fn_kw!(0, new_show_homescreen).as_obj(),
/// def show_device_menu(
/// *,
/// failed_backup: bool,
/// battery_percentage: int,
/// paired_devices: Iterable[str],
/// ) -> LayoutObj[UiResult]:
/// """Show the device menu."""
Qstr::MP_QSTR_show_device_menu => obj_fn_kw!(0, new_show_device_menu).as_obj(),
/// def show_info(
/// *,
/// title: str,

View File

@ -7,7 +7,7 @@ use crate::ui::{
const ELLIPSIS: &str = "...";
#[derive(Copy, Clone)]
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum LineBreaking {
/// Break line only at whitespace, if possible. If we don't find any
/// whitespace, break words.
@ -19,7 +19,7 @@ pub enum LineBreaking {
BreakWordsNoHyphen,
}
#[derive(Copy, Clone)]
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum PageBreaking {
/// Stop after hitting the bottom-right edge of the bounds.
Cut,
@ -54,7 +54,7 @@ pub struct TextLayout {
}
/// Configuration for chunkifying the text into smaller parts.
#[derive(Copy, Clone)]
#[derive(PartialEq, Eq, Copy, Clone)]
pub struct Chunks {
/// How many characters will be grouped in one chunk.
pub chunk_size: usize,
@ -79,7 +79,7 @@ impl Chunks {
}
}
#[derive(Copy, Clone)]
#[derive(PartialEq, Eq, Copy, Clone)]
pub struct TextStyle {
/// Text font ID.
pub text_font: Font,

View File

@ -861,6 +861,16 @@ impl FirmwareUI for UIBolt {
Ok(layout)
}
fn show_device_menu(
_failed_backup: bool,
_battery_percentage: u8,
_paired_devices: heapless::Vec<TString<'static>, 1>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::ValueError(
c"show_device_menu not supported",
))
}
fn show_info(
title: TString<'static>,
description: TString<'static>,

View File

@ -1027,6 +1027,16 @@ impl FirmwareUI for UICaesar {
Ok(layout)
}
fn show_device_menu(
_failed_backup: bool,
_battery_percentage: u8,
_paired_devices: Vec<TString<'static>, 1>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::ValueError(
c"show_device_menu not supported",
))
}
fn show_info(
title: TString<'static>,
description: TString<'static>,

View File

@ -884,6 +884,16 @@ impl FirmwareUI for UIDelizia {
Ok(layout)
}
fn show_device_menu(
_failed_backup: bool,
_battery_percentage: u8,
_paired_devices: heapless::Vec<TString<'static>, 1>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::ValueError(
c"show_device_menu not supported",
))
}
fn show_info(
title: TString<'static>,
description: TString<'static>,

View File

@ -14,6 +14,9 @@ use crate::{
},
};
#[cfg(feature = "bootloader")]
use super::super::fonts;
use super::super::theme;
pub enum ButtonMsg {
@ -28,7 +31,7 @@ pub struct Button {
touch_expand: Option<Insets>,
content: ButtonContent,
content_offset: Offset,
styles: ButtonStyleSheet,
stylesheet: ButtonStyleSheet,
text_align: Alignment,
radius: Option<u8>,
state: State,
@ -41,9 +44,23 @@ pub struct Button {
impl Button {
const LINE_SPACING: i16 = 7;
#[cfg(not(feature = "bootloader"))]
const SUBTEXT_STYLE: TextStyle = theme::label_menu_item_subtitle();
const DEFAULT_SUBTEXT_STYLE: TextStyle = theme::label_menu_item_subtitle();
#[cfg(feature = "bootloader")]
const SUBTEXT_STYLE: TextStyle = theme::TEXT_NORMAL;
const DEFAULT_SUBTEXT_STYLE: TextStyle = theme::TEXT_NORMAL;
#[cfg(not(feature = "bootloader"))]
pub const SUBTEXT_STYLE_GREEN: TextStyle = theme::label_menu_item_subtitle_green();
#[cfg(feature = "bootloader")]
pub const SUBTEXT_STYLE_GREEN: TextStyle = TextStyle::new(
fonts::FONT_SATOSHI_REGULAR_38,
theme::GREEN,
theme::BG,
theme::GREEN,
theme::GREEN,
);
const MENU_ITEM_RADIUS: u8 = 12;
const MENU_ITEM_ALIGNMENT: Alignment = Alignment::Start;
const MENU_ITEM_CONTENT_OFFSET: Offset = Offset::x(12);
pub const fn new(content: ButtonContent) -> Self {
Self {
@ -51,7 +68,7 @@ impl Button {
content_offset: Offset::zero(),
area: Rect::zero(),
touch_expand: None,
styles: theme::button_default(),
stylesheet: theme::button_default(),
text_align: Alignment::Center,
radius: None,
state: State::Initial,
@ -62,12 +79,41 @@ impl Button {
}
}
pub fn new_menu_item(text: TString<'static>, stylesheet: ButtonStyleSheet) -> Self {
Self::with_text(text)
.with_text_align(Self::MENU_ITEM_ALIGNMENT)
.with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
.styled(stylesheet)
.with_radius(Self::MENU_ITEM_RADIUS)
}
pub fn new_menu_item_with_subtext(
text: TString<'static>,
stylesheet: ButtonStyleSheet,
subtext: TString<'static>,
subtext_style: Option<TextStyle>,
) -> Self {
Self::with_text_and_subtext(text, subtext, subtext_style)
.with_text_align(Self::MENU_ITEM_ALIGNMENT)
.with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
.styled(stylesheet)
.with_radius(Self::MENU_ITEM_RADIUS)
}
pub const fn with_text(text: TString<'static>) -> Self {
Self::new(ButtonContent::Text(text))
}
pub const fn with_text_and_subtext(text: TString<'static>, subtext: TString<'static>) -> Self {
Self::new(ButtonContent::TextAndSubtext(text, subtext))
pub fn with_text_and_subtext(
text: TString<'static>,
subtext: TString<'static>,
subtext_style: Option<TextStyle>,
) -> Self {
Self::new(ButtonContent::TextAndSubtext {
text,
subtext,
subtext_style: subtext_style.unwrap_or(Self::DEFAULT_SUBTEXT_STYLE),
})
}
pub const fn with_icon(icon: Icon) -> Self {
@ -87,8 +133,8 @@ impl Button {
Self::new(ButtonContent::Empty)
}
pub const fn styled(mut self, styles: ButtonStyleSheet) -> Self {
self.styles = styles;
pub const fn styled(mut self, stylesheet: ButtonStyleSheet) -> Self {
self.stylesheet = stylesheet;
self
}
@ -205,32 +251,32 @@ impl Button {
let icon_height = child.icon.toif.height();
text_height.max(icon_height)
}
ButtonContent::TextAndSubtext(_, _) => {
ButtonContent::TextAndSubtext { subtext_style, .. } => {
self.style().font.allcase_text_height()
+ Self::LINE_SPACING
+ Self::SUBTEXT_STYLE.text_font.allcase_text_height()
+ subtext_style.text_font.allcase_text_height()
}
#[cfg(feature = "micropython")]
ButtonContent::HomeBar(_) => theme::ACTION_BAR_HEIGHT,
}
}
pub fn set_stylesheet(&mut self, styles: ButtonStyleSheet) {
if self.styles != styles {
self.styles = styles;
pub fn set_stylesheet(&mut self, stylesheet: ButtonStyleSheet) {
if self.stylesheet != stylesheet {
self.stylesheet = stylesheet;
}
}
pub fn style(&self) -> &ButtonStyle {
match self.state {
State::Initial | State::Released => self.styles.normal,
State::Pressed => self.styles.active,
State::Disabled => self.styles.disabled,
State::Initial | State::Released => self.stylesheet.normal,
State::Pressed => self.stylesheet.active,
State::Disabled => self.stylesheet.disabled,
}
}
pub fn style_sheet(&self) -> &ButtonStyleSheet {
&self.styles
pub fn stylesheet(&self) -> &ButtonStyleSheet {
&self.stylesheet
}
pub fn area(&self) -> Rect {
@ -317,10 +363,10 @@ impl Button {
}
}
pub fn render_content<'s>(
fn render_content<'s>(
&self,
target: &mut impl Renderer<'s>,
style: &ButtonStyle,
stylesheet: &ButtonStyle,
alpha: u8,
) {
match &self.content {
@ -333,16 +379,20 @@ impl Button {
Alignment::End => self.area.right_center() - self.content_offset,
} + y_offset;
text.map(|text| {
shape::Text::new(start_of_baseline, text, style.font)
.with_fg(style.text_color)
shape::Text::new(start_of_baseline, text, stylesheet.font)
.with_fg(stylesheet.text_color)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
});
}
ButtonContent::TextAndSubtext(text, subtext) => {
ButtonContent::TextAndSubtext {
text,
subtext,
subtext_style,
} => {
let text_y_offset =
Offset::y(self.content_height() / 2 - self.style().font.allcase_text_height());
Offset::y(self.content_height() / 2 - stylesheet.font.allcase_text_height());
let subtext_y_offset = Offset::y(self.content_height() / 2);
let start_of_baseline = match self.text_align {
Alignment::Start => self.area.left_center() + self.content_offset,
@ -352,17 +402,17 @@ impl Button {
let text_baseline = start_of_baseline - text_y_offset;
let subtext_baseline = start_of_baseline + subtext_y_offset;
text.map(|text| {
shape::Text::new(text_baseline, text, style.font)
.with_fg(style.text_color)
text.map(|t| {
shape::Text::new(text_baseline, t, stylesheet.font)
.with_fg(stylesheet.text_color)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
});
subtext.map(|subtext| {
shape::Text::new(subtext_baseline, subtext, Self::SUBTEXT_STYLE.text_font)
.with_fg(Self::SUBTEXT_STYLE.text_color)
shape::Text::new(subtext_baseline, subtext, subtext_style.text_font)
.with_fg(subtext_style.text_color)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
@ -371,7 +421,7 @@ impl Button {
ButtonContent::Icon(icon) => {
shape::ToifImage::new(self.area.center() + self.content_offset, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.icon_color)
.with_fg(stylesheet.icon_color)
.with_alpha(alpha)
.render(target);
}
@ -384,8 +434,8 @@ impl Button {
if let Some(text) = text {
const OFFSET_Y: Offset = Offset::y(25);
text.map(|text| {
shape::Text::new(baseline, text, style.font)
.with_fg(style.text_color)
shape::Text::new(baseline, text, stylesheet.font)
.with_fg(stylesheet.text_color)
.with_align(Alignment::Center)
.with_alpha(alpha)
.render(target);
@ -394,7 +444,7 @@ impl Button {
self.area.center() + OFFSET_Y,
theme::ICON_DASH_HORIZONTAL.toif,
)
.with_fg(style.icon_color)
.with_fg(stylesheet.icon_color)
.with_align(Alignment2D::CENTER)
.render(target);
} else {
@ -542,7 +592,7 @@ impl crate::trace::Trace for Button {
t.string("text", content.text);
t.bool("icon", true);
}
ButtonContent::TextAndSubtext(text, _) => {
ButtonContent::TextAndSubtext { text, .. } => {
t.string("text", *text);
}
#[cfg(feature = "micropython")]
@ -563,7 +613,11 @@ enum State {
pub enum ButtonContent {
Empty,
Text(TString<'static>),
TextAndSubtext(TString<'static>, TString<'static>),
TextAndSubtext {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: TextStyle,
},
Icon(Icon),
IconAndText(IconText),
#[cfg(feature = "micropython")]

View File

@ -14,10 +14,11 @@ use crate::{
};
use super::firmware::{
AllowedTextContent, ConfirmHomescreen, ConfirmHomescreenMsg, Homescreen, HomescreenMsg,
MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputScreen, NumberInputScreenMsg,
PinKeyboard, PinKeyboardMsg, SelectWordCountMsg, SelectWordCountScreen, SelectWordMsg,
SelectWordScreen, SetBrightnessScreen, TextScreen, TextScreenMsg,
AllowedTextContent, ConfirmHomescreen, ConfirmHomescreenMsg, DeviceMenuMsg, DeviceMenuScreen,
Homescreen, HomescreenMsg, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg,
NumberInputScreen, NumberInputScreenMsg, PinKeyboard, PinKeyboardMsg, SelectWordCountMsg,
SelectWordCountScreen, SelectWordMsg, SelectWordScreen, SetBrightnessScreen, TextScreen,
TextScreenMsg,
};
impl ComponentMsgObj for PinKeyboard<'_> {
@ -134,3 +135,17 @@ impl ComponentMsgObj for SetBrightnessScreen {
Ok(CONFIRMED.as_obj())
}
}
impl<'a> ComponentMsgObj for DeviceMenuScreen<'a> {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
DeviceMenuMsg::BackupFailed => "BackupFailed".try_into(),
DeviceMenuMsg::DevicePair => "DevicePair".try_into(),
DeviceMenuMsg::DeviceDisconnect(_) => "DeviceDisconnect".try_into(),
DeviceMenuMsg::CheckBackup => "CheckBackup".try_into(),
DeviceMenuMsg::WipeDevice => "WipeDevice".try_into(),
DeviceMenuMsg::ScreenBrightness => "ScreenBrightness".try_into(),
DeviceMenuMsg::Close => Ok(CANCELLED.as_obj()),
}
}
}

View File

@ -0,0 +1,526 @@
use crate::{
strutil::TString,
ui::{
component::{
text::{
paragraphs::{Paragraph, Paragraphs},
TextStyle,
},
Component, Event, EventCtx,
},
geometry::Rect,
layout_eckhart::{
component::{Button, ButtonStyleSheet},
constant::SCREEN,
firmware::{
Header, HeaderMsg, TextScreen, TextScreenMsg, VerticalMenu, VerticalMenuScreen,
VerticalMenuScreenMsg, MENU_MAX_ITEMS,
},
},
shape::Renderer,
},
};
use super::theme;
use heapless::Vec;
const MAX_DEPTH: usize = 5;
const MAX_SUBSCREENS: usize = 10;
const DISCONNECT_DEVICE_MENU_INDEX: usize = 1;
#[derive(Clone)]
enum Action {
// Go to another registered subscreen
GoTo(usize),
// Return a DeviceMenuMsg to the caller
Return(DeviceMenuMsg),
}
#[derive(Copy, Clone)]
pub enum DeviceMenuMsg {
// Root menu
BackupFailed,
// "Pair & Connect"
DevicePair, // pair a new device
DeviceDisconnect(
usize, /* which device to disconnect, index in the list of devices */
),
// Security menu
CheckBackup,
WipeDevice,
// Device menu
ScreenBrightness,
// nothing selected
Close,
}
struct MenuItem {
text: TString<'static>,
subtext: Option<(TString<'static>, Option<TextStyle>)>,
stylesheet: ButtonStyleSheet,
action: Option<Action>,
}
impl MenuItem {
pub fn new(text: TString<'static>, action: Option<Action>) -> Self {
Self {
text,
subtext: None,
stylesheet: theme::menu_item_title(),
action,
}
}
pub fn with_subtext(mut self, subtext: Option<(TString<'static>, Option<TextStyle>)>) -> Self {
self.subtext = subtext;
self
}
pub fn with_stylesheet(mut self, stylesheet: ButtonStyleSheet) -> Self {
self.stylesheet = stylesheet;
self
}
}
struct SubmenuScreen {
header_text: TString<'static>,
show_battery: bool,
items: Vec<MenuItem, MENU_MAX_ITEMS>,
}
impl SubmenuScreen {
pub fn new(header_text: TString<'static>, items: Vec<MenuItem, MENU_MAX_ITEMS>) -> Self {
Self {
header_text,
show_battery: false,
items,
}
}
pub fn with_battery(mut self) -> Self {
self.show_battery = true;
self
}
}
// Each subscreen of the DeviceMenuScreen is one of these
#[allow(clippy::large_enum_variant)]
enum Subscreen {
// A menu, with associated items and actions
Submenu(SubmenuScreen),
// A screen allowing the user to to disconnect a device
DeviceScreen(
TString<'static>, /* device name */
usize, /* index in the list of devices */
),
// The about screen
AboutScreen,
}
pub struct DeviceMenuScreen<'a> {
bounds: Rect,
battery_percentage: u8,
// These correspond to the currently active subscreen,
// which is one of the possible kinds of subscreens
// as defined by `enum Subscreen`
// The active one will be Some(...) and the other two will be None.
// This way we only need to keep one screen at any time in memory.
menu_screen: Option<VerticalMenuScreen>,
paired_device_screen: Option<VerticalMenuScreen>,
about_screen: Option<TextScreen<Paragraphs<[Paragraph<'a>; 2]>>>,
// Information needed to construct any subscreen on demand
subscreens: Vec<Subscreen, MAX_SUBSCREENS>,
// index of the current subscreen in the list of subscreens
active_subscreen: usize,
// stack of parents that led to the current subscreen
parent_subscreens: Vec<usize, MAX_DEPTH>,
}
impl<'a> DeviceMenuScreen<'a> {
pub fn new(
failed_backup: bool,
battery_percentage: u8,
// NB: we currently only support one device at a time.
// if we ever increase this size, we will need a way to return the correct
// device index on Disconnect back to uPy
// (see component_msg_obj.rs, which currently just returns "DeviceDisconnect" with no
// index!)
paired_devices: Vec<TString<'static>, 1>,
) -> Self {
let mut screen = Self {
bounds: Rect::zero(),
battery_percentage,
menu_screen: None,
paired_device_screen: None,
about_screen: None,
active_subscreen: 0,
subscreens: Vec::new(),
parent_subscreens: Vec::new(),
};
let about = screen.add_subscreen(Subscreen::AboutScreen);
let security = screen.add_security_menu();
let device = screen.add_device_menu("My device".into(), about); // TODO: device name
let settings = screen.add_settings_menu(security, device);
let mut paired_device_indices: Vec<usize, 1> = Vec::new();
for (i, device) in paired_devices.iter().enumerate() {
unwrap!(paired_device_indices
.push(screen.add_subscreen(Subscreen::DeviceScreen(*device, i))));
}
let devices = screen.add_paired_devices_menu(paired_devices, paired_device_indices);
let pair_and_connect = screen.add_pair_and_connect_menu(devices);
let root = screen.add_root_menu(failed_backup, pair_and_connect, settings);
screen.set_active_subscreen(root);
screen
}
fn is_low_battery(&self) -> bool {
self.battery_percentage < 20
}
fn add_paired_devices_menu(
&mut self,
paired_devices: Vec<TString<'static>, 1>,
paired_device_indices: Vec<usize, 1>,
) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
for (device, idx) in paired_devices.iter().zip(paired_device_indices) {
unwrap!(items.push(
MenuItem::new(*device, Some(Action::GoTo(idx))).with_subtext(Some((
"Connected".into(),
Some(Button::SUBTEXT_STYLE_GREEN)
))) // TODO: this should be a boolean feature of the device
));
}
self.add_subscreen(Subscreen::Submenu(SubmenuScreen::new(
"Manage paired devices".into(),
items,
)))
}
fn add_pair_and_connect_menu(&mut self, manage_devices_index: usize) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
unwrap!(items.push(
MenuItem::new(
"Manage paired devices".into(),
Some(Action::GoTo(manage_devices_index)),
)
.with_subtext(Some((
"1 device connected".into(),
Some(Button::SUBTEXT_STYLE_GREEN)
)))
));
unwrap!(items.push(MenuItem::new(
"Pair new device".into(),
Some(Action::Return(DeviceMenuMsg::DevicePair)),
)));
self.add_subscreen(Subscreen::Submenu(SubmenuScreen::new(
"Pair & connect".into(),
items,
)))
}
fn add_settings_menu(&mut self, security_index: usize, device_index: usize) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
unwrap!(items.push(MenuItem::new(
"Security".into(),
Some(Action::GoTo(security_index))
)));
unwrap!(items.push(MenuItem::new(
"Device".into(),
Some(Action::GoTo(device_index))
)));
self.add_subscreen(Subscreen::Submenu(SubmenuScreen::new(
"Settings".into(),
items,
)))
}
fn add_security_menu(&mut self) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
unwrap!(items.push(MenuItem::new(
"Check backup".into(),
Some(Action::Return(DeviceMenuMsg::CheckBackup)),
)));
unwrap!(items.push(MenuItem::new(
"Wipe device".into(),
Some(Action::Return(DeviceMenuMsg::WipeDevice))
)));
self.add_subscreen(Subscreen::Submenu(SubmenuScreen::new(
"Security".into(),
items,
)))
}
fn add_device_menu(&mut self, device_name: TString<'static>, about_index: usize) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
unwrap!(
items.push(MenuItem::new("Name".into(), None).with_subtext(Some((device_name, None))))
);
unwrap!(items.push(MenuItem::new(
"Screen brightness".into(),
Some(Action::Return(DeviceMenuMsg::ScreenBrightness)),
)));
unwrap!(items.push(MenuItem::new(
"About".into(),
Some(Action::GoTo(about_index))
)));
self.add_subscreen(Subscreen::Submenu(SubmenuScreen::new(
"Device".into(),
items,
)))
}
fn add_root_menu(
&mut self,
failed_backup: bool,
pair_and_connect_index: usize,
settings_index: usize,
) -> usize {
let mut items: Vec<MenuItem, MENU_MAX_ITEMS> = Vec::new();
if failed_backup {
unwrap!(items.push(
MenuItem::new(
"Backup failed".into(),
Some(Action::Return(DeviceMenuMsg::BackupFailed)),
)
.with_subtext(Some(("Review".into(), None)))
.with_stylesheet(theme::menu_item_title_red()),
));
}
unwrap!(items.push(
MenuItem::new(
"Pair & connect".into(),
Some(Action::GoTo(pair_and_connect_index)),
)
.with_subtext(Some((
"1 device connected".into(),
Some(Button::SUBTEXT_STYLE_GREEN)
)))
));
unwrap!(items.push(MenuItem::new(
"Settings".into(),
Some(Action::GoTo(settings_index)),
)));
self.add_subscreen(Subscreen::Submenu(
SubmenuScreen::new("".into(), items).with_battery(),
))
}
fn add_subscreen(&mut self, screen: Subscreen) -> usize {
unwrap!(self.subscreens.push(screen));
self.subscreens.len() - 1
}
fn set_active_subscreen(&mut self, idx: usize) {
assert!(idx < self.subscreens.len());
self.active_subscreen = idx;
self.build_active_subscreen();
}
fn build_active_subscreen(&mut self) {
match self.subscreens[self.active_subscreen] {
Subscreen::Submenu(ref mut submenu) => {
self.paired_device_screen = None;
self.about_screen = None;
let mut menu = VerticalMenu::empty().with_separators();
for item in &submenu.items {
let button = if let Some((subtext, subtext_style)) = item.subtext {
Button::new_menu_item_with_subtext(
item.text,
item.stylesheet,
subtext,
subtext_style,
)
} else {
Button::new_menu_item(item.text, item.stylesheet)
};
menu = menu.item(button);
}
let mut header = Header::new(submenu.header_text)
.with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled);
if submenu.show_battery {
header = header.with_icon(
theme::ICON_BATTERY_ZAP,
if self.is_low_battery() {
theme::YELLOW
} else {
theme::GREEN_LIME
},
);
} else {
header = header.with_left_button(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
HeaderMsg::Back,
);
}
self.menu_screen = Some(VerticalMenuScreen::new(menu).with_header(header));
}
Subscreen::DeviceScreen(device, _) => {
self.menu_screen = None;
self.about_screen = None;
let mut menu = VerticalMenu::empty().with_separators();
menu = menu.item(Button::new_menu_item(device, theme::menu_item_title()));
menu = menu.item(Button::new_menu_item(
"Disconnect".into(),
theme::menu_item_title_red(),
));
self.paired_device_screen = Some(
VerticalMenuScreen::new(menu).with_header(
Header::new("Manage".into())
.with_right_button(
Button::with_icon(theme::ICON_CROSS),
HeaderMsg::Cancelled,
)
.with_left_button(
Button::with_icon(theme::ICON_CHEVRON_LEFT),
HeaderMsg::Back,
),
),
);
}
Subscreen::AboutScreen => {
self.menu_screen = None;
self.paired_device_screen = None;
let about_content = Paragraphs::new([
Paragraph::new(&theme::firmware::TEXT_REGULAR, "Firmware version"),
Paragraph::new(&theme::firmware::TEXT_REGULAR, "2.3.1"), // TODO
]);
self.about_screen = Some(
TextScreen::new(about_content)
.with_header(Header::new("About".into()).with_close_button()),
);
}
}
}
fn handle_submenu(&mut self, ctx: &mut EventCtx, idx: usize) -> Option<DeviceMenuMsg> {
match self.subscreens[self.active_subscreen] {
Subscreen::Submenu(ref mut menu_screen) => {
match menu_screen.items[idx].action {
Some(Action::GoTo(menu)) => {
self.menu_screen.as_mut().unwrap().update_menu(ctx);
unwrap!(self.parent_subscreens.push(self.active_subscreen));
self.set_active_subscreen(menu);
self.place(self.bounds);
}
Some(Action::Return(msg)) => return Some(msg),
None => {}
};
}
_ => {
panic!("Expected a submenu!");
}
}
None
}
fn go_back(&mut self) -> Option<DeviceMenuMsg> {
if let Some(parent) = self.parent_subscreens.pop() {
self.set_active_subscreen(parent);
self.place(self.bounds);
None
} else {
Some(DeviceMenuMsg::Close)
}
}
}
impl<'a> Component for DeviceMenuScreen<'a> {
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;
match self.subscreens[self.active_subscreen] {
Subscreen::Submenu(..) => self.menu_screen.place(bounds),
Subscreen::DeviceScreen(..) => self.paired_device_screen.place(bounds),
Subscreen::AboutScreen => self.about_screen.place(bounds),
};
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Handle the event for the active menu
match self.subscreens[self.active_subscreen] {
Subscreen::Submenu(..) => match self.menu_screen.event(ctx, event) {
Some(VerticalMenuScreenMsg::Selected(index)) => {
return self.handle_submenu(ctx, index);
}
Some(VerticalMenuScreenMsg::Back) => {
return self.go_back();
}
Some(VerticalMenuScreenMsg::Close) => {
return Some(DeviceMenuMsg::Close);
}
_ => {}
},
Subscreen::DeviceScreen(_, i) => match self.paired_device_screen.event(ctx, event) {
Some(VerticalMenuScreenMsg::Selected(index)) => {
if index == DISCONNECT_DEVICE_MENU_INDEX {
return Some(DeviceMenuMsg::DeviceDisconnect(i));
}
}
Some(VerticalMenuScreenMsg::Back) => {
return self.go_back();
}
Some(VerticalMenuScreenMsg::Close) => {
return Some(DeviceMenuMsg::Close);
}
_ => {}
},
Subscreen::AboutScreen => {
if let Some(TextScreenMsg::Cancelled) = self.about_screen.event(ctx, event) {
return self.go_back();
}
}
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
match &self.subscreens[self.active_subscreen] {
Subscreen::Submenu(..) => self.menu_screen.render(target),
Subscreen::DeviceScreen(..) => self.paired_device_screen.render(target),
Subscreen::AboutScreen => self.about_screen.render(target),
}
}
}
#[cfg(feature = "ui_debug")]
impl<'a> crate::trace::Trace for DeviceMenuScreen<'a> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("DeviceMenuScreen");
}
}

View File

@ -1,6 +1,7 @@
mod action_bar;
mod brightness_screen;
mod confirm_homescreen;
mod device_menu_screen;
mod header;
mod hint;
mod hold_to_confirm;
@ -17,6 +18,7 @@ mod vertical_menu_screen;
pub use action_bar::{ActionBar, ActionBarMsg};
pub use brightness_screen::SetBrightnessScreen;
pub use confirm_homescreen::{ConfirmHomescreen, ConfirmHomescreenMsg};
pub use device_menu_screen::{DeviceMenuMsg, DeviceMenuScreen};
pub use header::{Header, HeaderMsg};
pub use hint::Hint;
pub use hold_to_confirm::HoldToConfirmAnim;

View File

@ -233,6 +233,7 @@ pub fn new_continue_recovery_homepage(
Button::with_text_and_subtext(
TR::words__recovery_share.into(),
TR::buttons__more_info.into(),
None,
)
.styled(theme::menu_item_title())
.with_text_align(Alignment::Start)

View File

@ -164,6 +164,10 @@ pub const fn label_menu_item_subtitle() -> TextStyle {
TextStyle::new(fonts::FONT_SATOSHI_REGULAR_22, GREY, BG, GREY, GREY)
}
pub const fn label_menu_item_subtitle_green() -> TextStyle {
TextStyle::new(fonts::FONT_SATOSHI_REGULAR_22, GREEN, BG, GREEN, GREEN)
}
// Button styles
pub const fn button_confirm() -> ButtonStyleSheet {
ButtonStyleSheet {
@ -310,6 +314,10 @@ pub const fn menu_item_title_orange() -> ButtonStyleSheet {
menu_item_title!(ORANGE)
}
pub const fn menu_item_title_red() -> ButtonStyleSheet {
menu_item_title!(RED)
}
macro_rules! button_homebar_style {
($text_color:expr, $icon_color:expr) => {
ButtonStyleSheet {

View File

@ -32,13 +32,15 @@ use crate::{
use super::{
component::Button,
firmware::{
ActionBar, Bip39Input, ConfirmHomescreen, Header, HeaderMsg, Hint, Homescreen,
MnemonicKeyboard, NumberInputScreen, PinKeyboard, SelectWordCountScreen, SelectWordScreen,
SetBrightnessScreen, Slip39Input, TextScreen,
ActionBar, Bip39Input, ConfirmHomescreen, DeviceMenuScreen, Header, HeaderMsg, Hint,
Homescreen, MnemonicKeyboard, NumberInputScreen, PinKeyboard, SelectWordCountScreen,
SelectWordScreen, SetBrightnessScreen, Slip39Input, TextScreen,
},
flow, fonts, theme, UIEckhart,
};
use heapless::Vec;
impl FirmwareUI for UIEckhart {
fn confirm_action(
title: TString<'static>,
@ -781,6 +783,19 @@ impl FirmwareUI for UIEckhart {
Ok(layout)
}
fn show_device_menu(
failed_backup: bool,
battery_percentage: u8,
paired_devices: Vec<TString<'static>, 1>,
) -> Result<impl LayoutMaybeTrace, Error> {
let layout = RootComponent::new(DeviceMenuScreen::new(
failed_backup,
battery_percentage,
paired_devices,
));
Ok(layout)
}
fn show_info(
title: TString<'static>,
description: TString<'static>,

View File

@ -303,6 +303,12 @@ pub trait FirmwareUI {
notification_level: u8,
) -> Result<impl LayoutMaybeTrace, Error>;
fn show_device_menu(
failed_backup: bool,
battery_percentage: u8,
paired_devices: Vec<TString<'static>, 1>,
) -> Result<impl LayoutMaybeTrace, Error>;
fn show_info(
title: TString<'static>,
description: TString<'static>,

View File

@ -532,6 +532,16 @@ def show_homescreen(
"""Idle homescreen."""
# rust/src/ui/api/firmware_micropython.rs
def show_device_menu(
*,
failed_backup: bool,
battery_percentage: int,
paired_devices: Iterable[str],
) -> LayoutObj[UiResult]:
"""Show the device menu."""
# rust/src/ui/api/firmware_micropython.rs
def show_info(
*,

View File

@ -3,8 +3,10 @@ from typing import Coroutine
import storage
import storage.cache
import storage.device
import trezorui_api
from trezor import config, wire
from trezor.enums import MessageType
from trezor.ui.layouts import raise_if_not_confirmed
from trezor.ui.layouts.homescreen import Busyscreen, Homescreen, Lockscreen
from apps.base import busy_expiry_ms, lock_device
@ -53,11 +55,43 @@ async def homescreen() -> None:
hold_to_lock=config.has_pin(),
)
try:
await obj.get_result()
res = await obj.get_result()
finally:
obj.__del__()
lock_device()
if res is trezorui_api.INFO:
# MOCK DATA
failed_backup = True
battery_percentage = 22
paired_devices = ["Suite on my de-Googled Phone"]
#
menu_result = await raise_if_not_confirmed(
trezorui_api.show_device_menu(
failed_backup=failed_backup,
battery_percentage=battery_percentage,
paired_devices=paired_devices,
),
"device_menu",
)
print(menu_result)
if menu_result == "DevicePair":
await raise_if_not_confirmed(
trezorui_api.show_pairing_device_name(
device_name="My Trez",
),
"device_name",
)
await raise_if_not_confirmed(
trezorui_api.show_pairing_code(
code="123456",
),
"pairing_code",
)
else:
lock_device()
async def _lockscreen(screensaver: bool = False) -> None: