diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index a7fc14ddb8..c411ff0c49 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -98,6 +98,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; @@ -238,6 +239,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; @@ -350,6 +352,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; @@ -644,6 +647,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 3c02b1a2db..f6426c3f6d 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: usize = 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()?; @@ -1560,6 +1573,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: bool, + /// paired_devices: Iterable[str], + /// ) -> LayoutObj[UiResult]: + /// """Idle homescreen.""" + 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/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs index b9b1757f14..d6253dba1c 100644 --- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs @@ -870,6 +870,10 @@ impl FirmwareUI for UIBolt { Ok(layout) } + fn show_device_menu(_failed_backup: bool, _battery_percentage: usize, _paired_devices: Vec, 10>) -> 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 238565b08f..41bbb78f37 100644 --- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs @@ -1036,6 +1036,10 @@ impl FirmwareUI for UICaesar { Ok(layout) } + fn show_device_menu(_failed_backup: bool, _battery_percentage: usize, _paired_devices: Vec, 10>) -> 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 cf2a5e8c11..4c19f0c693 100644 --- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs @@ -888,6 +888,10 @@ impl FirmwareUI for UIDelizia { Ok(layout) } + fn show_device_menu(_failed_backup: bool, _battery_percentage: usize, _paired_devices: Vec, 10>) -> 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 60e778e1b5..e6ac6467c3 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs @@ -13,7 +13,7 @@ use crate::{ }, }; -use super::super::theme; +use super::super::{fonts, theme}; pub enum ButtonMsg { Pressed, @@ -27,7 +27,8 @@ pub struct Button { touch_expand: Option, content: ButtonContent, content_offset: Offset, - styles: ButtonStyleSheet, + stylesheet: ButtonStyleSheet, + subtext_style: TextStyle, text_align: Alignment, radius: Option, state: State, @@ -39,9 +40,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"))] + const SUBTEXT_STYLE_GREEN: TextStyle = theme::label_menu_item_subtitle_green(); + #[cfg(feature = "bootloader")] + 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 { @@ -49,7 +64,8 @@ impl Button { content_offset: Offset::zero(), area: Rect::zero(), touch_expand: None, - styles: theme::button_default(), + stylesheet: theme::button_default(), + subtext_style: Self::DEFAULT_SUBTEXT_STYLE, text_align: Alignment::Center, radius: None, state: State::Initial, @@ -59,6 +75,25 @@ impl Button { } } + pub fn new_menu_item( + text: TString<'static>, + subtext: Option>, + stylesheet: ButtonStyleSheet, + ) -> Self { + match subtext { + Some(subtext) => Self::with_text_and_subtext(text, subtext) + .with_text_align(Self::MENU_ITEM_ALIGNMENT) + .with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET) + .styled(stylesheet) + .with_radius(Self::MENU_ITEM_RADIUS), + None => 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 const fn with_text(text: TString<'static>) -> Self { Self::new(ButtonContent::Text(text)) } @@ -84,8 +119,13 @@ 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 + } + + pub const fn subtext_green(mut self) -> Self { + self.subtext_style = Self::SUBTEXT_STYLE_GREEN; self } @@ -196,29 +236,29 @@ impl Button { ButtonContent::TextAndSubtext(_, _) => { self.style().font.allcase_text_height() + Self::LINE_SPACING - + Self::SUBTEXT_STYLE.text_font.allcase_text_height() + + self.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 { @@ -260,10 +300,11 @@ impl Button { } } - pub fn render_content<'s>( + fn render_content<'s>( &self, target: &mut impl Renderer<'s>, - style: &ButtonStyle, + stylesheet: &ButtonStyle, + subtext_style: &TextStyle, alpha: u8, ) { match &self.content { @@ -276,8 +317,8 @@ 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); @@ -285,7 +326,7 @@ impl Button { } ButtonContent::TextAndSubtext(text, subtext) => { 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, @@ -295,17 +336,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); @@ -314,7 +355,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); } @@ -327,8 +368,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); @@ -337,7 +378,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 { @@ -360,7 +401,7 @@ impl Button { pub fn render_with_alpha<'s>(&self, target: &mut impl Renderer<'s>, alpha: u8) { let style = self.style(); self.render_background(target, style, alpha); - self.render_content(target, style, alpha); + self.render_content(target, style, &self.subtext_style, alpha); } } @@ -469,7 +510,7 @@ impl Component for Button { fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { let style = self.style(); self.render_background(target, style, 0xFF); - self.render_content(target, style, 0xFF); + self.render_content(target, style, &self.subtext_style, 0xFF); } } 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..c8dbc0741b 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::NotImplemented => "NotImplemented".try_into(), + DeviceMenuMsg::BackupFailed => "BackupFailed".try_into(), + DeviceMenuMsg::PairNewDevice => "PairNewDevice".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..ed70ed7306 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs @@ -0,0 +1,432 @@ +use crate::{ + strutil::TString, + ui::{ + component::{ + base::AttachType, + text::paragraphs::{Paragraph, Paragraphs}, + Component, Event, EventCtx, + }, + geometry::Rect, + layout_eckhart::{ + component::Button, + 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_SUBMENUS: usize = 10; + +#[derive(Clone, Debug)] +enum Action { + // Go to another registered child screen + GoTo(usize), + + // Return a DeviceMenuMsg to the caller + Return(DeviceMenuMsg), +} + +struct SubmenuScreen { + pub screen: VerticalMenuScreen, + + // actions for the menu items in the VerticalMenuScreen, in order + pub actions: Vec, MENU_MAX_ITEMS>, +} + +enum Subscreen { + Submenu(SubmenuScreen), + AboutScreen, +} + +#[derive(Copy, Clone, Debug)] +pub enum DeviceMenuMsg { + NotImplemented, + + // Root menu + BackupFailed, + + // "Pair & Connect" + PairNewDevice, + + // Security menu + CheckBackup, + WipeDevice, + + // Device menu + ScreenBrightness, + + Close, +} + +pub struct DeviceMenuScreen<'a> { + bounds: Rect, + + about_screen: TextScreen; 2]>>, + + // all the subscreens in the DeviceMenuScreen + // which can be either VerticalMenuScreens with associated Actions + // or some predefined TextScreens, such as "About" + subscreens: Vec, + + // the 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: usize, + paired_devices: Vec, 10>, + ) -> Self { + let about_content = Paragraphs::new([ + Paragraph::new(&theme::firmware::TEXT_REGULAR, "Firmware version"), + Paragraph::new(&theme::firmware::TEXT_REGULAR, "2.3.1"), // TODO + ]); + + let about_screen = TextScreen::new(about_content) + .with_header(Header::new("About".into()).with_close_button()); + + let mut screen = Self { + bounds: Rect::zero(), + about_screen, + 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 device in &paired_devices { + unwrap!(paired_device_indices.push(screen.add_paired_device_menu(*device))); + } + + 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, + battery_percentage, + pair_and_connect, + settings, + ); + + screen.set_active_subscreen(root); + + screen + } + + fn add_paired_device_menu(&mut self, device: TString<'static>) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + menu = menu.item(Button::new_menu_item( + device.into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(None)); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("Manage".into()) + .with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_paired_devices_menu( + &mut self, + paired_devices: Vec, 10>, + paired_device_indices: Vec, + ) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + for (device, idx) in paired_devices.iter().zip(paired_device_indices) { + menu = menu.item(Button::new_menu_item( + (*device).into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::GoTo(idx)))); + } + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("Manage paired devices".into()) + .with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled) + .with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_pair_and_connect_menu(&mut self, manage_devices_index: usize) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + menu = menu.item( + Button::new_menu_item( + "Manage paired devices".into(), + Some("1 device connected".into()), // TODO + theme::menu_item_title(), + ) + .subtext_green(), + ); + unwrap!(actions.push(Some(Action::GoTo(manage_devices_index)))); + menu = menu.item(Button::new_menu_item( + "Pair new device".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::Return(DeviceMenuMsg::PairNewDevice)))); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("Pair & Connect".into()) + .with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled) + .with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_settings_menu(&mut self, security_index: usize, device_index: usize) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + menu = menu.item(Button::new_menu_item( + "Security".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::GoTo(security_index)))); + menu = menu.item(Button::new_menu_item( + "Device".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::GoTo(device_index)))); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("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), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_security_menu(&mut self) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + menu = menu.item(Button::new_menu_item( + "Check backup".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::Return(DeviceMenuMsg::CheckBackup)))); + menu = menu.item(Button::new_menu_item( + "Wipe device".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::Return(DeviceMenuMsg::WipeDevice)))); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("Security".into()) + .with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled) + .with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_device_menu(&mut self, device_name: TString<'static>, about_index: usize) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + menu = menu.item(Button::new_menu_item( + "Name".into(), + Some(device_name), + theme::menu_item_title(), + )); + unwrap!(actions.push(None)); + menu = menu.item(Button::new_menu_item( + "Screen brightness".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::Return(DeviceMenuMsg::ScreenBrightness)))); + menu = menu.item(Button::new_menu_item( + "About".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::GoTo(about_index)))); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("Security".into()) + .with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled) + .with_left_button(Button::with_icon(theme::ICON_CHEVRON_LEFT), HeaderMsg::Back), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + fn add_root_menu( + &mut self, + failed_backup: bool, + battery_percentage: usize, + pair_and_connect_index: usize, + settings_index: usize, + ) -> usize { + let mut actions: Vec, MENU_MAX_ITEMS> = Vec::new(); + let mut menu = VerticalMenu::empty().with_separators(); + if failed_backup { + menu = menu.item(Button::new_menu_item( + "Backup failed".into(), + Some("Review".into()), + theme::menu_item_title_red(), + )); + unwrap!(actions.push(Some(Action::Return(DeviceMenuMsg::BackupFailed)))); + } + + menu = menu.item( + Button::new_menu_item( + "Pair & connect".into(), + Some("1 device connected".into()), // TODO + theme::menu_item_title(), + ) + .subtext_green(), + ); + unwrap!(actions.push(Some(Action::GoTo(pair_and_connect_index)))); + + menu = menu.item(Button::new_menu_item( + "Settings".into(), + None, + theme::menu_item_title(), + )); + unwrap!(actions.push(Some(Action::GoTo(settings_index)))); + + let screen = VerticalMenuScreen::new(menu).with_header( + Header::new("".into()) + .with_battery(battery_percentage) + .with_right_button(Button::with_icon(theme::ICON_CROSS), HeaderMsg::Cancelled), + ); + + self.add_subscreen(Subscreen::Submenu(SubmenuScreen { screen, actions })) + } + + 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; + } + + 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.actions[idx] { + Some(Action::GoTo(menu)) => { + menu_screen.screen.update_menu(ctx); + unwrap!(self.parent_subscreens.push(self.active_subscreen)); + self.active_subscreen = menu; + self.place(self.bounds); + } + Some(Action::Return(msg)) => return Some(msg), + None => {} + }; + } + _ => { + assert!(false, "Expected a submenu!"); + } + } + + None + } + + fn go_back(&mut self) -> Option { + if let Some(parent) = self.parent_subscreens.pop() { + self.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(ref mut menu_screen) => menu_screen.screen.place(bounds), + Subscreen::AboutScreen => self.about_screen.place(bounds), + }; + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Update the menu when the screen is attached + if let Event::Attach(AttachType::Initial) = event {} + + // Handle the event for the active menu + match self.subscreens[self.active_subscreen] { + Subscreen::Submenu(ref mut menu_screen) => match menu_screen.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::AboutScreen => match self.about_screen.event(ctx, event) { + Some(TextScreenMsg::Cancelled) => { + return self.go_back(); + } + _ => {} + }, + } + + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + match &self.subscreens[self.active_subscreen] { + Subscreen::Submenu(ref menu_screen) => menu_screen.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/header.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/header.rs index 22161a58b9..d480130754 100644 --- a/core/embed/rust/src/ui/layout_eckhart/firmware/header.rs +++ b/core/embed/rust/src/ui/layout_eckhart/firmware/header.rs @@ -138,6 +138,19 @@ impl Header { } } + pub fn with_battery(self, percentage: usize) -> Self { + let is_low_battery = percentage < 20; // TODO + + self.with_icon( + theme::ICON_BATTERY_ZAP, + if is_low_battery { + theme::YELLOW + } else { + theme::GREEN_LIME + }, + ) + } + #[inline(never)] pub fn with_menu_button(self) -> Self { self.with_right_button( 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/theme/firmware.rs b/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs index b170b277f1..a6305d67cd 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 e7d8b32a9d..518e581821 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -32,6 +32,7 @@ use crate::{ use super::{ component::Button, firmware::{ + DeviceMenuScreen, ActionBar, Bip39Input, ConfirmHomescreen, Header, HeaderMsg, Hint, Homescreen, MnemonicKeyboard, NumberInputScreen, PinKeyboard, SelectWordCountScreen, SelectWordScreen, SetBrightnessScreen, Slip39Input, TextScreen, @@ -39,6 +40,8 @@ use super::{ flow, fonts, theme, UIEckhart, }; +use heapless::Vec; + impl FirmwareUI for UIEckhart { fn confirm_action( title: TString<'static>, @@ -780,6 +783,11 @@ impl FirmwareUI for UIEckhart { Ok(layout) } + fn show_device_menu(failed_backup: bool, battery_percentage: usize, paired_devices: Vec, 10>) -> 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 0ed6421f33..8c8b87d052 100644 --- a/core/embed/rust/src/ui/ui_firmware.rs +++ b/core/embed/rust/src/ui/ui_firmware.rs @@ -301,6 +301,12 @@ pub trait FirmwareUI { notification_level: u8, ) -> Result; + fn show_device_menu( + failed_backup: bool, + battery_percentage: usize, + paired_devices: Vec, 10>, + ) -> 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 60b8edc263..439b8d89ee 100644 --- a/core/mocks/generated/trezorui_api.pyi +++ b/core/mocks/generated/trezorui_api.pyi @@ -531,6 +531,16 @@ def show_homescreen( """Idle homescreen.""" +# rust/src/ui/api/firmware_micropython.rs +def show_device_menu( + *, + failed_backup: bool, + battery_percentage: bool, + paired_devices: Iterable[str], +) -> LayoutObj[UiResult]: + """Idle homescreen.""" + + # 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 e01801806e..6c8357cce3 100644 --- a/core/src/apps/homescreen/__init__.py +++ b/core/src/apps/homescreen/__init__.py @@ -5,7 +5,7 @@ import storage.cache import storage.device from trezor import config, wire from trezor.enums import MessageType -from trezor.ui.layouts import error_popup, raise_if_not_confirmed +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 @@ -22,7 +22,7 @@ async def busyscreen() -> None: async def homescreen() -> None: from trezor import TR - from trezorui_api import INFO + from trezorui_api import INFO, show_device_menu if storage.device.is_initialized(): label = storage.device.get_label() @@ -57,15 +57,15 @@ async def homescreen() -> None: try: res = await obj.get_result() if res is INFO: - # trigger device menu - await raise_if_not_confirmed( - error_popup( - "Not implemented", - "DeviceMenu not yet implemented", - button="OK then..", - ), - "device_menu", - ) + + ##### MOCK DATA + failed_backup = True + battery_percentage = 22 + paired_devices = ["Suite on my de-Googled Phone"] + ##### + + menu_result = await raise_if_not_confirmed(show_device_menu(failed_backup=failed_backup, battery_percentage=battery_percentage, paired_devices=paired_devices), "device_menu") + print(menu_result) else: lock_device() finally: