chore(core/eckhart): fix mnemonic UI deviations
What changed, and why it matters
This commit is a cosmetic user-interface fix for the Trezor hardware wallet's new 'Eckhart' layout. It adjusts how recovery seed words are displayed on screen—text alignment, prompt placement, and spacing—without changing any security logic, cryptography, or how secrets are handled.
No security action required; treat as a normal UI polish commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change modifies Rust UI code for the Eckhart layout’s BIP-39/SLIP-39 mnemonic entry keyboards. It replaces a shared prompt/input ‘Maybe’ wrapper with separate prompt and word-number labels, centers text using area-relative offsets, updates translation strings (including a new ‘Start entering’ prompt and a line-broken ‘Word X\nof Y’ for Eckhart), and renames constants for clarity. No cryptographic, validation, or secret-management code is touched.
Changed components
core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/bip39.rscore/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mnemonic.rscore/embed/rust/src/ui/layout_eckhart/firmware/keyboard/slip39.rscore/embed/rust/src/translations/generated/translated_string.rscore/translations/en.jsonInspect captured patch +119 / −104
diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h
index 98d6de86..2b92a30c 100644
--- a/core/embed/rust/librust_qstr.h
+++ b/core/embed/rust/librust_qstr.h
@@ -576,6 +576,7 @@ static void _librust_qstrs(void) {
MP_QSTR_recovery__share_from_another_multi_share_backup;
MP_QSTR_recovery__share_from_group_entered_template;
MP_QSTR_recovery__share_num_template;
+ MP_QSTR_recovery__start_entering;
MP_QSTR_recovery__title;
MP_QSTR_recovery__title_cancel_dry_run;
MP_QSTR_recovery__title_cancel_recovery;
diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs
index 585180d0..8b9c68d3 100644
--- a/core/embed/rust/src/translations/generated/translated_string.rs
+++ b/core/embed/rust/src/translations/generated/translated_string.rs
@@ -785,7 +785,7 @@ pub enum TranslatedString {
recovery__wanna_cancel_dry_run = 526, // "Are you sure you want to cancel the backup check?"
recovery__wanna_cancel_recovery = 527, // "Are you sure you want to cancel the recovery process?"
recovery__word_count_template = 528, // "({0} words)"
- recovery__word_x_of_y_template = 529, // "Word {0} of {1}"
+ recovery__word_x_of_y_template = 529, // {"Bolt": "Word {0} of {1}", "Caesar": "Word {0} of {1}", "Delizia": "Word {0} of {1}", "Eckhart": "Word {0}\nof {1}"}
recovery__x_more_items_starting_template_plural = 530, // {"Bolt": "{count} more {plural} starting", "Caesar": "{count} more {plural} starting", "Delizia": "{count} more {plural} starting", "Eckhart": "You need {count} more {plural} starting"}
recovery__x_more_shares_needed_template_plural = 531, // {"Bolt": "{count} more {plural} needed", "Caesar": "{count} more {plural} needed", "Delizia": "{count} more {plural} needed", "Eckhart": "{count} more {plural} needed."}
recovery__x_of_y_entered_template = 532, // {"Bolt": "{0} of {1} shares entered", "Caesar": "{0} of {1} shares entered", "Delizia": "{0} of {1} shares entered", "Eckhart": "{0} of {1} shares entered."}
@@ -1510,6 +1510,7 @@ pub enum TranslatedString {
words__set = 1119, // "Set"
words__wipe = 1120, // "Wipe"
lockscreen__unlock = 1121, // "Unlock"
+ recovery__start_entering = 1122, // "Start entering"
}
impl TranslatedString {
@@ -2451,7 +2452,14 @@ impl TranslatedString {
(Self::recovery__wanna_cancel_dry_run, "Are you sure you want to cancel the backup check?"),
(Self::recovery__wanna_cancel_recovery, "Are you sure you want to cancel the recovery process?"),
(Self::recovery__word_count_template, "({0} words)"),
+ #[cfg(feature = "layout_bolt")]
+ (Self::recovery__word_x_of_y_template, "Word {0} of {1}"),
+ #[cfg(feature = "layout_caesar")]
(Self::recovery__word_x_of_y_template, "Word {0} of {1}"),
+ #[cfg(feature = "layout_delizia")]
+ (Self::recovery__word_x_of_y_template, "Word {0} of {1}"),
+ #[cfg(feature = "layout_eckhart")]
+ (Self::recovery__word_x_of_y_template, "Word {0}\nof {1}"),
#[cfg(feature = "layout_bolt")]
(Self::recovery__x_more_items_starting_template_plural, "{count} more {plural} starting"),
#[cfg(feature = "layout_caesar")]
@@ -3339,6 +3347,7 @@ impl TranslatedString {
(Self::words__set, "Set"),
(Self::words__wipe, "Wipe"),
(Self::lockscreen__unlock, "Unlock"),
+ (Self::recovery__start_entering, "Start entering"),
];
#[cfg(feature = "micropython")]
@@ -4290,6 +4299,7 @@ impl TranslatedString {
(Qstr::MP_QSTR_recovery__share_from_another_multi_share_backup, Self::recovery__share_from_another_multi_share_backup),
(Qstr::MP_QSTR_recovery__share_from_group_entered_template, Self::recovery__share_from_group_entered_template),
(Qstr::MP_QSTR_recovery__share_num_template, Self::recovery__share_num_template),
+ (Qstr::MP_QSTR_recovery__start_entering, Self::recovery__start_entering),
(Qstr::MP_QSTR_recovery__title, Self::recovery__title),
(Qstr::MP_QSTR_recovery__title_cancel_dry_run, Self::recovery__title_cancel_dry_run),
(Qstr::MP_QSTR_recovery__title_cancel_recovery, Self::recovery__title_cancel_recovery),
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/bip39.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/bip39.rs
index 0e1198a5..f40b5571 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/bip39.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/bip39.rs
@@ -2,14 +2,13 @@ use crate::{
trezorhal::bip39,
ui::{
component::{text::common::TextBox, Component, Event, EventCtx},
- geometry::{Alignment, Offset, Point, Rect},
+ geometry::{Alignment, Offset, Rect},
shape::{Renderer, Text},
},
};
use super::super::super::{
component::{Button, ButtonMsg},
- constant::WIDTH,
firmware::keyboard::{
common::{render_pending_marker, MultiTapKeyboard},
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
@@ -122,40 +121,42 @@ impl Component for Bip39Input {
let area = self.button.area();
let style = self.button.style();
let suggestion_style = self.button_suggestion.style();
+ let entered = self.textbox.content();
+ let entered_width = style.font.text_width(entered);
- // Paint the entered content (the prefix of the suggested word).
- let text = self.textbox.content();
- let width = style.font.text_width(text);
-
- // User input together with suggestion is centered vertically in the input area
- // and centered horizontally on the screen
- let text_base_y = area.left_center().y + style.font.allcase_text_height() / 2;
- let text_base_x = if let Some(word) = self.suggested_word {
- style.font.horz_center(0, WIDTH, word)
+ let shown_width = if let Some(suggested) = self.suggested_word {
+ style.font.text_width(suggested)
} else {
- style.font.horz_center(0, WIDTH, text)
+ entered_width
};
- let text_base = Point::new(text_base_x, text_base_y);
+
+ // User input together with suggestion is centered vertically in the input area
+ let mut cursor = area.center().ofs(Offset::new(
+ -shown_width / 2,
+ style.font.visible_text_height("1") / 2,
+ ));
self.button.render(target);
- // Render text input + suggested completion
- Text::new(text_base, text, style.font)
+ // Render entered text input
+ Text::new(cursor, entered, style.font)
.with_fg(style.text_color)
.with_align(Alignment::Start)
.render(target);
- if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) {
- let word_baseline = text_base + Offset::x(width);
- Text::new(word_baseline, word, style.font)
+
+ // Render the pending marker.
+ if self.multi_tap.pending_key().is_some() {
+ render_pending_marker(target, cursor, entered, style.font, style.text_color);
+ }
+
+ // Render remaining of the suggested word if any
+ if let Some(suggested) = self.suggested_word.and_then(|w| w.get(entered.len()..)) {
+ cursor.x += entered_width;
+ Text::new(cursor, suggested, style.font)
.with_fg(suggestion_style.text_color)
.with_align(Alignment::Start)
.render(target);
}
-
- // Paint the pending marker.
- if self.multi_tap.pending_key().is_some() {
- render_pending_marker(target, text_base, text, style.font, style.text_color);
- }
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mnemonic.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mnemonic.rs
index d6f9f98e..90619984 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mnemonic.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/mnemonic.rs
@@ -1,7 +1,8 @@
use crate::{
strutil::TString,
+ translations::TR,
ui::{
- component::{Component, Event, EventCtx, Label, Maybe},
+ component::{Component, Event, EventCtx, Label},
geometry::Rect,
shape::Renderer,
},
@@ -26,9 +27,10 @@ pub enum MnemonicKeyboardMsg {
pub struct MnemonicKeyboard<T> {
/// Initial prompt, displayed on empty input.
- prompt: Maybe<Label<'static>>,
+ prompt: Label<'static>,
+ word_prompt: Label<'static>,
/// Input area, acting as the auto-complete.
- input: Maybe<T>,
+ input: T,
/// Key buttons.
keypad: Keypad,
/// Whether going back is allowed (is not on the very first word).
@@ -40,37 +42,36 @@ where
T: MnemonicInput,
{
pub const KEY_COUNT: usize = 9;
- pub fn new(input: T, prompt: TString<'static>, can_go_back: bool) -> Self {
- // Input might be already pre-filled
- let prompt_visible = input.is_empty();
-
+ // Ad hoc number that so that all languages can reasonably show the word number
+ // prompt
+ const WORD_PROMPT_WIDTH: i16 = 85;
+ pub fn new(input: T, words: TString<'static>, can_go_back: bool) -> Self {
let keypad_content: [_; MNEMONIC_KEY_COUNT] =
core::array::from_fn(|idx| ButtonContent::single_line_text(T::keys()[idx].into()));
Self {
- prompt: Maybe::new(
- theme::BG,
- Label::centered(prompt, theme::TEXT_SMALL).vertically_centered(),
- prompt_visible,
- ),
+ prompt: Label::left_aligned(
+ TR::recovery__start_entering.into(),
+ theme::firmware::TEXT_SMALL,
+ )
+ .vertically_centered(),
+ word_prompt: Label::centered(words, theme::TEXT_SMALL_LIGHT).vertically_centered(),
keypad: Keypad::new_hidden().with_keys_content(&keypad_content),
can_go_back,
- input: Maybe::new(theme::BG, input, !prompt_visible),
+ input,
}
}
fn on_input_change(&mut self, ctx: &mut EventCtx) {
self.toggle_buttons(ctx);
- self.toggle_prompt_or_input(ctx);
}
/// Either enable or disable the key buttons, depending on the dictionary
/// completion mask and the pending key.
fn toggle_buttons(&mut self, ctx: &mut EventCtx) {
- let input = self.input.inner();
// Enable/disable the key buttons based on the ability to form a valid word.
for idx in 0..Self::KEY_COUNT {
- let state = if input.can_key_press_lead_to_a_valid_word(idx) {
+ let state = if self.input.can_key_press_lead_to_a_valid_word(idx) {
ButtonState::Enabled
} else {
ButtonState::Disabled
@@ -80,7 +81,7 @@ where
}
// Determine states for erase and back buttons
- let (erase_state, back_state) = if input.is_empty() {
+ let (erase_state, back_state) = if self.input.is_empty() {
(
ButtonState::Hidden,
if self.can_go_back {
@@ -94,15 +95,14 @@ where
};
// Determine state and style for the confirm button based on input state
- let confirm_state = if input.is_empty() || input.mnemonic().is_none() {
+ let confirm_state = if self.input.is_empty() || self.input.mnemonic().is_none() {
ButtonState::Hidden
} else {
ButtonState::Enabled
};
- let confirm_style = if input.mnemonic().is_some() {
+ let confirm_style = if self.input.mnemonic().is_some() {
let any_press_can_lead_to_valid_word = || {
- (0..Self::KEY_COUNT)
- .any(|idx| self.input.inner().can_key_press_lead_to_a_valid_word(idx))
+ (0..Self::KEY_COUNT).any(|idx| self.input.can_key_press_lead_to_a_valid_word(idx))
};
if any_press_can_lead_to_valid_word() {
theme::button_keyboard()
@@ -126,17 +126,17 @@ where
.set_button_stylesheet(KeypadButton::Confirm, confirm_style);
}
- /// After edit operations, we need to either show or hide the prompt, the
- /// input, the erase button and the back button.
- fn toggle_prompt_or_input(&mut self, ctx: &mut EventCtx) {
- let input_empty = self.input.inner().is_empty();
- // Prompt is shown if the input is empty.
- self.prompt.show_if(ctx, input_empty);
- self.input.show_if(ctx, !input_empty);
+ fn render_prompt_or_input<'s>(&'s self, target: &mut impl Renderer<'s>) {
+ if self.input.is_empty() {
+ self.prompt.render(target);
+ self.word_prompt.render(target);
+ } else {
+ self.input.render(target);
+ }
}
pub fn mnemonic(&self) -> Option<&'static str> {
- self.input.inner().mnemonic()
+ self.input.mnemonic()
}
}
@@ -155,11 +155,14 @@ where
let (_, keypad_area) = bounds.split_bottom(KEYPAD_VISIBLE_HEIGHT);
let (input_area, _) = bounds.split_top(INPUT_TOUCH_HEIGHT);
- let prompt_area = input_area.inset(KEYBOARD_INPUT_INSETS);
+ let (prompt_area, word_prompt_area) = input_area
+ .inset(KEYBOARD_INPUT_INSETS)
+ .split_right(Self::WORD_PROMPT_WIDTH);
let input_area = input_area.inset(KEYBOARD_INPUT_INSETS);
// Prompt/input placement
self.prompt.place(prompt_area);
+ self.word_prompt.place(word_prompt_area);
self.input.place(input_area);
// Keypad placement
@@ -188,7 +191,7 @@ where
match self.keypad.event(ctx, event) {
Some(KeypadMsg::Key(idx)) => {
- self.input.inner_mut().on_key_click(ctx, idx);
+ self.input.on_key_click(ctx, idx);
self.on_input_change(ctx);
return None;
}
@@ -199,17 +202,17 @@ where
}
}
Some(KeypadMsg::EraseShort) => {
- self.input.inner_mut().on_backspace_click(ctx);
+ self.input.on_backspace_click(ctx);
self.on_input_change(ctx);
return None;
}
Some(KeypadMsg::EraseLong) => {
- self.input.inner_mut().on_backspace_long_press(ctx);
+ self.input.on_backspace_long_press(ctx);
self.on_input_change(ctx);
return None;
}
Some(KeypadMsg::Confirm) => {
- match self.input.inner_mut().on_confirm_click(ctx) {
+ match self.input.on_confirm_click(ctx) {
Some(MnemonicInputMsg::Confirmed) => {
// Confirmed, bubble up.
return Some(MnemonicKeyboardMsg::Confirmed);
@@ -229,20 +232,12 @@ where
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- let render_prompt_or_input = |target| {
- if self.input.inner().is_empty() {
- self.prompt.render(target);
- } else {
- self.input.render(target);
- }
- };
-
if self.keypad.pressed() {
- render_prompt_or_input(target);
+ self.render_prompt_or_input(target);
self.keypad.render(target);
} else {
self.keypad.render(target);
- render_prompt_or_input(target);
+ self.render_prompt_or_input(target);
}
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/slip39.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/slip39.rs
index b6312a08..16e3a3d8 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/slip39.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/slip39.rs
@@ -127,20 +127,22 @@ impl Component for Slip39Input {
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let area = self.button.area();
let style = self.button.style();
+ let y_offset = style.font.visible_text_height("1") / 2;
if let Some(word) = self.final_word {
// print the final mnemonic
- Text::new(
- area.center() + Offset::y(style.font.allcase_text_height() / 2),
- word,
- style.font,
- )
- .with_fg(style.text_color)
- .with_align(Alignment::Center)
- .render(target);
+ Text::new(area.center().ofs(Offset::y(y_offset)), word, style.font)
+ .with_fg(style.text_color)
+ .with_align(Alignment::Center)
+ .render(target);
} else {
let input_len = self.textbox.content().len();
+ // Skip rendering for empty input
+ if input_len == 0 {
+ return;
+ }
+
// Get last pending character, if any.
let last = self
.multi_tap
@@ -156,31 +158,28 @@ impl Component for Slip39Input {
});
// Initial position for drawing the icons
- let mut cursor = area.center().ofs(Offset::x(-self.size() / 2));
+ let mut cursor = area.center().ofs(Offset::x(-self.width() / 2));
let visible_icons = input_len.saturating_sub(last.is_some() as usize);
- if input_len > 0 {
- for _ in 0..visible_icons {
- ToifImage::new(cursor, Self::ICON.toif)
- .with_align(Alignment2D::TOP_LEFT)
- .with_fg(style.text_color)
- .render(target);
- cursor.x += Self::ICON_SPACE + Self::ICON_WIDTH;
- }
+ for _ in 0..visible_icons {
+ ToifImage::new(cursor, Self::ICON.toif)
+ .with_align(Alignment2D::CENTER_LEFT)
+ .with_fg(style.text_color)
+ .render(target);
+ cursor.x += Self::ICON_SPACING + Self::ICON_WIDTH;
+ }
- if let Some(last) = last {
- // Adapt x and y positions for the character
- cursor.y += style.font.allcase_text_height() / 2;
- cursor.x -= Self::ICON_WIDTH;
+ if let Some(last) = last {
+ // Adapt y position for the last character
+ cursor.y += y_offset;
- // Paint the last character
- Text::new(cursor, last, style.font)
- .with_align(Alignment::Start)
- .with_fg(style.text_color)
- .render(target);
+ // Paint the last character
+ Text::new(cursor, last, style.font)
+ .with_align(Alignment::Start)
+ .with_fg(style.text_color)
+ .render(target);
- render_pending_marker(target, cursor, last, style.font, style.text_color);
- }
+ render_pending_marker(target, cursor, last, style.font, style.text_color);
}
}
}
@@ -189,7 +188,7 @@ impl Component for Slip39Input {
impl Slip39Input {
const ICON: Icon = theme::ICON_DASH_VERTICAL;
const ICON_WIDTH: i16 = Self::ICON.toif.width();
- const ICON_SPACE: i16 = 12;
+ const ICON_SPACING: i16 = 12;
pub fn new() -> Self {
Self {
@@ -303,10 +302,11 @@ impl Slip39Input {
self.textbox.content().parse().ok()
}
- fn size(&self) -> i16 {
+ fn width(&self) -> i16 {
let ndots = self.textbox.content().len();
let mut width = Self::ICON_WIDTH * (ndots as i16);
- width += Self::ICON_SPACE * (ndots.saturating_sub(1) as i16);
+ // the last character is wider than the icon so we count one extra space as well
+ width += Self::ICON_SPACING * (ndots as i16);
width
}
}
diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi
index 0dc3f309..eadbc871 100644
--- a/core/mocks/trezortranslate_keys.pyi
+++ b/core/mocks/trezortranslate_keys.pyi
@@ -643,6 +643,7 @@ class TR:
recovery__share_from_another_multi_share_backup: str = "You have entered a share from a different backup."
recovery__share_from_group_entered_template: str = "Share #{0} from Group #{1} entered."
recovery__share_num_template: str = "Share {0}"
+ recovery__start_entering: str = "Start entering"
recovery__title: str = "Recover wallet"
recovery__title_cancel_dry_run: str = "Cancel backup check"
recovery__title_cancel_recovery: str = "Cancel recovery"
diff --git a/core/translations/en.json b/core/translations/en.json
index f25286f5..22f4fbbf 100644
--- a/core/translations/en.json
+++ b/core/translations/en.json
@@ -795,6 +795,7 @@
"Delizia": "Share {0}",
"Eckhart": "Share #{0}"
},
+ "recovery__start_entering": "Start entering",
"recovery__title": "Recover wallet",
"recovery__title_cancel_dry_run": "Cancel backup check",
"recovery__title_cancel_recovery": "Cancel recovery",
@@ -814,7 +815,12 @@
"recovery__wanna_cancel_dry_run": "Are you sure you want to cancel the backup check?",
"recovery__wanna_cancel_recovery": "Are you sure you want to cancel the recovery process?",
"recovery__word_count_template": "({0} words)",
- "recovery__word_x_of_y_template": "Word {0} of {1}",
+ "recovery__word_x_of_y_template": {
+ "Bolt": "Word {0} of {1}",
+ "Caesar": "Word {0} of {1}",
+ "Delizia": "Word {0} of {1}",
+ "Eckhart": "Word {0}\nof {1}"
+ },
"recovery__x_more_items_starting_template_plural": {
"Bolt": "{count} more {plural} starting",
"Caesar": "{count} more {plural} starting",
diff --git a/core/translations/order.json b/core/translations/order.json
index 7ab97616..dffbbabc 100644
--- a/core/translations/order.json
+++ b/core/translations/order.json
@@ -1120,5 +1120,6 @@
"1118": "wipe__start_again",
"1119": "words__set",
"1120": "words__wipe",
- "1121": "lockscreen__unlock"
+ "1121": "lockscreen__unlock",
+ "1122": "recovery__start_entering"
}
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index 5b0333bb..817ae5d9 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "67ef5350a15c889fcb30e0f785fb1dd8ca46656ff132a2ea248f603698e75f6f",
- "datetime": "2025-08-26T14:23:38.274450+00:00",
- "commit": "2a02a0c154bdae6870612553681a9df306b05340"
+ "merkle_root": "ec617a229738c6408469d3570ec6f78206731997b71c2286fe845f76222a8823",
+ "datetime": "2025-08-26T14:25:08.789484+00:00",
+ "commit": "2131905f505fa633fce27c85a653933addc513be"
},
"history": [
{
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.