feat(core/eckhart): LED effect in HoldToConfirm
What changed, and why it matters
This commit adds a visual LED effect and a short 'finalized' animation to the hold-to-confirm action on Trezor's newer Eckhart-style user interface. It changes when the device reports a confirmation: instead of confirming the instant the user releases the button, the device now waits for a brief 500 ms animation to finish. During that animation it ignores further button input and lights the RGB LED in a color matching the on-screen confirmation. There is no indication this fixes a security bug; it appears to be a user-experience and visual-feedback feature.
No security action required. Treat as a normal UI/UX feature review. If desired, verify that the 500 ms finalize delay and input suppression do not conflict with accessibility or timeout requirements, and that the LED color mapping cannot be triggered outside the intended confirmation flow.
Security signals we found
Behavioral change in confirmation timing: confirmation is now deferred until after a 500 ms finalize animation completes
Input suppression during finalization: ActionBar ignores button events while awaiting_finalize is true
RGB LED control added in response to user confirmation, gated by the rgb_led feature flag
No bounds-checking, cryptographic, memory-safety, or privilege changes observed
Evidence from the diff
The patch refactors HoldToConfirmAnim in core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs. It replaces separate timer/rollback fields with an AnimState enum (Idle, Growing, RollingBack, Finalizing), adds a finalize() method that starts a 500 ms Finalizing state and sets the RGB LED via rgb_led::set_color, and emits HoldToConfirmMsg::Finalized when that animation ends. ActionBar now awaits the Finalized message before returning ActionBarMsg::Confirmed, setting awaiting_finalize = true to drop button events during the animation. A color_to_led_color helper is added in theme/mod.rs to map UI colors to LED colors. No changelog entry is requested.
Changed components
core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rscore/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rscore/embed/rust/src/ui/layout_eckhart/firmware/mod.rscore/embed/rust/src/ui/layout_eckhart/theme/mod.rsInspect captured patch +183 / −72
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
index 54cccef8..8e413307 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
@@ -38,6 +38,8 @@ pub struct ActionBar {
prev_button: Button,
/// Right button for paginated content
next_button: Button,
+ /// Whether we are waiting for the finalize animation to complete
+ awaiting_finalize: bool,
}
pub enum ActionBarMsg {
@@ -265,6 +267,7 @@ impl ActionBar {
next_button: Button::with_icon(theme::ICON_CHEVRON_DOWN)
.with_expanded_touch_area(Self::BUTTON_EXPAND_TOUCH)
.with_content_offset(Self::BUTTON_CONTENT_OFFSET.neg()),
+ awaiting_finalize: false,
}
}
@@ -367,20 +370,8 @@ impl ActionBar {
}
}
}
-}
-
-impl Component for ActionBar {
- type Msg = ActionBarMsg;
-
- fn place(&mut self, bounds: Rect) -> Rect {
- debug_assert_eq!(bounds.height(), Self::ACTION_BAR_HEIGHT);
- self.place_buttons(bounds);
- self.area = bounds;
- bounds
- }
- fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
- self.htc_anim.event(ctx, event);
+ fn handle_event_buttons(&mut self, ctx: &mut EventCtx, event: Event) -> Option<ActionBarMsg> {
match &self.mode {
Mode::Timeout => {
if self
@@ -469,9 +460,47 @@ impl Component for ActionBar {
}
}
}
- }
+ };
None
}
+}
+
+impl Component for ActionBar {
+ type Msg = ActionBarMsg;
+
+ fn place(&mut self, bounds: Rect) -> Rect {
+ debug_assert_eq!(bounds.height(), Self::ACTION_BAR_HEIGHT);
+ self.place_buttons(bounds);
+ self.area = bounds;
+ bounds
+ }
+
+ fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
+ let htc_event = self.htc_anim.event(ctx, event);
+
+ if let Some(super::HoldToConfirmMsg::Finalized) = htc_event {
+ return Some(ActionBarMsg::Confirmed);
+ }
+
+ if self.awaiting_finalize {
+ // Ignore button input while finalizing animation runs
+ return None;
+ }
+
+ let result = self.handle_event_buttons(ctx, event);
+
+ match (result, self.htc_anim.as_mut()) {
+ (Some(ActionBarMsg::Confirmed), Some(htc_anim)) => {
+ self.awaiting_finalize = true;
+ htc_anim.finalize();
+ ctx.request_anim_frame();
+ ctx.request_paint();
+ None
+ }
+ (Some(msg), _) => Some(msg),
+ _ => None,
+ }
+ }
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let show_divider = match self.mode {
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
index 10aa709a..e0f92c8c 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
@@ -2,9 +2,9 @@ use crate::{
strutil::TString,
time::{Duration, Stopwatch},
ui::{
- component::{Component, Event, EventCtx, Never},
+ component::{Component, Event, EventCtx},
display::Color,
- geometry::{Offset, Rect},
+ geometry::{Alignment2D, Insets, Offset, Rect},
lerp::Lerp,
shape::{self, Renderer},
},
@@ -21,6 +21,9 @@ use pareen;
#[cfg(feature = "haptic")]
use crate::trezorhal::haptic;
+#[cfg(feature = "rgb_led")]
+use crate::trezorhal::rgb_led;
+
/// A component that displays a border that grows from the bottom of the screen
/// to the top. The animation is parametrizable by color and duration.
pub struct HoldToConfirmAnim {
@@ -30,20 +33,32 @@ pub struct HoldToConfirmAnim {
color: Color,
/// Screen border shape
border: ScreenBorder,
- /// Timer for the animation
- timer: Stopwatch,
/// Header overlay text shown during the animation
header_overlay: Option<TString<'static>>,
- /// Rollback animation state
- rollback: RollbackState,
+ /// Animation state
+ state: AnimState,
}
-/// State of the rollback animation, when `stop` is called.
-struct RollbackState {
- /// Timer for the rollback animation
- timer: Stopwatch,
- /// Point in time of the growth animation when the rollback was initiated
- duration: Duration,
+pub enum HoldToConfirmMsg {
+ /// The hold to confirm action was completed
+ Finalized,
+}
+
+enum AnimState {
+ Idle,
+ /// Growing border from start
+ Growing {
+ stopwatch: Stopwatch,
+ },
+ /// State of the rollback animation, when `stop` is called.
+ RollingBack {
+ stopwatch: Stopwatch,
+ started_at: Duration,
+ },
+ /// Finalizing animation after confirmation, when `finalize` is called.
+ Finalizing {
+ stopwatch: Stopwatch,
+ },
}
impl HoldToConfirmAnim {
@@ -57,6 +72,8 @@ impl HoldToConfirmAnim {
/// Duration ratio for the rollback animation after `stop` is called
const ROLLBACK_DURATION_RATIO: f32 = Self::TOP_DURATION_RATIO;
+ const FINALIZING_DURATION: Duration = Duration::from_millis(500);
+
/// Duration after which the header overlay is shown after `start` is called
const HEADER_OVERLAY_DELAY: Duration = Duration::from_millis(300);
@@ -66,12 +83,8 @@ impl HoldToConfirmAnim {
total_duration: theme::CONFIRM_HOLD_DURATION.into(),
color: default_color,
border: ScreenBorder::new(default_color),
- timer: Stopwatch::default(),
header_overlay: None,
- rollback: RollbackState {
- timer: Stopwatch::default(),
- duration: Duration::default(),
- },
+ state: AnimState::Idle,
}
}
@@ -92,23 +105,40 @@ impl HoldToConfirmAnim {
}
pub fn start(&mut self) {
- self.timer = Stopwatch::new_started();
+ self.state = AnimState::Growing {
+ stopwatch: Stopwatch::new_started(),
+ };
}
pub fn stop(&mut self) {
- self.rollback.timer = Stopwatch::new_started();
- self.rollback.duration = self.timer.elapsed();
- self.timer = Stopwatch::new_stopped();
+ if let AnimState::Growing { stopwatch } = &self.state {
+ let started_at = stopwatch.elapsed();
+ self.state = AnimState::RollingBack {
+ stopwatch: Stopwatch::new_started(),
+ started_at,
+ };
+ }
}
- fn is_active(&self) -> bool {
- self.timer.is_running_within(self.total_duration)
+ pub fn finalize(&mut self) {
+ #[cfg(feature = "rgb_led")]
+ rgb_led::set_color(theme::color_to_led_color(self.color).into());
+ self.state = AnimState::Finalizing {
+ stopwatch: Stopwatch::new_started(),
+ };
}
- fn is_rollback(&self) -> bool {
- self.rollback
- .timer
- .is_running_within(self.rollback_duration())
+ fn is_animating(&self) -> bool {
+ match &self.state {
+ AnimState::Idle => false,
+ AnimState::Growing { stopwatch } => stopwatch.is_running_within(self.total_duration),
+ AnimState::RollingBack { stopwatch, .. } => {
+ stopwatch.is_running_within(self.rollback_duration())
+ }
+ AnimState::Finalizing { stopwatch } => {
+ stopwatch.is_running_within(Self::FINALIZING_DURATION)
+ }
+ }
}
fn rollback_duration(&self) -> Duration {
@@ -117,7 +147,7 @@ impl HoldToConfirmAnim {
}
impl Component for HoldToConfirmAnim {
- type Msg = Never;
+ type Msg = HoldToConfirmMsg;
fn place(&mut self, bounds: Rect) -> Rect {
bounds
@@ -125,43 +155,56 @@ impl Component for HoldToConfirmAnim {
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
- if self.is_active() || self.is_rollback() {
+ if self.is_animating() {
ctx.request_anim_frame();
ctx.request_paint();
}
- };
+ // Finalizing just completed
+ if let AnimState::Finalizing { stopwatch } = &self.state {
+ if stopwatch.is_running() && !stopwatch.is_running_within(Self::FINALIZING_DURATION)
+ {
+ #[cfg(feature = "rgb_led")]
+ rgb_led::set_color(0);
+ return Some(HoldToConfirmMsg::Finalized);
+ }
+ }
+ }
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- // Rollback & Fading out animation
- if self.is_rollback() {
- let rollback_elapsed = self.rollback.timer.elapsed();
- let alpha = self.get_rollback_alpha(rollback_elapsed);
- let rollback_duration_progressed = self
- .rollback
- .duration
- .checked_add(rollback_elapsed)
- .unwrap_or_default();
- let (clip, top_gap) = self.get_clips(rollback_duration_progressed);
- let top_back_rollback = self.get_top_gap_rollback(rollback_elapsed);
- let top_gap = top_gap.union(top_back_rollback);
- self.render_clipped_border(clip, top_gap, alpha, target);
- }
-
- // Growing animation
- if self.is_active() {
- let elapsed = self.timer.elapsed();
- // override header with custom text
- if elapsed > Self::HEADER_OVERLAY_DELAY {
- self.render_header_overlay(target);
+ match &self.state {
+ AnimState::Idle => {}
+ AnimState::RollingBack {
+ stopwatch,
+ started_at,
+ } => {
+ let rollback_elapsed = stopwatch.elapsed();
+ let alpha = self.get_rollback_alpha(rollback_elapsed);
+ let rollback_duration_progressed =
+ started_at.checked_add(rollback_elapsed).unwrap_or_default();
+ let (clip, top_gap) = self.get_clips(rollback_duration_progressed);
+ let top_back_rollback = self.get_top_gap_rollback(rollback_elapsed);
+ let top_gap = top_gap.union(top_back_rollback);
+ self.render_clipped_border(clip, top_gap, alpha, target);
+ }
+ AnimState::Growing { stopwatch } => {
+ let elapsed = stopwatch.elapsed();
+ if elapsed > Self::HEADER_OVERLAY_DELAY {
+ self.render_header_overlay(target);
+ }
+ let (clip, top_gap) = self.get_clips(elapsed);
+ self.render_clipped_border(clip, top_gap, u8::MAX, target);
+
+ #[cfg(feature = "haptic")]
+ haptic::play_custom(self.get_haptic(elapsed), 100);
+ }
+ AnimState::Finalizing { stopwatch } => {
+ let elapsed = stopwatch.elapsed();
+ let alpha = self.get_finalizing_alpha(elapsed);
+ self.render_done_icon(alpha, target);
+ self.border.render(alpha, target);
}
- // growing border
- let (clip, top_gap) = self.get_clips(elapsed);
- self.render_clipped_border(clip, top_gap, u8::MAX, target);
-
- #[cfg(feature = "haptic")]
- haptic::play_custom(self.get_haptic(elapsed), 100);
}
}
}
@@ -192,6 +235,22 @@ impl HoldToConfirmAnim {
});
}
}
+ fn render_done_icon<'s>(&'s self, alpha: u8, target: &mut impl Renderer<'s>) {
+ let icon = theme::ICON_DONE;
+ let header_pad = Rect::from_top_left_and_size(
+ SCREEN.top_left(),
+ Offset::new(SCREEN.width(), Header::HEADER_HEIGHT),
+ );
+ let icon_area = header_pad.inset(Insets::left(theme::PADDING));
+ shape::Bar::new(header_pad)
+ .with_bg(theme::BG)
+ .render(target);
+ shape::ToifImage::new(icon_area.left_center(), icon.toif)
+ .with_fg(self.color)
+ .with_alpha(alpha)
+ .with_align(Alignment2D::CENTER_LEFT)
+ .render(target);
+ }
fn render_clipped_border<'s>(
&'s self,
@@ -221,6 +280,17 @@ impl HoldToConfirmAnim {
u8::lerp(u8::MAX, u8::MIN, shift.eval(progress))
}
+ fn get_finalizing_alpha(&self, elapsed: Duration) -> u8 {
+ let progress = (elapsed / Self::FINALIZING_DURATION).clamp(0.0, 1.0);
+ let shift = pareen::constant(0.0).seq_ease_in(
+ 0.0,
+ easer::functions::Cubic,
+ 1.0,
+ pareen::constant(1.0),
+ );
+ u8::lerp(u8::MAX, u8::MIN, shift.eval(progress))
+ }
+
fn get_top_gap_rollback(&self, elapsed: Duration) -> Rect {
let progress = (elapsed / self.rollback_duration()).clamp(0.0, 1.0);
let clip_width = (progress * SCREEN.width() as f32) as i16;
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 3432db83..e55cc9c7 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/mod.rs
@@ -29,7 +29,7 @@ pub use device_menu_screen::{DeviceMenuMsg, DeviceMenuScreen};
pub use fido::{FidoAccountName, FidoCredential};
pub use header::{Header, HeaderMsg};
pub use hint::Hint;
-pub use hold_to_confirm::HoldToConfirmAnim;
+pub use hold_to_confirm::{HoldToConfirmAnim, HoldToConfirmMsg};
pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg};
pub use keyboard::{
bip39::Bip39Input,
diff --git a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs
index aee0bbde..3c1b9d4f 100644
--- a/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/theme/mod.rs
@@ -60,6 +60,18 @@ pub const LED_ORANGE: Color = Color::rgb(0xBC, 0x2A, 0x06);
pub const LED_RED: Color = Color::rgb(0x64, 0x06, 0x03);
pub const LED_YELLOW: Color = Color::rgb(0x16, 0x10, 0x00);
pub const LED_BLUE: Color = Color::rgb(0x05, 0x05, 0x32);
+pub const fn color_to_led_color(color: Color) -> Color {
+ match color {
+ WHITE => LED_WHITE,
+ GREEN_LIME => LED_GREEN_LIME,
+ GREEN_LIGHT => LED_GREEN_LIGHT,
+ ORANGE => LED_ORANGE,
+ RED => LED_RED,
+ YELLOW => LED_YELLOW,
+ BLUE => LED_BLUE,
+ _ => color,
+ }
+}
// Common constants
pub const PADDING: i16 = 24; // [px]
Why this scored 17/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.