What changed, and why it matters
This commit adds a smoother, momentum-based scrolling effect to long menus on the Trezor T3W1 hardware wallet. It is a user-experience improvement, not a security fix. There is no indication it addresses a vulnerability or that an attacker could exploit it.
No security action required. Review as normal UI feature code.
Security signals we found
No security-relevant signals detected in the diff or commit metadata.
Change is a UX feature described as 'Improved scrolling experience in longer menus'.
Evidence from the diff
The patch introduces inertia/coast scrolling to the Eckhart layout’s vertical menu screen. It adds an InertiaState struct that tracks swipe velocity, applies friction over time, and animates continued scrolling after the user lifts their finger. The code clamps velocities, caps displacement to i16 ranges, and stops coasting at boundaries. No security-relevant bug fixes, input sanitization beyond clamping, or cryptographic changes are present.
Changed components
core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rsTrezor T3W1 (Eckhart layout) vertical menu UIInspect captured patch +163 / −2
diff --git a/core/.changelog.d/6551.fixed b/core/.changelog.d/6551.fixed
new file mode 100644
index 00000000..b7fc6698
--- /dev/null
+++ b/core/.changelog.d/6551.fixed
@@ -0,0 +1 @@
+[T3W1] Improved scrolling experience in longer menus.
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 7fa15d83..515fe6da 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
@@ -1,5 +1,6 @@
use crate::{
strutil::TString,
+ time::Instant,
ui::{
component::{
swipe_detect::{SwipeConfig, SwipeSettings},
@@ -32,6 +33,8 @@ pub struct VerticalMenuScreen<T> {
swipe: Option<SwipeDetect>,
/// Swipe configuration
swipe_config: SwipeConfig,
+ /// Inertia scrolling state
+ inertia: InertiaState,
}
pub enum VerticalMenuScreenMsg {
@@ -45,7 +48,7 @@ pub enum VerticalMenuScreenMsg {
}
impl<T: MenuItems> VerticalMenuScreen<T> {
- const TOUCH_SENSITIVITY_DIVIDER: i16 = 12;
+ const TOUCH_SENSITIVITY_DIVIDER: i16 = 8;
const SUBTITLE_STYLE: TextStyle =
theme::TEXT_MEDIUM_GREY.with_line_breaking(LineBreaking::BreakAtWhitespace);
const SUBTITLE_HEIGHT: i16 = 68;
@@ -64,6 +67,7 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
swipe_config: SwipeConfig::new()
.with_swipe(Direction::Up, SwipeSettings::Default)
.with_swipe(Direction::Down, SwipeSettings::Default),
+ inertia: InertiaState::new(),
}
}
@@ -141,6 +145,27 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
return;
}
+ // Handle inertia animation frames
+ if EventCtx::is_anim_frame(event) && self.inertia.is_active() {
+ if let Some(displacement) = self.inertia.advance(ctx) {
+ let current = self.menu.get_offset();
+ // Perform addition in a wider type and clamp to the valid offset range
+ let new_offset_i32 = current as i32 + displacement as i32;
+ let new_offset = new_offset_i32.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
+ self.menu.set_offset(new_offset);
+
+ // If we hit a boundary, stop coasting
+ let clamped_offset = self.menu.get_offset();
+ if clamped_offset != new_offset {
+ self.inertia.stop();
+ }
+
+ self.menu.update_button_states(ctx);
+ ctx.request_paint();
+ }
+ return;
+ }
+
if let Some(swipe) = &mut self.swipe {
// Handle swipe events from the standalone swipe detector or ones coming from
// the flow. These two are mutually exclusive and should not be triggered at the
@@ -151,10 +176,18 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
});
match swipe_event {
- Some(SwipeEvent::Start(_) | SwipeEvent::End(_)) => {
+ Some(SwipeEvent::Start(_)) => {
+ // Cancel any ongoing inertia on new touch
+ self.inertia.stop();
// Lock the base position to scroll around
self.offset_base = self.menu.get_offset();
}
+ Some(SwipeEvent::End(Direction::Up | Direction::Down)) => {
+ // Lock the base position
+ self.offset_base = self.menu.get_offset();
+ // Start coasting if velocity is sufficient
+ self.inertia.start_coast(ctx);
+ }
Some(SwipeEvent::Move(dir @ (Direction::Up | Direction::Down), delta)) => {
// Reduce swipe sensitivity
let delta = delta / Self::TOUCH_SENSITIVITY_DIVIDER;
@@ -166,6 +199,10 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
};
self.menu.set_offset(offset);
+
+ // Record clamped offset for velocity tracking
+ self.inertia.record_move(self.menu.get_offset());
+
self.menu.update_button_states(ctx);
ctx.request_paint();
}
@@ -276,6 +313,129 @@ impl<T: MenuItems> Component for VerticalMenuScreen<T> {
}
}
+/// State for velocity-based inertia scrolling
+struct InertiaState {
+ /// Current scroll velocity in pixels per millisecond
+ velocity: f32,
+ /// Accumulated fractional offset not yet applied
+ remainder: f32,
+ /// Previous offset for velocity calculation
+ last_offset: i16,
+ /// Timestamp of last move event
+ last_move_time: Option<Instant>,
+ /// Timestamp of last animation frame
+ last_frame_time: Option<Instant>,
+}
+
+impl InertiaState {
+ /// Precomputed ln(FRICTION_PER_MS). ln(0.993) ≈ -0.00702
+ const LN_FRICTION_PER_MS: f32 = -0.00702;
+
+ /// Stop coasting below this (px/ms). 0.06 px/ms ~ 1.0 px/frame
+ const MIN_VELOCITY: f32 = 0.06;
+
+ /// Cap velocity at ~2.5 px/ms (~42 px/frame at 60fps).
+ /// Prevents violent flings from overshooting the entire menu.
+ const MAX_VELOCITY: f32 = 2.5;
+
+ /// EMA smoothing. 0.4 gives a good balance: responsive to quick flicks
+ /// while filtering jitter from noisy touch panels.
+ const VELOCITY_SMOOTHING: f32 = 0.4;
+
+ const fn new() -> Self {
+ Self {
+ velocity: 0.0,
+ remainder: 0.0,
+ last_offset: 0,
+ last_move_time: None,
+ last_frame_time: None,
+ }
+ }
+
+ /// Reset all inertia state (e.g. on new touch)
+ fn stop(&mut self) {
+ *self = Self::new();
+ }
+
+ /// Record a move sample and update smoothed velocity
+ fn record_move(&mut self, offset: i16) {
+ let now = Instant::now();
+ if let Some(prev_time) = self.last_move_time {
+ let dt_ms = now.saturating_duration_since(prev_time).to_millis() as f32;
+ if dt_ms > 0.0 {
+ let delta = (offset - self.last_offset) as f32;
+ let instant_velocity = delta / dt_ms;
+ // Exponential moving average
+ self.velocity = self.velocity * (1.0 - Self::VELOCITY_SMOOTHING)
+ + instant_velocity * Self::VELOCITY_SMOOTHING;
+ }
+ }
+ self.last_offset = offset;
+ self.last_move_time = Some(now);
+ }
+
+ /// Check if coasting should start and request the first frame if so
+ fn start_coast(&mut self, ctx: &mut EventCtx) {
+ let now = Instant::now();
+ if let Some(last_move) = self.last_move_time {
+ let idle_ms = now.saturating_duration_since(last_move).to_millis() as f32;
+ // If movement stopped before finger-up, don't launch inertia from stale speed.
+ if idle_ms > 50.0 {
+ self.stop();
+ return;
+ }
+ }
+
+ // Clamp velocity
+ self.velocity = self.velocity.clamp(-Self::MAX_VELOCITY, Self::MAX_VELOCITY);
+
+ if self.velocity.abs() >= Self::MIN_VELOCITY {
+ self.last_frame_time = Some(now);
+ self.remainder = 0.0;
+ ctx.request_anim_frame();
+ } else {
+ self.stop();
+ }
+ }
+
+ /// Returns true if currently coasting
+ fn is_active(&self) -> bool {
+ self.last_frame_time.is_some()
+ }
+
+ /// Advance the coasting by one frame. Returns the integer offset delta
+ /// to apply, or None if coasting has stopped.
+ fn advance(&mut self, ctx: &mut EventCtx) -> Option<i16> {
+ let last_frame_time = self.last_frame_time?;
+ let now = Instant::now();
+ let dt_ms = now.saturating_duration_since(last_frame_time).to_millis() as f32;
+ let dt_ms = dt_ms.min(100.0); // clamp to avoid excessive jumps on long delays
+
+ // Apply velocity to get fractional displacement
+ let displacement = self.velocity * dt_ms + self.remainder;
+ // Clamp to i16 range before truncation to avoid saturating cast surprises
+ let displacement = displacement.clamp(i16::MIN as f32, i16::MAX as f32);
+ // Split into integer part (to apply) and fractional remainder (to accumulate)
+ let int_displacement = displacement as i16;
+ self.remainder = displacement - int_displacement as f32;
+
+ // Apply friction: v *= friction^dt
+ // First-order Taylor: a^dt ≈ 1 + dt*ln(a)
+ let friction = 1.0 + Self::LN_FRICTION_PER_MS * dt_ms;
+ self.velocity *= friction.max(0.0);
+
+ self.last_frame_time = Some(now);
+
+ if self.velocity.abs() < Self::MIN_VELOCITY {
+ self.stop();
+ } else {
+ ctx.request_anim_frame();
+ }
+
+ Some(int_displacement)
+ }
+}
+
#[cfg(feature = "micropython")]
impl<T: MenuItems> Swipable for VerticalMenuScreen<T> {
fn get_swipe_config(&self) -> SwipeConfig {
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.