diff --git a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs index 9732074a52..5d1764cb94 100644 --- a/core/embed/rust/src/ui/layout_eckhart/component/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/component/mod.rs @@ -6,17 +6,19 @@ mod header; mod hint; mod result; mod text_screen; -mod vertical_menu_page; +mod vertical_menu; +mod vertical_menu_screen; mod welcome_screen; pub use action_bar::ActionBar; -pub use button::{Button, ButtonMsg, ButtonStyle, ButtonStyleSheet, IconText}; +pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, IconText}; pub use error::ErrorScreen; pub use header::{Header, HeaderMsg}; pub use hint::Hint; pub use result::{ResultFooter, ResultScreen, ResultStyle}; pub use text_screen::{AllowedTextContent, TextScreen, TextScreenMsg}; -pub use vertical_menu_page::VerticalMenuPage; +pub use vertical_menu::{VerticalMenu, VerticalMenuMsg, MENU_MAX_ITEMS}; +pub use vertical_menu_screen::{VerticalMenuScreen, VerticalMenuScreenMsg}; pub use welcome_screen::WelcomeScreen; use super::{constant, theme}; diff --git a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs new file mode 100644 index 0000000000..85de9f9f14 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs @@ -0,0 +1,198 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + geometry::{Insets, Offset, Rect}, + layout_eckhart::{ + component::{Button, ButtonContent, ButtonMsg}, + theme, + }, + shape::{Bar, Renderer}, +}; + +use heapless::Vec; + +/// Number of buttons. +/// Presently, VerticalMenu holds only fixed number of buttons. +pub const MENU_MAX_ITEMS: usize = 5; + +type VerticalMenuButtons = Vec; + +pub struct VerticalMenu { + /// Bounds the sliding window of the menu. + bounds: Rect, + /// FUll bounds of the menu, including off-screen items. + virtual_bounds: Rect, + /// Menu items. + buttons: VerticalMenuButtons, + /// Whether to show separators between buttons. + separators: bool, + /// Vertical offset of the current view. + offset_y: i16, + /// Maximum vertical offset. + max_offset: i16, +} + +pub enum VerticalMenuMsg { + Selected(usize), + /// Left header button clicked + Back, + /// Right header button clicked + Close, +} + +impl VerticalMenu { + const SIDE_INSET: i16 = 24; + const BUTTON_PADDING: i16 = 28; + + fn new(buttons: VerticalMenuButtons) -> Self { + Self { + virtual_bounds: Rect::zero(), + bounds: Rect::zero(), + buttons, + separators: false, + offset_y: 0, + max_offset: 0, + } + } + + pub fn empty() -> Self { + Self::new(VerticalMenuButtons::new()) + } + + pub fn with_separators(mut self) -> Self { + self.separators = true; + self + } + + pub fn item(mut self, button: Button) -> Self { + unwrap!(self.buttons.push(button.styled(theme::menu_item_title()))); + self + } + + pub fn item_yellow(mut self, button: Button) -> Self { + unwrap!(self + .buttons + .push(button.styled(theme::menu_item_title_yellow()))); + self + } + + pub fn item_red(mut self, button: Button) -> Self { + unwrap!(self + .buttons + .push(button.styled(theme::menu_item_title_red()))); + self + } + + pub fn area(&self) -> Rect { + self.bounds + } + + /// Scroll the menu to the desired offset. + pub fn set_offset(&mut self, offset_y: i16) { + self.offset_y = offset_y.max(0).min(self.max_offset); + } + + /// Chcek if the menu is on the bottom. + pub fn is_max_offset(&self) -> bool { + self.offset_y == self.max_offset + } + + /// Get the current sliding window offset. + pub fn get_offset(&self) -> i16 { + self.offset_y + } + + /// Update menu buttons based on the current offset. + pub fn update_menu(&mut self, ctx: &mut EventCtx) { + for button in self.buttons.iter_mut() { + let in_bounds = button + .area() + .translate(Offset::y(-self.offset_y)) + .union(self.bounds) + == self.bounds; + button.enable_if(ctx, in_bounds); + } + } +} + +impl Component for VerticalMenu { + type Msg = VerticalMenuMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // Crop the menu area + self.bounds = bounds.inset(Insets::sides(Self::SIDE_INSET)); + + let button_width = self.bounds.width(); + let mut top_left = self.bounds.top_left(); + + for button in self.buttons.iter_mut() { + let button_height = button.content_height() + 2 * Self::BUTTON_PADDING; + + // Calculate button bounds (might overflow the menu bounds) + let button_bounds = + Rect::from_top_left_and_size(top_left, Offset::new(button_width, button_height)); + button.place(button_bounds); + + top_left = top_left + Offset::y(button_height); + } + + // Calculate virtual bounds of all buttons combined + let height = top_left.y - self.bounds.top_left().y; + self.virtual_bounds = Rect::from_top_left_and_size( + self.bounds.top_left(), + Offset::new(self.bounds.width(), height), + ); + + // Calculate maximum offset for scrolling + self.max_offset = (self.virtual_bounds.height() - self.bounds.height()).max(0); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + for (i, button) in self.buttons.iter_mut().enumerate() { + if let Some(ButtonMsg::Clicked) = button.event(ctx, event) { + return Some(VerticalMenuMsg::Selected(i)); + } + } + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + // Clip and translate the sliding window based on the scroll offset + target.in_clip(self.bounds, &|target| { + target.with_origin(Offset::y(-self.offset_y), &|target| { + // Render menu button + for button in (&self.buttons).into_iter() { + button.render(target); + } + + // Render separators between buttons + if self.separators { + for i in 1..self.buttons.len() { + let button = self.buttons.get(i).unwrap(); + + // Render a line above the button + let separator = Rect::from_top_left_and_size( + button.area().top_left(), + Offset::new(button.area().width(), 1), + ); + Bar::new(separator) + .with_fg(theme::GREY_EXTRA_DARK) + .render(target); + } + } + }); + }); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for VerticalMenu { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("VerticalMenu"); + t.in_list("buttons", &|button_list| { + for button in &self.buttons { + button_list.child(button); + } + }); + } +} diff --git a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_page.rs b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_page.rs deleted file mode 100644 index cb30a10962..0000000000 --- a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_page.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::{ - strutil::TString, - ui::{ - component::{Component, Event, EventCtx}, - display::Icon, - geometry::Rect, - layout_eckhart::{component::button::IconText, theme}, - shape::Renderer, - }, -}; - -use heapless::Vec; - -use super::{button::ButtonMsg, constant, Button, Header, HeaderMsg}; - -/// Number of buttons. -/// Presently, VerticalMenu holds only fixed number of buttons. -const MENU_MAX_ITEMS: usize = 4; - -type VerticalMenuButtons = Vec; - -/// TODO: this is just a mockup for now -pub struct VerticalMenuPage { - header: Header, - buttons: VerticalMenuButtons, -} - -pub enum VerticalMenuMsg { - Selected(usize), - /// Left header button clicked - Back, - /// Right header button clicked - Close, -} - -impl VerticalMenuPage { - fn new(buttons: VerticalMenuButtons) -> Self { - Self { - header: Header::new(TString::empty()), - buttons, - } - } - - pub fn empty() -> Self { - Self::new(VerticalMenuButtons::new()) - } - - pub fn with_header(mut self, header: Header) -> Self { - self.header = header; - self - } - - pub fn item(mut self, icon: Icon, text: TString<'static>) -> Self { - unwrap!(self.buttons.push( - Button::with_icon_and_text(IconText::new(text, icon)).styled(theme::button_default()) - )); - self - } - - pub fn danger(mut self, icon: Icon, text: TString<'static>) -> Self { - unwrap!( - (self.buttons.push( - Button::with_icon_and_text(IconText::new(text, icon)) - .styled(theme::button_warning_high()) - )), - "unwrap failed" - ); - self - } -} - -impl Component for VerticalMenuPage { - type Msg = VerticalMenuMsg; - - fn place(&mut self, bounds: Rect) -> Rect { - // assert full screen - debug_assert_eq!(bounds.height(), constant::HEIGHT); - debug_assert_eq!(bounds.width(), constant::WIDTH); - - const MENU_BUTTON_HEIGHT: i16 = 64; // TODO: variable height buttons - /// Fixed height of a separator. - const MENU_SEP_HEIGHT: i16 = 2; - let n_seps = self.buttons.len() - 1; - let (header_area, mut rest) = bounds.split_top(Header::HEADER_HEIGHT); - for (i, button) in self.buttons.iter_mut().enumerate() { - let (area_button, new_remaining) = rest.split_top(MENU_BUTTON_HEIGHT); - button.place(area_button); - rest = new_remaining; - if i < n_seps { - let (_area_sep, new_remaining) = rest.split_top(MENU_SEP_HEIGHT); - rest = new_remaining; - } - } - - self.header.place(header_area); - - bounds - } - - fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { - if let Some(msg) = self.header.event(ctx, event) { - match msg { - HeaderMsg::Cancelled => return Some(VerticalMenuMsg::Close), - _ => {} - } - } - - for (i, button) in self.buttons.iter_mut().enumerate() { - if let Some(ButtonMsg::Clicked) = button.event(ctx, event) { - return Some(VerticalMenuMsg::Selected(i)); - } - } - None - } - - fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { - for (i, button) in (&self.buttons).into_iter().enumerate() { - button.render(target); - } - } -} - -#[cfg(feature = "ui_debug")] -impl crate::trace::Trace for VerticalMenuPage { - fn trace(&self, t: &mut dyn crate::trace::Tracer) { - t.component("VerticalMenuPage"); - t.in_list("buttons", &|button_list| { - for button in &self.buttons { - button_list.child(button); - } - }); - } -} diff --git a/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs new file mode 100644 index 0000000000..b7388f7d26 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/component/vertical_menu_screen.rs @@ -0,0 +1,193 @@ +use crate::{ + strutil::TString, + ui::{ + component::{ + base::AttachType, + swipe_detect::{SwipeConfig, SwipeSettings}, + Component, Event, EventCtx, SwipeDetect, + }, + event::{SwipeEvent, TouchEvent}, + geometry::{Alignment2D, Direction, Offset, Rect}, + layout_eckhart::{ + component::{constant::screen, Header, HeaderMsg, VerticalMenu, VerticalMenuMsg}, + theme, + }, + shape::{Renderer, ToifImage}, + }, +}; + +pub struct VerticalMenuScreen { + header: Header, + /// Scrollable vertical menu + menu: VerticalMenu, + /// Base position of the menu sliding window to scroll around + offset_base: i16, + /// Swipe detector + swipe: SwipeDetect, + /// Swipe configuration + swipe_config: SwipeConfig, +} + +pub enum VerticalMenuScreenMsg { + Selected(usize), + /// Left header button clicked + Back, + /// Right header button clicked + Close, +} + +impl VerticalMenuScreen { + pub fn new(menu: VerticalMenu) -> Self { + Self { + header: Header::new(TString::empty()), + menu, + offset_base: 0, + swipe: SwipeDetect::new(), + swipe_config: SwipeConfig::new() + .with_swipe(Direction::Up, SwipeSettings::default()) + .with_swipe(Direction::Down, SwipeSettings::default()), + } + } + + pub fn with_header(mut self, header: Header) -> Self { + self.header = header; + self + } + + // Shift position of touch events in the menu area by an offset of the current + // sliding window position + fn shift_touch_event(&self, event: Event) -> Option { + match event { + Event::Touch(touch_event) => { + let shifted_event = match touch_event { + TouchEvent::TouchStart(point) if self.menu.area().contains(point) => Some( + TouchEvent::TouchStart(point.ofs(Offset::y(self.menu.get_offset())).into()), + ), + TouchEvent::TouchMove(point) if self.menu.area().contains(point) => Some( + TouchEvent::TouchMove(point.ofs(Offset::y(self.menu.get_offset())).into()), + ), + TouchEvent::TouchEnd(point) if self.menu.area().contains(point) => Some( + TouchEvent::TouchEnd(point.ofs(Offset::y(self.menu.get_offset())).into()), + ), + _ => None, // Ignore touch events outside the bounds + }; + shifted_event.map(Event::Touch) + } + _ => None, // Ignore other events + } + } + + /// Update menu buttons based on the current offset. + pub fn update_menu(&mut self, ctx: &mut EventCtx) { + self.menu.update_menu(ctx); + } +} + +impl Component for VerticalMenuScreen { + type Msg = VerticalMenuScreenMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // assert full screen + debug_assert_eq!(bounds.height(), screen().height()); + debug_assert_eq!(bounds.width(), screen().width()); + + let (header_area, menu_area) = bounds.split_top(Header::HEADER_HEIGHT); + + self.menu.place(menu_area); + self.header.place(header_area); + + 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 { + self.update_menu(ctx); + } + + match self.swipe.event(ctx, event, self.swipe_config) { + Some(SwipeEvent::Start(_)) => { + // Lock the base position to scroll around + self.offset_base = self.menu.get_offset(); + } + + Some(SwipeEvent::End(_)) => { + // Lock the base position to scroll around + self.offset_base = self.menu.get_offset(); + } + + Some(SwipeEvent::Move(dir, delta)) => { + // Decrease the sensitivity of the swipe + let delta = delta / 10; + // Scroll the menu based on the swipe direction + match dir { + Direction::Up => { + self.menu.set_offset(self.offset_base + delta); + self.menu.update_menu(ctx); + return None; + } + Direction::Down => { + self.menu.set_offset(self.offset_base - delta); + self.menu.update_menu(ctx); + return None; + } + _ => {} + } + } + _ => {} + }; + + if let Some(msg) = self.header.event(ctx, event) { + match msg { + HeaderMsg::Cancelled => return Some(VerticalMenuScreenMsg::Close), + HeaderMsg::Back => return Some(VerticalMenuScreenMsg::Back), + _ => {} + } + } + + // Shift touch events in the menu area by the current sliding window position + if let Some(shifted) = self.shift_touch_event(event) { + if let Some(msg) = self.menu.event(ctx, shifted) { + match msg { + VerticalMenuMsg::Selected(i) => { + return Some(VerticalMenuScreenMsg::Selected(i)) + } + _ => {} + } + } + } + + // Handle shifted touch events in the menu + if let Some(msg) = self.menu.event(ctx, event) { + match msg { + VerticalMenuMsg::Selected(i) => return Some(VerticalMenuScreenMsg::Selected(i)), + _ => {} + } + } + + None + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + self.header.render(target); + self.menu.render(target); + + // Render the down arrow if the menu can be scrolled down + if !self.menu.is_max_offset() { + ToifImage::new( + self.menu.area().bottom_center(), + theme::ICON_CHEVRON_DOWN_MINI.toif, + ) + .with_align(Alignment2D::BOTTOM_CENTER) + .with_fg(theme::GREY_LIGHT) + .render(target); + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for VerticalMenuScreen { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("VerticalMenuScreen"); + } +}