diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 2b803aca3e..adc70b4596 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -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; diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs index 3a5ed1bfe9..c5d672fb16 100644 --- a/core/embed/rust/src/ui/api/firmware_micropython.rs +++ b/core/embed/rust/src/ui/api/firmware_micropython.rs @@ -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 = 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, diff --git a/core/embed/rust/src/ui/component/text/layout.rs b/core/embed/rust/src/ui/component/text/layout.rs index 20ccd892d9..f40a204891 100644 --- a/core/embed/rust/src/ui/component/text/layout.rs +++ b/core/embed/rust/src/ui/component/text/layout.rs @@ -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, diff --git a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs index 0996e53474..25fce0e5d8 100644 --- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs @@ -861,6 +861,16 @@ impl FirmwareUI for UIBolt { Ok(layout) } + fn show_device_menu( + _failed_backup: bool, + _battery_percentage: u8, + _paired_devices: heapless::Vec, 1>, + ) -> Result { + Err::, Error>(Error::ValueError( + c"show_device_menu not supported", + )) + } + fn show_info( title: TString<'static>, description: TString<'static>, diff --git a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs index 2350ceafa6..ba093f7471 100644 --- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs @@ -1027,6 +1027,16 @@ impl FirmwareUI for UICaesar { Ok(layout) } + fn show_device_menu( + _failed_backup: bool, + _battery_percentage: u8, + _paired_devices: Vec, 1>, + ) -> Result { + Err::, Error>(Error::ValueError( + c"show_device_menu not supported", + )) + } + fn show_info( title: TString<'static>, description: TString<'static>, diff --git a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs index dd652d4e7b..11241d0970 100644 --- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs @@ -884,6 +884,16 @@ impl FirmwareUI for UIDelizia { Ok(layout) } + fn show_device_menu( + _failed_backup: bool, + _battery_percentage: u8, + _paired_devices: heapless::Vec, 1>, + ) -> Result { + Err::, Error>(Error::ValueError( + c"show_device_menu not supported", + )) + } + fn show_info( title: TString<'static>, description: TString<'static>, diff --git a/core/embed/rust/src/ui/layout_eckhart/component/button.rs b/core/embed/rust/src/ui/layout_eckhart/component/button.rs index ae65aacc8f..fbaafccedd 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs @@ -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, content: ButtonContent, content_offset: Offset, - styles: ButtonStyleSheet, + stylesheet: ButtonStyleSheet, text_align: Alignment, radius: Option, 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, + ) -> 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, + ) -> 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")] diff --git a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs index bb096f1f29..5c0f44cbd1 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs @@ -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 { + 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()), + } + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs new file mode 100644 index 0000000000..78d5f39ea0 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs @@ -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)>, + stylesheet: ButtonStyleSheet, + action: Option, +} + +impl MenuItem { + pub fn new(text: TString<'static>, action: Option) -> Self { + Self { + text, + subtext: None, + stylesheet: theme::menu_item_title(), + action, + } + } + + pub fn with_subtext(mut self, subtext: Option<(TString<'static>, Option)>) -> 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, +} + +impl SubmenuScreen { + pub fn new(header_text: TString<'static>, items: Vec) -> 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, + paired_device_screen: Option, + about_screen: Option; 2]>>>, + + // Information needed to construct any subscreen on demand + subscreens: Vec, + + // 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, +} + +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, 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 = 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, 1>, + paired_device_indices: Vec, + ) -> usize { + let mut items: Vec = 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 = 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 = 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 = 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 = 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 = 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 { + 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 { + 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 { + // 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"); + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs index 95c1340c43..9c575f8341 100644 --- a/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs @@ -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; diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/continue_recovery_homepage.rs b/core/embed/rust/src/ui/layout_eckhart/flow/continue_recovery_homepage.rs index b9bc141771..4239f20e47 100644 --- a/core/embed/rust/src/ui/layout_eckhart/flow/continue_recovery_homepage.rs +++ b/core/embed/rust/src/ui/layout_eckhart/flow/continue_recovery_homepage.rs @@ -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) diff --git a/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs b/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs index 79824ec53c..83e0cb30b2 100644 --- a/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs @@ -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 { diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs index bd23454d3b..608903acfe 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -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, 1>, + ) -> Result { + let layout = RootComponent::new(DeviceMenuScreen::new( + failed_backup, + battery_percentage, + paired_devices, + )); + Ok(layout) + } + fn show_info( title: TString<'static>, description: TString<'static>, diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs index 9bc3cc35f7..c0642ff05c 100644 --- a/core/embed/rust/src/ui/ui_firmware.rs +++ b/core/embed/rust/src/ui/ui_firmware.rs @@ -303,6 +303,12 @@ pub trait FirmwareUI { notification_level: u8, ) -> Result; + fn show_device_menu( + failed_backup: bool, + battery_percentage: u8, + paired_devices: Vec, 1>, + ) -> Result; + fn show_info( title: TString<'static>, description: TString<'static>, diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi index ed23f903b2..9d6e098e9a 100644 --- a/core/mocks/generated/trezorui_api.pyi +++ b/core/mocks/generated/trezorui_api.pyi @@ -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( *, diff --git a/core/src/apps/homescreen/__init__.py b/core/src/apps/homescreen/__init__.py index 4dcd7646e5..f158d1fc2e 100644 --- a/core/src/apps/homescreen/__init__.py +++ b/core/src/apps/homescreen/__init__.py @@ -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: