feat(eckhart): improve homescreen information
What changed, and why it matters
This commit is a user-interface refresh for the Trezor T3W1 device homescreen. It adds a battery icon, a connection indicator, and makes notification labels interactive when relevant. There is no direct evidence of a security vulnerability being fixed or introduced; the changes are primarily cosmetic and structural.
No immediate security action required. Treat as a normal UI feature commit. If reviewing for security, verify that the new actionable notification flow does not allow unintended navigation from the homescreen and that the homescreen remains non-interactive until unlocked, but these are not indicated by the diff.
Security signals we found
API surface change: Notification tuple gains a third boolean field (actionable)
UI-only refactor with no crypto, PIN, storage, or communication protocol changes observed
New components (FuelGauge, ConnectionIndicator) are display-only indicators
No bounds checks, input validation, or memory allocation changes observed
Evidence from the diff
The patch refactors the Eckhart homescreen layout: it removes the HomeLabel component, introduces HomescreenHeader (device label + FuelGauge + ConnectionIndicator) and HomescreenNotificationCenter (LED, hint, homebar button), and extends the Notification struct/API tuple with an actionable boolean. The actionable flag lets certain notifications (backup failed, backup needed, PIN not set) drive the homebar button text/color. Existing Bolt and Delizia layouts are updated to pass actionable:false for their notifications. No cryptographic, authentication, or memory-safety changes are visible.
Changed components
core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rscore/embed/rust/src/ui/layout_eckhart/component/fuel_gauge.rscore/embed/rust/src/ui/layout_eckhart/theme/firmware.rscore/embed/rust/src/ui/notification.rscore/embed/rust/src/ui/api/firmware_micropython.rscore/src/apps/homescreen/__init__.pycore/src/trezor/ui/layouts/homescreen.pyInspect captured patch +658 / −167
diff --git a/core/.changelog.d/6501.added b/core/.changelog.d/6501.added
new file mode 100644
index 00000000..fee88b92
--- /dev/null
+++ b/core/.changelog.d/6501.added
@@ -0,0 +1 @@
+[T3W1] Improved information on the Homescreen.
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 1fb9ea3b..1a245415 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -877,10 +877,15 @@ extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut
let skip_first_paint: bool = kwargs.get(Qstr::MP_QSTR_skip_first_paint)?.try_into()?;
let notification = if let Some(notif_tuple) = notification {
- let [text, level]: [Obj; 2] = util::iter_into_array(notif_tuple)?;
+ let [text, level, actionable]: [Obj; 3] = util::iter_into_array(notif_tuple)?;
let text: TString<'static> = text.try_into()?;
let level: NotificationLevel = level.try_into()?;
- Some(Notification { text, level })
+ let actionable: bool = actionable.try_into()?;
+ Some(Notification {
+ text,
+ level,
+ actionable,
+ })
} else {
None
};
@@ -1918,7 +1923,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def show_homescreen(
/// *,
/// label: str,
- /// notification: tuple[str, int] | None = None,
+ /// notification: tuple[str, int, bool] | None = None,
/// lockable: bool,
/// skip_first_paint: bool,
/// ) -> LayoutObj[UiResult]:
diff --git a/core/embed/rust/src/ui/layout_bolt/component/homescreen.rs b/core/embed/rust/src/ui/layout_bolt/component/homescreen.rs
index cac0b5d4..d7864eab 100644
--- a/core/embed/rust/src/ui/layout_bolt/component/homescreen.rs
+++ b/core/embed/rust/src/ui/layout_bolt/component/homescreen.rs
@@ -96,6 +96,7 @@ impl Homescreen {
Some(Notification {
text: TR::homescreen__title_no_usb_connection.into(),
level: NotificationLevel::Alert,
+ actionable: false,
})
} else {
self.notification.clone()
diff --git a/core/embed/rust/src/ui/layout_delizia/component/homescreen.rs b/core/embed/rust/src/ui/layout_delizia/component/homescreen.rs
index 80199f87..ce0e9444 100644
--- a/core/embed/rust/src/ui/layout_delizia/component/homescreen.rs
+++ b/core/embed/rust/src/ui/layout_delizia/component/homescreen.rs
@@ -575,6 +575,7 @@ impl Homescreen {
Some(Notification {
text: TR::homescreen__title_no_usb_connection.into(),
level: NotificationLevel::Alert,
+ actionable: false,
})
} else {
self.notification.clone()
@@ -991,6 +992,7 @@ impl Component for Lockscreen {
let notif = Notification {
text: TR::homescreen__title_coinjoin_authorized.into(),
level: NotificationLevel::Success,
+ actionable: false,
};
render_notif(notif, NOTIFICATION_LOCKSCREEN_TOP, target);
diff --git a/core/embed/rust/src/ui/layout_eckhart/component/fuel_gauge.rs b/core/embed/rust/src/ui/layout_eckhart/component/fuel_gauge.rs
index 8eed6656..c5bbe929 100644
--- a/core/embed/rust/src/ui/layout_eckhart/component/fuel_gauge.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/component/fuel_gauge.rs
@@ -1,4 +1,5 @@
use crate::{
+ strutil::ShortString,
trezorhal::power_manager::{self, ChargingState},
ui::{
component::{Component, Event, EventCtx, Never},
@@ -22,13 +23,15 @@ use super::super::{
#[cfg(feature = "micropython")]
use super::super::theme::firmware::FUEL_GAUGE_DURATION;
+const ICON_PERCENT_GAP: i16 = 16;
+
/// Component for showing a small fuel gauge (battery status) consisting of:
/// - icon indicating charging or discharging state
/// - percentage
#[derive(Clone)]
pub struct FuelGauge {
/// Area where the fuel gauge is rendered
- area: Rect,
+ pub area: Rect,
/// Mode of the fuel gauge (Always or OnChrgStatusChange)
mode: FuelGaugeMode,
/// State of battery charging
@@ -86,6 +89,46 @@ impl FuelGauge {
}
}
+ /// Returns the total rendered width of the fuel gauge content.
+ pub fn content_width(&self) -> i16 {
+ let icon_w = self.icon_width();
+ match self.mode {
+ FuelGaugeMode::AlwaysIconOnly | FuelGaugeMode::ChargingIconOnly => icon_w,
+ _ => {
+ let soc_fmt = self.soc_text();
+ icon_w + ICON_PERCENT_GAP + self.font.text_width(&soc_fmt)
+ }
+ }
+ }
+
+ const fn icon_width(&self) -> i16 {
+ match self.charging_state {
+ ChargingState::Charging => ICON_BATTERY_ZAP.toif.width(),
+ ChargingState::Discharging | ChargingState::Idle => ICON_BATTERY_FULL.toif.width(),
+ }
+ }
+
+ fn soc_text(&self) -> ShortString {
+ if self.soc.is_none() {
+ uformat!("?")
+ } else {
+ uformat!("{} %", self.soc.unwrap_or(0))
+ }
+ }
+
+ fn render_icon<'s>(
+ &self,
+ area: Rect,
+ icon: Icon,
+ color: Color,
+ target: &mut impl Renderer<'s>,
+ ) {
+ shape::ToifImage::new(area.left_center(), icon.toif)
+ .with_fg(color)
+ .with_align(Alignment2D::CENTER_LEFT)
+ .render(target);
+ }
+
const fn new(mode: FuelGaugeMode) -> Self {
#[cfg(feature = "micropython")]
let font = fonts::FONT_SATOSHI_REGULAR_22;
@@ -180,15 +223,9 @@ impl Component for FuelGauge {
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- const ICON_PERCENT_GAP: i16 = 16;
-
let soc = self.soc.unwrap_or(0);
let (icon, color_icon, color_text) = self.battery_indication(self.charging_state, soc);
- let soc_percent_fmt = if self.soc.is_none() {
- uformat!("?")
- } else {
- uformat!("{} %", soc)
- };
+ let soc_percent_fmt = self.soc_text();
let text_width = self.font.text_width(&soc_percent_fmt);
let text_height = self.font.text_height();
let icon_width = icon.toif.width();
@@ -203,30 +240,20 @@ impl Component for FuelGauge {
),
alignment,
);
- let text_y_coord = self.font.vert_center(area.y0, area.y1, &soc_percent_fmt);
match self.mode {
FuelGaugeMode::AlwaysIconOnly => {
- shape::ToifImage::new(area.left_center(), icon.toif)
- .with_fg(color_icon)
- .with_align(Alignment2D::CENTER_LEFT)
- .render(target);
+ self.render_icon(area, icon, color_icon, target);
}
FuelGaugeMode::ChargingIconOnly => {
if matches!(self.charging_state, ChargingState::Charging) {
- shape::ToifImage::new(area.left_center(), icon.toif)
- .with_fg(color_icon)
- .with_align(Alignment2D::CENTER_LEFT)
- .render(target);
+ self.render_icon(area, icon, color_icon, target);
}
}
_ => {
// both icon and percentage
- shape::ToifImage::new(area.left_center(), icon.toif)
- .with_fg(color_icon)
- .with_align(Alignment2D::CENTER_LEFT)
- .render(target);
-
+ self.render_icon(area, icon, color_icon, target);
+ let text_y_coord = self.font.vert_center(area.y0, area.y1, &soc_percent_fmt);
shape::Text::new(
Point::new(area.x1, text_y_coord),
&soc_percent_fmt,
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
index ddc759fd..e44a8e1a 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
@@ -2,22 +2,24 @@ use crate::{
error::Error,
io::BinaryData,
strutil::TString,
+ time::{Duration, Instant, Stopwatch},
translations::TR,
ui::{
- component::{text::TextStyle, Component, Event, EventCtx, Label, Never, Swipe},
+ component::{Component, Event, EventCtx, Label, Never, Swipe, Timer},
display::{image::ImageInfo, Color},
- geometry::{Direction, Offset, Rect},
+ event::TouchEvent,
+ geometry::{Alignment2D, Direction, Offset, Point, Rect},
layout::util::get_user_custom_image,
+ lerp::Lerp,
notification::{Notification, NotificationLevel},
shape::{self, Renderer},
+ util::animation_disabled,
},
};
+use core::sync::atomic::{AtomicBool, Ordering};
use super::{
- super::{
- component::{Button, ButtonContent, FuelGauge},
- fonts,
- },
+ super::component::{Button, ButtonContent, ConnectionIndicator, FuelGauge},
constant::{HEIGHT, SCREEN, WIDTH},
theme::{self, firmware::button_homebar_style, ScreenBackground},
ActionBar, ActionBarMsg, Hint,
@@ -26,24 +28,22 @@ use super::{
#[cfg(feature = "rgb_led")]
use crate::ui::led::LedState;
+const SHADOW_HEIGHT: i16 = 54;
+
/// Full-screen component for the homescreen and lockscreen.
pub struct Homescreen {
- /// Device name with shadow
- label: HomeLabel,
- /// Notification
- hint: Option<Hint<'static>>,
+ /// Device name label, fuel gauge, and connection status
+ header: HomescreenHeader,
+ /// Notification rendering, including LED and hint text
+ notification_center: HomescreenNotificationCenter,
/// Home action bar
action_bar: ActionBar,
/// Background image
image: Option<BinaryData<'static>>,
- /// LED color
- led_color: Option<Color>,
/// Whether the homescreen is locked
locked: bool,
/// Whether the homescreen is a boot screen
bootscreen: bool,
- /// Fuel gauge (battery status indicator) rendered in the `action_bar` area
- fuel_gauge: FuelGauge,
/// Swipe component for vertical swiping
swipe: Swipe,
// swipe_config: SwipeConfig,
@@ -64,72 +64,30 @@ impl Homescreen {
notification: Option<Notification>,
) -> Result<Self, Error> {
let image = get_homescreen_image();
- let shadow = image.is_some();
+ let image_used = image.is_some();
- // Notification
- let (led_color, hint) = match notification {
- Some(ref notification) => {
- let (led_color, hint) = Self::get_notification_display(notification);
- (Some(led_color), Some(hint))
- }
- None if locked && coinjoin_authorized => (
- Some(theme::LED_GREEN_LIME),
- Some(Hint::new_instruction_green(
- TR::coinjoin__do_not_disconnect,
- Some(theme::ICON_INFO),
- )),
- ),
- None => (None, None),
- };
+ let notification_center = HomescreenNotificationCenter::new(
+ notification,
+ locked,
+ coinjoin_authorized,
+ image_used,
+ );
// Homebar
- let (style_sheet, gradient) = button_homebar_style(notification.map(|n| n.level));
- let btn = Button::new(Self::homebar_content(bootscreen, locked))
- .styled(style_sheet)
- .with_gradient(gradient);
+ let btn = notification_center.homebar_button(bootscreen, locked);
+
+ let is_alert = notification_center.is_alert();
Ok(Self {
- label: HomeLabel::new(label, shadow),
- hint,
+ header: HomescreenHeader::new(label, image_used, !is_alert),
+ notification_center,
action_bar: ActionBar::new_single(btn),
image,
- led_color,
locked,
bootscreen,
- fuel_gauge: FuelGauge::always_icon_only(),
swipe: Swipe::new().up(),
})
}
-
- fn homebar_content(bootscreen: bool, locked: bool) -> ButtonContent {
- let text = (bootscreen || locked).then_some(TR::lockscreen__unlock.into());
- ButtonContent::HomeBar(text)
- }
-
- fn get_notification_display(n: &Notification) -> (Color, Hint<'static>) {
- match n.level {
- NotificationLevel::Alert => (theme::LED_RED, Hint::new_warning_danger(n.text)),
- NotificationLevel::Warning => (theme::LED_YELLOW, Hint::new_warning_neutral(n.text)),
- NotificationLevel::Info => (theme::LED_BLUE, Hint::new_instruction(n.text, None)),
- NotificationLevel::Success => (
- theme::LED_GREEN_LIGHT,
- Hint::new_instruction_green(n.text, Some(theme::ICON_INFO)),
- ),
- }
- }
-
- fn event_fuel_gauge(&mut self, ctx: &mut EventCtx, event: Event) {
- self.fuel_gauge.event(ctx, event);
- let bar_content = if self.fuel_gauge.should_be_shown() {
- ButtonContent::Empty
- } else {
- Self::homebar_content(self.bootscreen, self.locked)
- };
-
- if let Some(b) = self.action_bar.right_button_mut() {
- b.set_content(bar_content)
- }
- }
}
impl Component for Homescreen {
@@ -141,32 +99,25 @@ impl Component for Homescreen {
debug_assert_eq!(bounds.width(), SCREEN.width());
let (rest, bar_area) = bounds.split_bottom(theme::ACTION_BAR_HEIGHT);
- let rest = if let Some(hint) = &mut self.hint {
- let (rest, hint_area) = rest.split_bottom(hint.height());
- hint.place(hint_area);
- rest
- } else {
- rest
- };
- let label_area = rest.inset(theme::CONTENT_INSETS_NO_HEADER);
+ let (status_area, rest) = rest.split_top(theme::HEADER_HEIGHT);
- self.label.place(label_area);
+ self.header.place(status_area.inset(theme::SIDE_INSETS));
+ self.notification_center.place(rest);
self.action_bar.place(bar_area);
- self.fuel_gauge.place(bar_area);
// Swipe component is placed in the action bar touch area
self.swipe.place(self.action_bar.touch_area());
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
- self.event_fuel_gauge(ctx, event);
+ self.header.event(ctx, event);
+ self.notification_center.event(ctx, event);
let swipe_up = matches!(self.swipe.event(ctx, event), Some(Direction::Up));
let homebar_tap = matches!(
self.action_bar.event(ctx, event),
Some(ActionBarMsg::Confirmed)
);
-
if swipe_up || homebar_tap {
return if self.locked {
Some(HomescreenMsg::Dismissed)
@@ -183,70 +134,507 @@ impl Component for Homescreen {
if let ImageInfo::Jpeg(_) = ImageInfo::parse(image) {
shape::JpegImage::new_image(SCREEN.top_left(), image).render(target);
}
- } else {
- ScreenBackground::new(self.led_color, None).render(target);
}
- self.label.render(target);
- self.hint.render(target);
+ self.notification_center.render(target);
+ self.header.render(target);
self.action_bar.render(target);
- if self.fuel_gauge.should_be_shown() {
- self.fuel_gauge.render(target);
+ }
+}
+
+struct HomescreenNotificationCenter {
+ /// Current notification to display, if any
+ notification: Option<Notification>,
+ /// Whether the notification is actionable (i.e. has a corresponding entry
+ /// in the DeviceMenu)
+ actionable_notification: bool,
+ /// Notification text display
+ hint: Option<Hint<'static>>,
+ hint_shadow_area: Rect,
+ /// LED color
+ led_color: Option<Color>,
+ /// Whether the LED is currently active
+ led_active: bool,
+ /// Timer for toggling the LED on/off
+ led_timer: Timer,
+ /// Whether a custom background image is used, which affects the UI
+ background_image: bool,
+}
+
+impl HomescreenNotificationCenter {
+ pub fn new(
+ notification: Option<Notification>,
+ locked: bool,
+ coinjoin_authorized: bool,
+ background_image: bool,
+ ) -> Self {
+ // If there's a notification which has an entry in the DeviceMenu
+ let actionable_notification = notification.as_ref().is_some_and(|n| n.actionable);
+
+ let led_color = match notification {
+ Some(ref notification) => Some(Self::get_notification_led_color(notification)),
+ None if locked && coinjoin_authorized => Some(theme::LED_GREEN_LIME),
+ None => None,
+ };
+
+ let hint = match notification {
+ Some(ref n) if !n.actionable => Some(Self::get_notification_hint(n)),
+ None if locked && coinjoin_authorized => Some(Hint::new_instruction_green(
+ TR::coinjoin__do_not_disconnect,
+ Some(theme::ICON_INFO),
+ )),
+ _ => None,
+ };
+
+ Self {
+ notification,
+ actionable_notification,
+ hint,
+ hint_shadow_area: Rect::zero(),
+ led_color,
+ led_active: false,
+ led_timer: Timer::new(),
+ background_image,
+ }
+ }
+
+ pub fn homebar_button(&self, bootscreen: bool, locked: bool) -> Button {
+ let text: Option<TString<'static>> = if bootscreen || locked {
+ Some(TR::lockscreen__unlock.into())
+ } else if self.actionable_notification {
+ self.notification.as_ref().map(|n| n.text)
+ } else {
+ None
+ };
+ let level = self.notification.as_ref().map(|n| n.level);
+ let (style_sheet, gradient) =
+ button_homebar_style(level.as_ref(), self.actionable_notification);
+ Button::new(ButtonContent::HomeBar(text))
+ .styled(style_sheet)
+ .with_gradient(gradient)
+ }
+
+ fn notification_level(&self) -> Option<NotificationLevel> {
+ self.notification.as_ref().map(|n| n.level)
+ }
+
+ fn is_alert(&self) -> bool {
+ self.notification_level()
+ .map(|level| matches!(level, NotificationLevel::Alert))
+ .unwrap_or(false)
+ }
+
+ fn get_notification_led_color(n: &Notification) -> Color {
+ match n.level {
+ NotificationLevel::Alert => theme::LED_RED,
+ NotificationLevel::Warning => theme::LED_YELLOW,
+ NotificationLevel::Info => theme::LED_BLUE,
+ NotificationLevel::Success => theme::LED_GREEN_LIGHT,
+ }
+ }
+
+ fn get_notification_hint(n: &Notification) -> Hint<'static> {
+ match n.level {
+ NotificationLevel::Alert => Hint::new_warning_danger(n.text),
+ NotificationLevel::Warning => Hint::new_warning_neutral(n.text),
+ NotificationLevel::Info => Hint::new_instruction(n.text, None),
+ NotificationLevel::Success => {
+ Hint::new_instruction_green(n.text, Some(theme::ICON_INFO))
+ }
+ }
+ }
+}
+
+impl Component for HomescreenNotificationCenter {
+ type Msg = Never;
+ fn place(&mut self, bounds: Rect) -> Rect {
+ if let Some(hint) = &mut self.hint {
+ let hint_height = hint.height();
+ let hint_height_content = hint.height_no_padding().max(SHADOW_HEIGHT);
+ let hint_width = hint.width();
+ let (_rest, hint_area) = bounds.split_bottom(hint_height);
+
+ let shadow_offset_x = Offset::x(hint_height_content / 2);
+ let shadow_size = Offset::new(hint_width + theme::PADDING, hint_height_content)
+ + shadow_offset_x * 2.0;
+ // FIXME: hardcoded offset to properly center the shadow necessary due to
+ // asymmetric insets of the HintContent::Instruction
+ let shadow_anchor = hint_area.left_center() - shadow_offset_x - Offset::y(4);
+ self.hint_shadow_area =
+ Rect::snap(shadow_anchor, shadow_size, Alignment2D::CENTER_LEFT);
+ hint.place(hint_area);
+ }
+ bounds
+ }
+ fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ const LED_TOGGLE_DURATION: Duration = Duration::from_millis(3000);
+
+ if self.led_color.is_some() {
+ if self.is_alert() {
+ // Alert: LED is always on, no timer needed
+ if matches!(event, Event::Attach(_)) {
+ self.led_active = true;
+ ctx.request_paint();
+ }
+ } else {
+ match event {
+ Event::Attach(_) => {
+ // Start off, schedule turning on after 3s
+ self.led_active = false;
+ self.led_timer.start(ctx, LED_TOGGLE_DURATION);
+ }
+ Event::Timer(_) if self.led_timer.expire(event) => {
+ if !self.led_active {
+ // Turn on, schedule turning off
+ self.led_active = true;
+ self.led_timer.start(ctx, LED_TOGGLE_DURATION);
+ } else {
+ // Turn off, don't restart the timer
+ self.led_active = false;
+ }
+ ctx.request_paint();
+ }
+ _ => {}
+ }
+ }
}
+ None
+ }
+ fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ let active_color = if self.led_active {
+ self.led_color
+ } else {
+ None
+ };
+
+ if self.background_image {
+ render_pill_shaped_background(self.hint_shadow_area, target);
+ } else {
+ // default homescreen
+ ScreenBackground::new(active_color, None).render(target);
+ }
+ self.hint.render(target);
#[cfg(feature = "rgb_led")]
- target.set_led_state(LedState::Static(
- self.led_color.unwrap_or_else(Color::black),
- ));
+ target.set_led_state(LedState::Static(active_color.unwrap_or_else(Color::black)));
}
}
-/// Helper component to render a label with a shadow.
-struct HomeLabel {
+/// Helper component that displays device name label, battery status, and
+/// connection status indicator.
+/// It is combined because the label is shown together with the fuel gauge and
+/// connection indicator.
+struct HomescreenHeader {
+ area: Rect,
+ /// Device name
label: Label<'static>,
- /// Label shadow, only rendered when custom homescreen image is set
- label_shadow: Option<Label<'static>>,
+ /// Fuel gauge (battery status indicator)
+ fuel_gauge: FuelGauge,
+ /// Whether the device is connected to Host (either via USB or BLE)
+ connection_indicator: ConnectionIndicator,
+ /// Whether a custom background image is used, which affects the layout and
+ /// styling of the label
+ background_image: bool,
+ /// Whether to show fuel gauge and connection indicator
+ show_indicators: bool,
+ /// Cached text width of the label
+ text_width: i16,
+ /// Animation for showing/hiding the label
+ label_anim: Option<ShowLabelAnimation>,
+ /// Cached label clipping window
+ label_area: Rect,
+ /// Cached pill-shaped background area
+ label_shadow_area: Rect,
}
-impl HomeLabel {
- const LABEL_SHADOW_OFFSET: Offset = Offset::uniform(2);
- const LABEL_TEXT_STYLE: TextStyle = theme::firmware::TEXT_BIG;
- const LABEL_SHADOW_TEXT_STYLE: TextStyle = TextStyle::new(
- fonts::FONT_SATOSHI_EXTRALIGHT_46,
- theme::BLACK,
- theme::BLACK,
- theme::BLACK,
- theme::BLACK,
- );
-
- fn new(label: TString<'static>, shadow: bool) -> Self {
- let label_primary = Label::left_aligned(label, Self::LABEL_TEXT_STYLE).top_aligned();
- let label_shadow = shadow
- .then_some(Label::left_aligned(label, Self::LABEL_SHADOW_TEXT_STYLE).top_aligned());
+impl HomescreenHeader {
+ pub const SUBCOMPONENTS_GAP: i16 = 16;
+ const SHADOW_OFFSET_X: Offset = Offset::x(SHADOW_HEIGHT / 2);
+ const SHADOW_ANCHOR: Point = Point::new(0, 21).ofs(Self::SHADOW_OFFSET_X.neg());
+
+ pub fn new(label: TString<'static>, background_image: bool, show_info: bool) -> Self {
+ let style = theme::firmware::TEXT_SMALL;
+ let text_width = label.map(|text| style.text_font.text_width(text));
+ let label_anim = Some(ShowLabelAnimation::new(text_width, background_image));
+
Self {
- label: label_primary,
- label_shadow,
+ area: Rect::zero(),
+ label: Label::left_aligned(label, style).top_aligned(),
+ fuel_gauge: FuelGauge::always_icon_only(),
+ connection_indicator: ConnectionIndicator::new_polled(),
+ background_image,
+ show_indicators: show_info,
+ text_width,
+ label_anim,
+ label_area: Rect::zero(),
+ label_shadow_area: Rect::zero(),
}
}
-
- fn inner(&self) -> &Label<'static> {
- &self.label
- }
}
-impl Component for HomeLabel {
+impl Component for HomescreenHeader {
type Msg = Never;
+
fn place(&mut self, bounds: Rect) -> Rect {
- self.label.place(bounds);
- self.label_shadow
- .place(bounds.translate(Self::LABEL_SHADOW_OFFSET));
- bounds
+ if self.show_indicators {
+ let (fuel_gauge_area, _) = bounds.split_left(self.fuel_gauge.content_width());
+ let connection_indicator_area = Rect::snap(
+ fuel_gauge_area.right_center(),
+ Offset::uniform(ConnectionIndicator::AREA_SIZE_NEEDED),
+ Alignment2D::CENTER_LEFT,
+ )
+ .translate(Offset::x(Self::SUBCOMPONENTS_GAP));
+
+ self.fuel_gauge.place(fuel_gauge_area);
+ self.connection_indicator.place(connection_indicator_area);
+
+ let label_area = {
+ let anchor = if self.connection_indicator.connected {
+ connection_indicator_area.right_center()
+ } else {
+ fuel_gauge_area.right_center()
+ };
+ Rect::snap(
+ anchor,
+ Offset::new(self.text_width, self.label.font().max_height),
+ Alignment2D::CENTER_LEFT,
+ )
+ .translate(Offset::x(Self::SUBCOMPONENTS_GAP))
+ };
+ self.label_area = label_area;
+ self.label.place(label_area);
+
+ // pill background spans from off-screen left to cover the full status row
+ let shadow_size =
+ Offset::new(label_area.x1, SHADOW_HEIGHT) + Self::SHADOW_OFFSET_X * 2.0;
+ self.label_shadow_area = Rect::from_top_left_and_size(Self::SHADOW_ANCHOR, shadow_size);
+ } else {
+ let label_area = Rect::snap(
+ bounds.left_center(),
+ Offset::new(self.text_width, self.label.font().max_height),
+ Alignment2D::CENTER_LEFT,
+ );
+ self.label_area = label_area;
+ self.label.place(label_area);
+ }
+
+ self.area = bounds;
+ self.area
}
- fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
+
+ fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ if self.show_indicators {
+ self.fuel_gauge.event(ctx, event);
+ let connection_event = self.connection_indicator.event(ctx, event);
+ if matches!(event, Event::PM(_)) || connection_event.is_some() {
+ // TODO: could FuelGauge also return Some(()) on update?
+ self.place(self.area);
+ ctx.request_paint();
+ }
+
+ if let Some(label_anim) = &mut self.label_anim {
+ label_anim.process_event(ctx, event);
+ }
+ }
+
None
}
+
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- self.label_shadow.render(target);
- self.label.render(target);
+ if self.show_indicators {
+ if let Some(animation) = &self.label_anim {
+ let x_offset = animation.eval_offset();
+ if self.background_image {
+ target.with_origin(x_offset, &|target| {
+ render_pill_shaped_background(self.label_shadow_area, target);
+ });
+ }
+ target.in_clip(self.label_area, &|target| {
+ target.with_origin(x_offset, &|target| {
+ self.label.render(target);
+ });
+ });
+ }
+ self.fuel_gauge.render(target);
+ self.connection_indicator.render(target);
+ } else {
+ self.label.render(target);
+ }
+ }
+}
+
+static FIRST_BOOT: AtomicBool = AtomicBool::new(true);
+
+struct ShowLabelAnimation {
+ stopwatch: Stopwatch,
+ timer: Timer,
+ duration: Duration,
+ label_width: i16,
+ animating: bool,
+ hidden: bool,
+ /// When true, the label slides in/out. When false, it instantly
+ /// shows/hides.
+ animated: bool,
+}
+
+impl ShowLabelAnimation {
+ const HIDE_AFTER: Duration = Duration::from_millis(3000);
+ const MOVE_DURATION: Duration = Duration::from_millis(500);
+ // width at which MOVE_DURATION applies exactly
+ const REFERENCE_WIDTH: i16 = 200;
+
+ pub fn new(label_width: i16, animated: bool) -> Self {
+ // start with hidden by default but not on first boot
+ let hidden = if FIRST_BOOT.swap(false, Ordering::Relaxed) {
+ false
+ } else {
+ !animation_disabled()
+ };
+
+ let scaled_ms =
+ (Self::MOVE_DURATION.to_millis() * label_width as u32) / Self::REFERENCE_WIDTH as u32;
+ let duration = Duration::from_millis(scaled_ms);
+
+ Self {
+ stopwatch: Stopwatch::default(),
+ timer: Timer::new(),
+ duration,
+ label_width,
+ animating: false,
+ hidden,
+ animated,
+ }
+ }
+
+ fn is_active(&self) -> bool {
+ self.stopwatch.is_running_within(self.duration)
+ }
+
+ fn reset(&mut self) {
+ self.stopwatch = Stopwatch::default();
+ }
+
+ fn change_dir(&mut self) {
+ let elapsed = self.stopwatch.elapsed();
+
+ let start = self
+ .duration
+ .checked_sub(elapsed)
+ .and_then(|e| Instant::now().checked_sub(e));
+
+ if let Some(start) = start {
+ self.stopwatch = Stopwatch::Running(start);
+ } else {
+ self.stopwatch = Stopwatch::new_started();
+ }
+ }
+
+ fn eval(&self) -> f32 {
+ if animation_disabled() {
+ return 1.0;
+ }
+
+ let t = self.stopwatch.elapsed().to_millis() as f32 / 1000.0;
+
+ if self.hidden {
+ pareen::constant(0.0)
+ .seq_ease_out(
+ 0.0,
+ easer::functions::Cubic,
+ self.duration.to_millis() as f32 / 1000.0,
+ pareen::constant(1.0),
+ )
+ .eval(t)
+ } else {
+ pareen::constant(1.0)
+ .seq_ease_in(
+ 0.0,
+ easer::functions::Cubic,
+ self.duration.to_millis() as f32 / 1000.0,
+ pareen::constant(0.0),
+ )
+ .eval(t)
+ }
+ }
+
+ pub fn eval_offset(&self) -> Offset {
+ if animation_disabled() || !self.animated {
+ if self.hidden && !self.animating {
+ return Offset::x(-self.label_width);
+ }
+ return Offset::zero();
+ }
+
+ let pos = self.eval();
+ Offset::x(i16::lerp(-self.label_width, 0, pos))
+ }
+
+ pub fn process_event(&mut self, ctx: &mut EventCtx, event: Event) {
+ match event {
+ Event::Attach(_) => {
+ if !self.hidden {
+ self.timer.start(ctx, Self::HIDE_AFTER);
+ }
+ }
+ Event::Timer(EventCtx::ANIM_FRAME_TIMER) => {
+ if self.is_active() {
+ ctx.request_anim_frame();
+ ctx.request_paint();
+ } else if self.animating {
+ self.animating = false;
+ self.hidden = !self.hidden;
+ self.reset();
+ ctx.request_paint();
+
+ if !self.hidden {
+ self.timer.start(ctx, Self::HIDE_AFTER);
+ }
+ }
+ }
+ Event::Timer(_) if self.timer.expire(event) && !animation_disabled() => {
+ if self.animated {
+ self.stopwatch.start();
+ ctx.request_anim_frame();
+ self.animating = true;
+ self.hidden = false;
+ } else {
+ // Instant hide
+ self.hidden = true;
+ ctx.request_paint();
+ }
+ }
+ Event::Touch(TouchEvent::TouchStart(point)) => {
+ // Only trigger animation at the top of the screen
+ if point.y <= SCREEN.height() / 2 {
+ if self.animated {
+ if !self.animating {
+ if self.hidden {
+ self.stopwatch.start();
+ self.animating = true;
+ ctx.request_anim_frame();
+ ctx.request_paint();
+ } else {
+ self.timer.start(ctx, Self::HIDE_AFTER);
+ }
+ } else if !self.hidden {
+ self.change_dir();
+ self.hidden = true;
+ ctx.request_anim_frame();
+ ctx.request_paint();
+ }
+ } else {
+ // Instant show/hide
+ if self.hidden {
+ self.hidden = false;
+ self.timer.start(ctx, Self::HIDE_AFTER);
+ ctx.request_paint();
+ } else {
+ self.timer.start(ctx, Self::HIDE_AFTER);
+ }
+ }
+ }
+ }
+ _ => {}
+ }
}
}
@@ -268,10 +656,30 @@ fn get_homescreen_image() -> Option<BinaryData<'static>> {
None
}
+fn render_pill_shaped_background<'s>(area: Rect, target: &mut impl Renderer<'s>) {
+ const SHADOW_ALPHA: u8 = 230; // 90%
+ shape::Bar::new(area)
+ .with_bg(theme::BG)
+ .with_fg(theme::BG)
+ .with_radius(area.height() / 2)
+ .with_alpha(SHADOW_ALPHA)
+ .render(target);
+}
+
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Homescreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Homescreen");
- t.child("label", self.label.inner());
+ t.child("status", &self.header);
+ t.child("homebar", &self.action_bar);
+ }
+}
+#[cfg(feature = "ui_debug")]
+impl crate::trace::Trace for HomescreenHeader {
+ fn trace(&self, t: &mut dyn crate::trace::Tracer) {
+ t.component("HomescreenStatus");
+ t.child("label", &self.label);
+ t.child("fuel_gauge", &self.fuel_gauge);
+ t.child("connection_indicator", &self.connection_indicator);
}
}
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 460aa317..a2539289 100644
--- a/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/theme/firmware.rs
@@ -393,11 +393,11 @@ pub const fn menu_item_title_red() -> ButtonStyleSheet {
}
macro_rules! button_homebar_style {
- ($icon_color:expr) => {
+ ($icon_color:expr, $text_color:expr) => {
ButtonStyleSheet {
normal: &ButtonStyle {
font: fonts::FONT_SATOSHI_MEDIUM_26,
- text_color: GREY_LIGHT,
+ text_color: $text_color,
button_color: GREY_SUPER_DARK,
icon_color: $icon_color,
},
@@ -418,15 +418,36 @@ macro_rules! button_homebar_style {
};
}
-pub const fn button_homebar_style(nl: Option<NotificationLevel>) -> (ButtonStyleSheet, Gradient) {
- match nl {
- Some(NotificationLevel::Alert) => (button_homebar_style!(RED), Gradient::Alert),
- Some(NotificationLevel::Warning) => (button_homebar_style!(GREY_LIGHT), Gradient::Warning),
- Some(NotificationLevel::Info) => (button_homebar_style!(GREY_LIGHT), Gradient::DefaultGrey),
- Some(NotificationLevel::Success) => {
- (button_homebar_style!(GREY_LIGHT), Gradient::SignGreen)
+pub const fn button_homebar_style(
+ nl: Option<&NotificationLevel>,
+ actionable: bool,
+) -> (ButtonStyleSheet, Gradient) {
+ match (nl, actionable) {
+ (Some(NotificationLevel::Alert), true) => {
+ (button_homebar_style!(RED, RED), Gradient::Alert)
+ }
+ (Some(NotificationLevel::Alert), false) => {
+ (button_homebar_style!(RED, GREY_LIGHT), Gradient::Alert)
+ }
+ (Some(NotificationLevel::Warning), true) => {
+ (button_homebar_style!(GREY_LIGHT, YELLOW), Gradient::Warning)
}
- None => (button_homebar_style!(GREY_LIGHT), Gradient::DefaultGrey),
+ (Some(NotificationLevel::Warning), false) => (
+ button_homebar_style!(GREY_LIGHT, GREY_LIGHT),
+ Gradient::Warning,
+ ),
+ (Some(NotificationLevel::Info), _) => (
+ button_homebar_style!(GREY_LIGHT, GREY_LIGHT),
+ Gradient::DefaultGrey,
+ ),
+ (Some(NotificationLevel::Success), _) => (
+ button_homebar_style!(GREY_LIGHT, GREY_LIGHT),
+ Gradient::SignGreen,
+ ),
+ (None, _) => (
+ button_homebar_style!(GREY_LIGHT, GREY_LIGHT),
+ Gradient::DefaultGrey,
+ ),
}
}
diff --git a/core/embed/rust/src/ui/notification.rs b/core/embed/rust/src/ui/notification.rs
index b7c90cfb..af1de8f2 100644
--- a/core/embed/rust/src/ui/notification.rs
+++ b/core/embed/rust/src/ui/notification.rs
@@ -15,11 +15,16 @@ use crate::micropython::{
pub struct Notification {
pub text: TString<'static>,
pub level: NotificationLevel,
+ pub actionable: bool,
}
impl Notification {
- pub fn new(text: TString<'static>, level: NotificationLevel) -> Self {
- Self { text, level }
+ pub fn new(text: TString<'static>, level: NotificationLevel, actionable: bool) -> Self {
+ Self {
+ text,
+ level,
+ actionable,
+ }
}
}
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index c0510f15..f31ac750 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -605,7 +605,7 @@ def show_group_share_success(
def show_homescreen(
*,
label: str,
- notification: tuple[str, int] | None = None,
+ notification: tuple[str, int, bool] | None = None,
lockable: bool,
skip_first_paint: bool,
) -> LayoutObj[UiResult]:
diff --git a/core/src/apps/homescreen/__init__.py b/core/src/apps/homescreen/__init__.py
index 2f3fd6d9..d6614075 100644
--- a/core/src/apps/homescreen/__init__.py
+++ b/core/src/apps/homescreen/__init__.py
@@ -36,17 +36,38 @@ async def homescreen() -> None:
notification = (
TR.homescreen__title_coinjoin_authorized,
NotificationLevel.SUCCESS,
+ False,
)
elif storage.device.is_initialized() and storage.device.no_backup():
- notification = (TR.homescreen__title_seedless, NotificationLevel.ALERT)
+ notification = (
+ TR.homescreen__title_seedless,
+ NotificationLevel.ALERT,
+ False,
+ )
elif storage.device.is_initialized() and storage.device.unfinished_backup():
- notification = (TR.homescreen__title_backup_failed, NotificationLevel.ALERT)
+ notification = (
+ TR.homescreen__title_backup_failed,
+ NotificationLevel.ALERT,
+ True,
+ )
elif storage.device.is_initialized() and storage.device.needs_backup():
- notification = (TR.homescreen__title_backup_needed, NotificationLevel.WARNING)
+ notification = (
+ TR.homescreen__title_backup_needed,
+ NotificationLevel.WARNING,
+ True,
+ )
elif storage.device.is_initialized() and not config.has_pin():
- notification = (TR.homescreen__title_pin_not_set, NotificationLevel.WARNING)
+ notification = (
+ TR.homescreen__title_pin_not_set,
+ NotificationLevel.WARNING,
+ True,
+ )
elif storage.device.get_experimental_features():
- notification = (TR.homescreen__title_experimental_mode, NotificationLevel.INFO)
+ notification = (
+ TR.homescreen__title_experimental_mode,
+ NotificationLevel.INFO,
+ False,
+ )
obj = Homescreen(
label=label,
diff --git a/core/src/trezor/ui/layouts/homescreen.py b/core/src/trezor/ui/layouts/homescreen.py
index 3cf8ff93..ceed646e 100644
--- a/core/src/trezor/ui/layouts/homescreen.py
+++ b/core/src/trezor/ui/layouts/homescreen.py
@@ -65,7 +65,7 @@ class Homescreen(HomescreenBase):
def __init__(
self,
label: str | None,
- notification: Tuple[str, int] | None,
+ notification: Tuple[str, int, bool] | None,
lockable: bool,
) -> None:
super().__init__(
Why this scored 18/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.