Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eckhart vertical menu improvement #4617

Merged
merged 5 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions core/embed/rust/src/ui/layout_eckhart/component/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
strutil::TString,
time::Duration,
ui::{
component::{Component, Event, EventCtx, Timer},
component::{text::TextStyle, Component, Event, EventCtx, Timer},
display::{toif::Icon, Color, Font},
event::TouchEvent,
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
Expand Down Expand Up @@ -37,6 +37,8 @@ pub struct Button {

impl Button {
pub const BASELINE_OFFSET: Offset = Offset::new(2, 6);
const LINE_SPACING: i16 = 7;
const SUBTEXT_STYLE: TextStyle = theme::label_menu_item_subtitle();

pub const fn new(content: ButtonContent) -> Self {
Self {
Expand All @@ -57,6 +59,10 @@ impl Button {
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 const fn with_icon(icon: Icon) -> Self {
Self::new(ButtonContent::Icon(icon))
}
Expand Down Expand Up @@ -147,6 +153,24 @@ impl Button {
&self.content
}

pub fn content_height(&self) -> i16 {
match &self.content {
ButtonContent::Empty => 0,
ButtonContent::Text(_) => self.style().font.allcase_text_height(),
ButtonContent::Icon(icon) => icon.toif.height(),
ButtonContent::IconAndText(child) => {
let text_height = self.style().font.allcase_text_height();
let icon_height = child.icon.toif.height();
text_height.max(icon_height)
}
ButtonContent::TextAndSubtext(_, _) => {
self.style().font.allcase_text_height()
+ Self::LINE_SPACING
+ Self::SUBTEXT_STYLE.text_font.allcase_text_height()
}
}
}

pub fn set_stylesheet(&mut self, styles: ButtonStyleSheet) {
if self.styles != styles {
self.styles = styles;
Expand Down Expand Up @@ -208,7 +232,7 @@ impl Button {
match &self.content {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
let y_offset = Offset::y(self.style().font.allcase_text_height() / 2);
let y_offset = Offset::y(self.content_height() / 2);
let start_of_baseline = match self.text_align {
Alignment::Start => {
self.area.left_center() + Offset::x(Self::BASELINE_OFFSET.x)
Expand All @@ -224,6 +248,37 @@ impl Button {
.render(target);
});
}
ButtonContent::TextAndSubtext(text, subtext) => {
let text_y_offset = Offset::y(
self.content_height() / 2 - self.style().font.allcase_text_height() / 2,
);
let subtext_y_offset = Offset::y(self.content_height() / 2);
let start_of_baseline = match self.text_align {
Alignment::Start => {
self.area.left_center() + Offset::x(Self::BASELINE_OFFSET.x)
}
Alignment::Center => self.area.center(),
Alignment::End => self.area.right_center() - Offset::x(Self::BASELINE_OFFSET.x),
};
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)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
});

text.map(|subtext| {
shape::Text::new(subtext_baseline, subtext, Self::SUBTEXT_STYLE.text_font)
.with_fg(Self::SUBTEXT_STYLE.text_color)
.with_align(self.text_align)
.with_alpha(alpha)
.render(target);
});
}
ButtonContent::Icon(icon) => {
shape::ToifImage::new(self.area.center(), icon.toif)
.with_align(Alignment2D::CENTER)
Expand Down Expand Up @@ -376,6 +431,9 @@ impl crate::trace::Trace for Button {
t.string("text", content.text);
t.bool("icon", true);
}
ButtonContent::TextAndSubtext(text, _) => {
t.string("text", *text);
}
}
}
}
Expand All @@ -392,6 +450,7 @@ enum State {
pub enum ButtonContent {
Empty,
Text(TString<'static>),
TextAndSubtext(TString<'static>, TString<'static>),
Icon(Icon),
IconAndText(IconText),
}
Expand All @@ -403,7 +462,7 @@ pub struct ButtonStyleSheet {
pub disabled: &'static ButtonStyle,
}

#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Clone)]
pub struct ButtonStyle {
pub font: Font,
pub text_color: Color,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub struct Header {

#[derive(Copy, Clone)]
pub enum HeaderMsg {
Back,
Cancelled,
Menu,
}
Expand Down
8 changes: 5 additions & 3 deletions core/embed/rust/src/ui/layout_eckhart/component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ where
match msg {
HeaderMsg::Cancelled => return Some(TextScreenMsg::Cancelled),
HeaderMsg::Menu => return Some(TextScreenMsg::Menu),
_ => {}
}
}
if let Some(msg) = self.action_bar.event(ctx, event) {
Expand Down
198 changes: 198 additions & 0 deletions core/embed/rust/src/ui/layout_eckhart/component/vertical_menu.rs
Original file line number Diff line number Diff line change
@@ -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<Button, MENU_MAX_ITEMS>;

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<Self::Msg> {
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);
}
});
}
}
Loading
Loading