fix(core): keep vertical offset when reloading device menu
What changed, and why it matters
This commit fixes a visual glitch on the Trezor T3W1 device where the device menu would visibly jump or flicker when it refreshed. The fix preserves the menu's current scroll position across refreshes by passing an extra offset value through the UI API. There is no indication this change addresses a security vulnerability.
No security action required. Treat as a normal UI bugfix.
Security signals we found
No security-relevant signals observed in the diff.
Change is described in changelog as '[T3W1] Fix device menu refresh.'
No input validation, memory safety, cryptographic, or authorization changes are present.
Evidence from the diff
The patch adds an init_submenu_offset parameter to the show_device_menu UI API and returns a next_menu_offset value alongside the existing result tuple. On the Eckhart layout (T3W1), the device menu screen now captures the current vertical scroll offset via current_state() and re-applies it when the same menu is reloaded, preventing flicker. Other layouts (Bolt, Caesar, Delizia) accept but ignore the new parameter. The Python homescreen loop stores and forwards the offset. The change is purely UI/UX state preservation.
Changed components
core/src/apps/homescreen/device_menu.pycore/embed/rust/src/ui/api/firmware_micropython.rscore/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rscore/embed/rust/src/ui/layout_eckhart/component_msg_obj.rscore/embed/rust/src/ui/layout_eckhart/ui_firmware.rscore/embed/rust/src/ui/layout_bolt/ui_firmware.rscore/embed/rust/src/ui/layout_caesar/ui_firmware.rscore/embed/rust/src/ui/layout_delizia/ui_firmware.rscore/embed/rust/src/ui/ui_firmware.rscore/mocks/generated/trezorui_api.pyicore/embed/rust/librust_qstr.hInspect captured patch +70 / −17
diff --git a/core/.changelog.d/7202.fixed b/core/.changelog.d/7202.fixed
new file mode 100644
index 00000000..887d0423
--- /dev/null
+++ b/core/.changelog.d/7202.fixed
@@ -0,0 +1 @@
+[T3W1] Fix device menu refresh.
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index bd7fdcea..b43d19df 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -392,6 +392,7 @@ static void _librust_qstrs(void) {
MP_QSTR_info_button;
MP_QSTR_init;
MP_QSTR_init_submenu_idx;
+ MP_QSTR_init_submenu_offset;
MP_QSTR_inputs__back;
MP_QSTR_inputs__cancel;
MP_QSTR_inputs__delete;
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 368fde29..d4060a91 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -913,6 +913,7 @@ extern "C" fn new_show_device_menu(n_args: usize, args: *const Obj, kwargs: *mut
let init_submenu_idx: Option<u8> = kwargs
.get(Qstr::MP_QSTR_init_submenu_idx)?
.try_into_option()?;
+ let init_submenu_offset: i16 = kwargs.get(Qstr::MP_QSTR_init_submenu_offset)?.try_into()?;
let backup_failed: bool = kwargs.get(Qstr::MP_QSTR_backup_failed)?.try_into()?;
let backup_needed: bool = kwargs.get(Qstr::MP_QSTR_backup_needed)?.try_into()?;
let ble_enabled: bool = kwargs.get(Qstr::MP_QSTR_ble_enabled)?.try_into()?;
@@ -963,6 +964,7 @@ extern "C" fn new_show_device_menu(n_args: usize, args: *const Obj, kwargs: *mut
.try_into_option()?;
let layout = ModelUI::show_device_menu(
init_submenu_idx,
+ init_submenu_offset,
backup_failed,
backup_needed,
ble_enabled,
@@ -1971,6 +1973,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def show_device_menu(
/// *,
/// init_submenu_idx: int | None,
+ /// init_submenu_offset: int,
/// backup_failed: bool,
/// backup_needed: bool,
/// ble_enabled: bool,
@@ -1987,8 +1990,8 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// led_enabled: bool | None,
/// about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]],
/// production_year: str | None,
- /// ) -> LayoutContext[tuple[str, int | None, int]]:
- /// """Show the device menu. Result is a tuple (action, action_arg, parent_menu_id)."""
+ /// ) -> LayoutContext[tuple[str, int | None, int, int]]:
+ /// """Show the device menu. Result is a tuple (action, action_arg, next_menu_id, next_menu_offset)."""
Qstr::MP_QSTR_show_device_menu => obj_fn_kw!(0, new_show_device_menu).as_obj(),
/// def show_pairing_device_name(
diff --git a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
index 3d6be027..f0803b51 100644
--- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -915,6 +915,7 @@ impl FirmwareUI for UIBolt {
fn show_device_menu(
_init_submenu_idx: Option<u8>,
+ _init_submenu_offset: i16,
_backup_failed: bool,
_backup_needed: bool,
_ble_enabled: bool,
diff --git a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
index b6df6b29..d6341cb0 100644
--- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -1098,6 +1098,7 @@ impl FirmwareUI for UICaesar {
fn show_device_menu(
_init_submenu_idx: Option<u8>,
+ _init_submenu_offset: i16,
_backup_failed: bool,
_backup_needed: bool,
_ble_enabled: bool,
diff --git a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
index a939aaa1..6397cbf8 100644
--- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -915,6 +915,7 @@ impl FirmwareUI for UIDelizia {
fn show_device_menu(
_init_submenu_idx: Option<u8>,
+ _init_submenu_offset: i16,
_backup_failed: bool,
_backup_needed: bool,
_ble_enabled: bool,
diff --git a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs
index 5b927e9e..5b7691da 100644
--- a/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs
@@ -159,7 +159,20 @@ impl ComponentMsgObj for DeviceMenuScreen {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
let action_obj = msg.id_to_obj();
let result_obj = msg.args_to_obj();
- let next_menu_obj = self.next_menu_id(msg).to_u8().into();
- new_tuple(&[action_obj, result_obj, next_menu_obj])
+ let next_menu_id = self.next_menu_id(msg);
+ let vertical_offset: u16 = match self.current_state() {
+ // If the same menu will be displayed, reuse current menu offset.
+ Some((current_menu_id, vertical_offset)) if current_menu_id == next_menu_id => {
+ // Only non-negative offsets are used.
+ vertical_offset.try_into().unwrap_or(0)
+ }
+ _ => 0, // Otherwise, don't reapply current menu offset.
+ };
+ new_tuple(&[
+ action_obj,
+ result_obj,
+ next_menu_id.to_u8().into(),
+ vertical_offset.into(),
+ ])
}
}
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 b93e86c8..9828b4d6 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
@@ -42,7 +42,7 @@ use heapless::Vec;
use num_traits::{FromPrimitive, ToPrimitive};
#[repr(u8)]
-#[derive(Copy, Clone, Default, FromPrimitive, ToPrimitive)]
+#[derive(Copy, Clone, Default, FromPrimitive, ToPrimitive, PartialEq, Eq)]
#[cfg_attr(test, derive(Debug))]
pub enum DeviceMenuId {
#[default]
@@ -289,6 +289,7 @@ impl DeviceMenuScreen {
#[allow(clippy::too_many_arguments)]
pub fn new(
init_submenu_idx: Option<u8>,
+ init_submenu_offset: i16,
backup_failed: bool,
backup_needed: bool,
ble_enabled: bool,
@@ -401,7 +402,7 @@ impl DeviceMenuScreen {
.unwrap_or_default();
let init_subscreen = unwrap!(screen.try_resolve_submenu(init_submenu_id));
- screen.set_active_subscreen(init_subscreen);
+ screen.set_active_subscreen(init_subscreen, init_submenu_offset);
Ok(screen)
}
@@ -779,14 +780,15 @@ impl DeviceMenuScreen {
self.subscreens.len() as u8 - 1
}
- fn set_active_subscreen(&mut self, idx: u8) {
+ fn set_active_subscreen(&mut self, idx: u8, offset: i16) {
assert!(usize::from(idx) < self.subscreens.len());
self.active_subscreen = idx;
- self.build_active_subscreen();
+ self.build_active_subscreen(offset);
}
fn activate_subscreen(&mut self, idx: u8, ctx: &mut EventCtx) {
- self.set_active_subscreen(idx);
+ // A new subscreen is shown - previous offset is not reused.
+ self.set_active_subscreen(idx, 0);
self.place(self.bounds);
if let ActiveScreen::Menu(screen, ..) = self.active_screen.deref_mut() {
screen.initialize_screen(ctx);
@@ -795,7 +797,15 @@ impl DeviceMenuScreen {
}
}
- fn build_active_subscreen(&mut self) {
+ /// Used to avoid flickering on menu refresh.
+ pub fn current_state(&self) -> Option<(DeviceMenuId, i16)> {
+ match self.active_screen.deref() {
+ ActiveScreen::Menu(menu, id) => Some((*id, menu.get_offset())),
+ _ => None,
+ }
+ }
+
+ fn build_active_subscreen(&mut self, offset: i16) {
match self.subscreens[usize::from(self.active_subscreen)] {
Subscreen::Submenu(submenu_index, id) => {
let submenu = &self.submenus[usize::from(submenu_index)];
@@ -830,7 +840,8 @@ impl DeviceMenuScreen {
*self.active_screen.deref_mut() = ActiveScreen::Menu(
VerticalMenuScreen::new(menu)
.with_header(header)
- .with_subtitle(submenu.subtitle.unwrap_or(TString::empty())),
+ .with_subtitle(submenu.subtitle.unwrap_or(TString::empty()))
+ .with_initial_offset(offset),
id,
);
}
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 515fe6da..4a6a9a32 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
@@ -35,6 +35,8 @@ pub struct VerticalMenuScreen<T> {
swipe_config: SwipeConfig,
/// Inertia scrolling state
inertia: InertiaState,
+ /// Initial vertical offset
+ initial_offset: i16,
}
pub enum VerticalMenuScreenMsg {
@@ -68,6 +70,7 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
.with_swipe(Direction::Up, SwipeSettings::Default)
.with_swipe(Direction::Down, SwipeSettings::Default),
inertia: InertiaState::new(),
+ initial_offset: 0,
}
}
@@ -86,13 +89,25 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
self
}
+ pub fn with_initial_offset(mut self, offset: i16) -> Self {
+ self.initial_offset = offset;
+ self
+ }
+
+ pub fn get_offset(&self) -> i16 {
+ self.menu.get_offset()
+ }
+
/// Update swipe detection and buttons state based on menu size
pub fn initialize_screen(&mut self, ctx: &mut EventCtx) {
+ // `self.initial_offset` replaced with 0, so next screens are not "resumed".
+ let initial_offset = core::mem::take(&mut self.initial_offset);
+
if animation_disabled() {
self.swipe = Some(SwipeDetect::new());
ctx.enable_swipe();
// Set default position for the sliding window
- self.menu.set_offset(0);
+ self.menu.set_offset(initial_offset);
// Update the menu buttons state
self.menu.update_button_states(ctx);
return;
@@ -110,7 +125,7 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
}
// Set default position for the sliding window
- self.menu.set_offset(0);
+ self.menu.set_offset(initial_offset);
// Update button states
self.menu.update_button_states(ctx);
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
index 1b90be08..1b5f490a 100644
--- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -1086,6 +1086,7 @@ impl FirmwareUI for UIEckhart {
fn show_device_menu(
init_submenu_idx: Option<u8>,
+ init_submenu_offset: i16,
backup_failed: bool,
backup_needed: bool,
ble_enabled: bool,
@@ -1108,6 +1109,7 @@ impl FirmwareUI for UIEckhart {
) -> Result<impl LayoutMaybeTrace, Error> {
let layout = RootComponent::new(DeviceMenuScreen::new(
init_submenu_idx,
+ init_submenu_offset,
backup_failed,
backup_needed,
ble_enabled,
diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs
index c48bb160..df283594 100644
--- a/core/embed/rust/src/ui/ui_firmware.rs
+++ b/core/embed/rust/src/ui/ui_firmware.rs
@@ -336,6 +336,7 @@ pub trait FirmwareUI {
#[allow(clippy::too_many_arguments)]
fn show_device_menu(
init_submenu_idx: Option<u8>,
+ init_submenu_offset: i16,
backup_failed: bool,
backup_needed: bool,
ble_enabled: bool,
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index 8b811fbb..e94baf67 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -631,6 +631,7 @@ def show_homescreen(
def show_device_menu(
*,
init_submenu_idx: int | None,
+ init_submenu_offset: int,
backup_failed: bool,
backup_needed: bool,
ble_enabled: bool,
@@ -647,8 +648,8 @@ def show_device_menu(
led_enabled: bool | None,
about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]],
production_year: str | None,
-) -> LayoutContext[tuple[str, int | None, int]]:
- """Show the device menu. Result is a tuple (action, action_arg, parent_menu_id)."""
+) -> LayoutContext[tuple[str, int | None, int, int]]:
+ """Show the device menu. Result is a tuple (action, action_arg, next_menu_id, next_menu_offset)."""
# rust/src/ui/api/firmware_micropython.rs
diff --git a/core/src/apps/homescreen/device_menu.py b/core/src/apps/homescreen/device_menu.py
index 7ad342e0..d7ebe2f0 100644
--- a/core/src/apps/homescreen/device_menu.py
+++ b/core/src/apps/homescreen/device_menu.py
@@ -74,6 +74,7 @@ async def handle_device_menu() -> None:
from trezor.wire.thp import paired_cache
init_submenu_idx = None
+ init_submenu_offset = 0
# Remain in the device loop until the menu is explicitly closed
while True:
@@ -135,6 +136,7 @@ async def handle_device_menu() -> None:
with trezorui_api.show_device_menu(
init_submenu_idx=init_submenu_idx,
+ init_submenu_offset=init_submenu_offset,
backup_failed=backup_failed,
backup_needed=backup_needed,
ble_enabled=ble_enabled,
@@ -168,10 +170,10 @@ async def handle_device_menu() -> None:
layout, br_name=None, layout_type=UsbAwareLayout
)
- if not isinstance(menu_result, tuple) or len(menu_result) != 3:
+ if not isinstance(menu_result, tuple) or len(menu_result) != 4:
raise RuntimeError(f"Unknown menu {menu_result}")
- action, arg, init_submenu_idx = menu_result
+ action, arg, init_submenu_idx, init_submenu_offset = menu_result
handler = _MENU_HANDLERS.get(action)
if not handler:
raise RuntimeError(f"Unknown menu {menu_result}")
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.