chore(core/eckhart): overlap vertical menu and header
What changed, and why it matters
This is a user-interface layout tweak for the Trezor hardware wallet's new 'Eckhart' design. It makes the first menu button visually overlap the screen header when there is no subtitle, and adds logic so the header is drawn on top if its buttons are being pressed. There is no security-relevant change here.
No security action required; treat as a normal UI/layout commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit modifies Rust UI code for the Trezor firmware’s layout_eckhart theme. It introduces a first_item_shrink flag in VerticalMenu, a BUTTON_TOP_SHRINK constant, and a Header::pressed() helper. When no subtitle is present, the menu’s first button is placed higher so it overlaps the header, and the render order is chosen so the header appears on top while its own buttons are pressed. Subtitles disable the overlap. A unit test checks that the overlap is smaller than the button padding so text is not clipped. No cryptographic, authentication, or privileged operations are touched.
Changed components
core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/embed/rust/src/ui/layout_eckhart/firmware/header.rscore/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu.rscore/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rsInspect captured patch +113 / −21
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
index 07b32631..60147950 100644
--- 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
@@ -699,6 +699,8 @@ impl DeviceMenuScreen {
self.place(self.bounds);
if let ActiveScreen::Menu(screen, ..) = self.active_screen.deref_mut() {
screen.initialize_screen(ctx);
+ } else if let ActiveScreen::Device(screen) = self.active_screen.deref_mut() {
+ screen.initialize_screen(ctx);
}
}
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 f19336df..71b44dfb 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/header.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/header.rs
@@ -129,6 +129,13 @@ impl Header {
ctx.request_paint();
}
+ /// Whether any of the buttons is currently pressed
+ /// Used for defining the order of rendering of overlapping components
+ pub fn pressed(&self) -> bool {
+ self.left_button.as_ref().is_some_and(|b| b.is_pressed())
+ || self.right_button.as_ref().is_some_and(|b| b.is_pressed())
+ }
+
/// Calculates the width needed for the right button
fn right_button_width(&self) -> i16 {
if let Some(b) = &self.right_button {
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu.rs
index 16e79c42..2891b213 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu.rs
@@ -99,6 +99,10 @@ pub struct VerticalMenu<T = ShortMenuVec> {
offset_y: i16,
/// Maximum vertical offset.
offset_y_max: i16,
+ /// Whether the first button touch area should be shrunk.
+ /// If true, the first button will overlap the header.
+ /// When using subtitle, the overlap should be disabled.
+ first_item_shrink: bool,
}
pub enum VerticalMenuMsg {
@@ -111,6 +115,10 @@ impl<T: MenuItems> VerticalMenu<T> {
#[cfg(test)]
pub const TEST_MENU_ITEM_CONTENT_PADDING: i16 = 32;
+ // Overlap with the header. Must be lower than MENU_ITEM_CONTENT_PADDING so the
+ // content is not clipped.
+ pub const BUTTON_TOP_SHRINK: i16 = 24;
+
fn new(buttons: T) -> Self {
Self {
bounds: Rect::zero(),
@@ -118,6 +126,7 @@ impl<T: MenuItems> VerticalMenu<T> {
total_height: 0,
offset_y: 0,
offset_y_max: 0,
+ first_item_shrink: true,
}
}
@@ -135,6 +144,11 @@ impl<T: MenuItems> VerticalMenu<T> {
self
}
+ pub fn no_first_item_shrink(&mut self) -> &mut Self {
+ self.first_item_shrink = false;
+ self
+ }
+
/// Check if the menu fits its area without scrolling.
pub fn fits_area(&self) -> bool {
self.total_height <= self.bounds.height()
@@ -185,7 +199,11 @@ impl<T: MenuItems> VerticalMenu<T> {
// The offset could reach only discrete values of cumsum of button heights
let current = self.offset_y;
- let mut cumsum = 0;
+ let mut cumsum = if self.first_item_shrink {
+ -Self::BUTTON_TOP_SHRINK
+ } else {
+ 0
+ };
for button in self
.buttons
@@ -214,23 +232,38 @@ impl<T: MenuItems> VerticalMenu<T> {
// The menu is scrollable until the last button is visible
#[cfg(feature = "ui_debug")]
if animation_disabled() {
- self.offset_y_max = self.total_height
+ let offset_y_base = self.total_height
- self
.buttons
.get_last()
.unwrap_or(&Button::empty())
.area()
.height();
+ self.offset_y_max = if self.first_item_shrink {
+ offset_y_base - Self::BUTTON_TOP_SHRINK
+ } else {
+ offset_y_base
+ };
return;
}
// Calculate the overflow of the menu area
- let menu_overflow = (self.total_height - self.bounds.height()).max(0);
+ let overflow_base = (self.total_height - self.bounds.height()).max(0);
+ let menu_overflow = if self.first_item_shrink {
+ (overflow_base - Self::BUTTON_TOP_SHRINK).max(0)
+ } else {
+ overflow_base
+ };
// Find the first button from the top that would completely fit in the menu area
// in the bottom position
for button in self.buttons.iter() {
- let offset = button.area().top_left().y - self.bounds.top_left().y;
+ let offset_base = (button.area().top_left().y - self.bounds.top_left().y).max(0);
+ let offset = if self.first_item_shrink {
+ (offset_base - Self::BUTTON_TOP_SHRINK).max(0)
+ } else {
+ offset_base
+ };
if offset > menu_overflow {
self.offset_y_max = offset;
return;
@@ -263,20 +296,34 @@ impl<T: MenuItems> VerticalMenu<T> {
}
fn render_separators<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ #[inline]
+ fn button_separator(button: &Button) -> Rect {
+ Rect::from_top_left_and_size(
+ button
+ .area()
+ .top_left()
+ .ofs(Offset::x(button.content_offset().x)),
+ Offset::new(button.area().width() - 2 * button.content_offset().x, 1),
+ )
+ }
+
+ if !self.first_item_shrink {
+ if let Some(button) = self.buttons.iter().next() {
+ if !button.is_pressed() {
+ Bar::new(button_separator(button))
+ .with_fg(theme::GREY_EXTRA_DARK)
+ .render(target);
+ }
+ }
+ }
+
for pair in self.buttons.iter().as_slice().windows(2) {
let [button_prev, button] = pair else {
continue;
};
if !button.is_pressed() && !button_prev.is_pressed() {
- let separator = Rect::from_top_left_and_size(
- button
- .area()
- .top_left()
- .ofs(Offset::x(button.content_offset().x)),
- Offset::new(button.area().width() - 2 * button.content_offset().x, 1),
- );
- Bar::new(separator)
+ Bar::new(button_separator(button))
.with_fg(theme::GREY_EXTRA_DARK)
.render(target);
}
@@ -295,13 +342,17 @@ impl<T: MenuItems> Component for VerticalMenu<T> {
let mut top_left = self.bounds.top_left();
// Place each button (might overflow the menu bounds)
- for button in self.buttons.iter_mut() {
+ for (i, button) in self.buttons.iter_mut().enumerate() {
let button_height =
button.content_height(button_width) + 2 * Self::MENU_ITEM_CONTENT_PADDING;
let button_bounds =
Rect::from_top_left_and_size(top_left, Offset::new(button_width, button_height));
button.place(button_bounds);
+ if i == 0 && self.first_item_shrink {
+ // Negative value because the param is the expand insets
+ button.set_expanded_touch_area(Insets::top(-Self::BUTTON_TOP_SHRINK));
+ }
top_left = top_left + Offset::y(button_height);
}
@@ -353,3 +404,17 @@ impl<T: MenuItems> crate::trace::Trace for VerticalMenu<T> {
});
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_overlap_limit() {
+ // The overlap must be smaller than the padding so that the text is not clipped
+ debug_assert!(
+ VerticalMenu::<ShortMenuVec>::BUTTON_TOP_SHRINK
+ < VerticalMenu::<ShortMenuVec>::TEST_MENU_ITEM_CONTENT_PADDING
+ );
+ }
+}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rs
index df5008bb..db30cd6c 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rs
@@ -4,18 +4,21 @@ use crate::{
component::{
swipe_detect::{SwipeConfig, SwipeSettings},
text::{layout::LayoutFit, TextStyle},
- Component, Event, EventCtx, Label, SwipeDetect, TextLayout,
+ Component, Event, EventCtx, Label, LineBreaking, SwipeDetect, TextLayout,
},
display::Icon,
event::SwipeEvent,
flow::Swipable,
- geometry::{Alignment2D, Direction, Offset, Rect},
+ geometry::{Alignment2D, Direction, Insets, Offset, Rect},
shape::{Renderer, ToifImage},
util::{animation_disabled, Pager},
},
};
-use super::{constant::SCREEN, theme, Header, HeaderMsg, MenuItems, VerticalMenu, VerticalMenuMsg};
+use super::{
+ constant::SCREEN, theme, Header, HeaderMsg, MenuItems, ShortMenuVec, VerticalMenu,
+ VerticalMenuMsg,
+};
pub struct VerticalMenuScreen<T> {
header: Header,
@@ -43,9 +46,11 @@ pub enum VerticalMenuScreenMsg {
impl<T: MenuItems> VerticalMenuScreen<T> {
const TOUCH_SENSITIVITY_DIVIDER: i16 = 12;
- const SUBTITLE_STYLE: TextStyle = theme::TEXT_MEDIUM_GREY;
+ const SUBTITLE_STYLE: TextStyle =
+ theme::TEXT_MEDIUM_GREY.with_line_breaking(LineBreaking::BreakAtWhitespace);
const SUBTITLE_HEIGHT: i16 = 68;
const SUBTITLE_DOUBLE_HEIGHT: i16 = 100;
+ const SUBTITLE_PADDING: i16 = 20;
const OVERFLOW_ARROW_Y_OFFSET: i16 = 18;
const OVERFLOW_ARROW_ICON: Icon = theme::ICON_CHEVRON_DOWN_MINI;
@@ -71,6 +76,8 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
if !subtitle.is_empty() {
self.subtitle =
Some(Label::left_aligned(subtitle, Self::SUBTITLE_STYLE).vertically_centered());
+ // The menu shouldn't overlap the subtitle area
+ self.menu.no_first_item_shrink();
}
self
}
@@ -203,7 +210,12 @@ impl<T: MenuItems> Component for VerticalMenuScreen<T> {
TextLayout::new(Self::SUBTITLE_STYLE)
.with_bounds(
Rect::from_size(Offset::new(bounds.width(), Self::SUBTITLE_HEIGHT))
- .inset(theme::SIDE_INSETS),
+ .inset(Insets::new(
+ Self::SUBTITLE_PADDING,
+ theme::PADDING,
+ Self::SUBTITLE_PADDING,
+ theme::PADDING,
+ )),
)
.fit_text(text)
}) {
@@ -216,7 +228,7 @@ impl<T: MenuItems> Component for VerticalMenuScreen<T> {
subtitle.place(subtitle_area.inset(theme::SIDE_INSETS));
rest
} else {
- rest
+ rest.outset(Insets::top(VerticalMenu::<ShortMenuVec>::BUTTON_TOP_SHRINK))
};
self.header.place(header_area);
@@ -249,9 +261,15 @@ impl<T: MenuItems> Component for VerticalMenuScreen<T> {
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- self.header.render(target);
self.subtitle.render(target);
- self.menu.render(target);
+ // Render overlapping components in correct order
+ if self.header.pressed() {
+ self.menu.render(target);
+ self.header.render(target);
+ } else {
+ self.header.render(target);
+ self.menu.render(target);
+ }
self.render_overflow_arrow(target);
}
}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.